Chemenu 2.1.0 - deterministischer Wissenskompiler
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki: raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren Grenzen in Code durchgesetzt sind statt im Prompt. Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44 Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist. Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs. Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte. Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet - siehe NOTICE.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""Read/write markdown files with YAML frontmatter, matching the formatting
|
||||
conventions already used across wiki/ (inline flow-style lists, unquoted
|
||||
dates, two-decimal confidence values).
|
||||
|
||||
We deliberately avoid a generic yaml.dump() for the frontmatter block because
|
||||
PyYAML's default block-style output does not match the existing convention
|
||||
(e.g. `tags: [a, b, c]` on one line). Instead we serialize each top-level key
|
||||
explicitly, preserving dict insertion order.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?(.*)\Z", re.DOTALL)
|
||||
|
||||
|
||||
def read_page(path: Path) -> tuple[dict[str, Any], str]:
|
||||
"""Return (frontmatter_dict, body) for a markdown file. If the file has no
|
||||
frontmatter block, returns ({}, full_text).
|
||||
|
||||
Deliberately permissive: malformed YAML degrades to an empty dict so bulk
|
||||
operations never crash on one bad page. Use `frontmatter_error()` (which
|
||||
`wikitool lint` does) to surface those pages instead of losing them
|
||||
silently.
|
||||
"""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
match = FRONTMATTER_RE.match(text)
|
||||
if not match:
|
||||
return {}, text
|
||||
fm_text, body = match.group(1), match.group(2)
|
||||
try:
|
||||
frontmatter = yaml.safe_load(fm_text) or {}
|
||||
except yaml.YAMLError:
|
||||
frontmatter = {}
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
return frontmatter, body
|
||||
|
||||
|
||||
def frontmatter_error(path: Path) -> str | None:
|
||||
"""Return a human-readable reason why `path`'s frontmatter can't be used,
|
||||
or None if it parses into a dict.
|
||||
|
||||
This is the strict counterpart to `read_page()`: without it, a page whose
|
||||
YAML is malformed (or whose frontmatter block is missing entirely) reads
|
||||
back as `{}` and then quietly slips past every frontmatter-driven check.
|
||||
"""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
match = FRONTMATTER_RE.match(text)
|
||||
if not match:
|
||||
return "no `---` frontmatter block"
|
||||
try:
|
||||
parsed = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError as exc:
|
||||
reason = str(exc).splitlines()[0] if str(exc) else exc.__class__.__name__
|
||||
return f"invalid YAML frontmatter: {reason}"
|
||||
if parsed is None:
|
||||
return "empty frontmatter block"
|
||||
if not isinstance(parsed, dict):
|
||||
return f"frontmatter is {type(parsed).__name__}, expected a mapping"
|
||||
return None
|
||||
|
||||
|
||||
def _format_scalar(value: Any, flow: bool = False) -> str:
|
||||
"""Render one frontmatter value. `flow` says it is going inside a `[...]`
|
||||
sequence, where more characters are indicators than at document level."""
|
||||
if value is None:
|
||||
return '""'
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, float):
|
||||
return f"{value:.2f}"
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, (datetime.date, datetime.datetime)):
|
||||
return value.isoformat()
|
||||
text = str(value)
|
||||
needs_quoting = (
|
||||
text == ""
|
||||
or text[0] in "[{'\"#&*!|>%@`"
|
||||
or text.strip() != text
|
||||
or ": " in text
|
||||
or not _round_trips_as_string(text, flow=flow)
|
||||
)
|
||||
if needs_quoting:
|
||||
return _quote(text)
|
||||
return text
|
||||
|
||||
|
||||
def _quote(text: str) -> str:
|
||||
"""Render `text` as a quoted YAML scalar valid in any context.
|
||||
|
||||
The dumper is asked for a one-element flow sequence and the brackets are
|
||||
taken off again, rather than a bare scalar: a bare plain scalar comes back
|
||||
with a `...` document-end marker attached, which is correct YAML for a
|
||||
document and nonsense inside a `[...]` list. Going through the library
|
||||
either way keeps the escaping rules where they belong - the same reason
|
||||
`_round_trips_as_string` asks the loader instead of listing cases.
|
||||
"""
|
||||
dumped = yaml.safe_dump([text], default_flow_style=True, width=10**9).strip()
|
||||
return dumped[1:-1].strip()
|
||||
|
||||
|
||||
def _round_trips_as_string(text: str, flow: bool = False) -> bool:
|
||||
"""Whether writing `text` unquoted would read back as the same string.
|
||||
|
||||
A string that merely *looks* like another YAML type comes back as that type:
|
||||
`"1945"` written bare is an int on the next read, and a schema expecting a
|
||||
string then rejects a page nothing visibly changed. The same holds for
|
||||
floats, YAML 1.1's `yes`/`no`/`on`/`off` booleans, `null`, and forms like
|
||||
`0x1F` or `1_000`.
|
||||
|
||||
Asking the loader instead of listing the cases is deliberate: the reader and
|
||||
the writer then agree by construction, and a resolver rule this code never
|
||||
heard of cannot drift out from under it.
|
||||
|
||||
Dates need no exception here. A date field holds a `datetime.date`, which
|
||||
`_format_scalar` renders bare before ever reaching this function - see
|
||||
`normalize_dates` for the other half of that contract.
|
||||
|
||||
`flow` asks the question in the context list values are actually written
|
||||
in. At document level a comma is an ordinary character, so the probe says
|
||||
"safe to write bare"; inside the `[...]` this file emits for every list it
|
||||
is an indicator, and the value silently comes back as two elements. That is
|
||||
how a `raw_files:` entry naming a file with a comma in its name lost half
|
||||
of itself on write - after `--set` had parsed it correctly.
|
||||
"""
|
||||
probe, expected = (f"[{text}]", [text]) if flow else (text, text)
|
||||
try:
|
||||
return yaml.safe_load(probe) == expected
|
||||
except yaml.YAMLError:
|
||||
# Unparseable bare - quoting is exactly the fix.
|
||||
return False
|
||||
|
||||
|
||||
def normalize_dates(frontmatter: dict[str, Any]) -> dict[str, Any]:
|
||||
"""A copy of `frontmatter` with every `datetime.date` rendered as an ISO
|
||||
string, at the top level and one list deep.
|
||||
|
||||
Frontmatter carries dates as `datetime.date` - that is what `yaml.safe_load`
|
||||
produces for a bare `2026-08-29`, what `_format_scalar` writes back bare, and
|
||||
therefore what the whole corpus stores. The schemas nonetheless declare those
|
||||
fields `type: string`, so anything validating raw frontmatter has to convert
|
||||
first.
|
||||
|
||||
This lives here rather than beside one validator because there are two, and
|
||||
only one of them used to do it: `TypeResolver.validate_frontmatter` (behind
|
||||
`lint`) normalized, while `touch`'s `validate_fields` did not. The gap was
|
||||
invisible only because `touch` happened to write date *strings*.
|
||||
"""
|
||||
normalized: dict[str, Any] = {}
|
||||
for key, value in frontmatter.items():
|
||||
if isinstance(value, datetime.date):
|
||||
normalized[key] = value.isoformat()
|
||||
elif isinstance(value, list):
|
||||
normalized[key] = [
|
||||
item.isoformat() if isinstance(item, datetime.date) else item for item in value
|
||||
]
|
||||
else:
|
||||
normalized[key] = value
|
||||
return normalized
|
||||
|
||||
|
||||
def _format_list(items: list[Any]) -> str:
|
||||
if not items:
|
||||
return "[]"
|
||||
return "[" + ", ".join(_format_scalar(v, flow=True) for v in items) + "]"
|
||||
|
||||
|
||||
def dump_frontmatter(frontmatter: dict[str, Any]) -> str:
|
||||
lines = []
|
||||
for key, value in frontmatter.items():
|
||||
if isinstance(value, list):
|
||||
lines.append(f"{key}: {_format_list(value)}")
|
||||
else:
|
||||
lines.append(f"{key}: {_format_scalar(value)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_page(path: Path, frontmatter: dict[str, Any], body: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fm_text = dump_frontmatter(frontmatter)
|
||||
if not body.startswith("\n"):
|
||||
body = "\n" + body
|
||||
content = f"---\n{fm_text}\n---{body}"
|
||||
if not content.endswith("\n"):
|
||||
content += "\n"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
Reference in New Issue
Block a user