Files
chemenu/tools/chemenu/commands/new_page.py
T
torben aa31d431fc
CI / verify (push) Successful in 57s
Release / release (push) Successful in 37s
new: scaffold materializes a schema default only for a required field
Files changed:
- CHANGES.md
- VERSION
- instructions/CONTRACT.md
- tools/CONTRACT.md
- tools/chemenu/commands/new_page.py
- tools/chemenu/tests/test_new_page.py
- types/type-spec.md
2026-09-16 21:38:20 +02:00

361 lines
15 KiB
Python

"""Scaffold new wiki pages from type-spec templates.
These commands produce structurally-correct frontmatter and a body
skeleton with TODO placeholders by loading templates from type-spec files.
The prose (Description, Summary, Key Takeaways, ...) is still written by the
LLM afterwards with its normal edit tool.
The split between type-spec templates and LLM-provided prose is intentional:
type definitions (naming, frontmatter shape, directory placement, templates) are
deterministic and stored in /types/; the content is judgment and provided by the LLM.
Frontmatter defaults, enum validity, and required-ness all come from the
type's `.schema.yaml` (via `TypeResolver`) - nothing here re-declares them.
A schema `default:` is materialized only for a field the schema also lists
in `required:` - an optional field's default is a reader-side assumption
(what a missing field means), and writing it into every scaffolded page
would turn that assumption into a stated claim instead (Gitea #109).
Directory placement for subtype-driven types (currently just entities) also
comes from the type-spec, via its `layout:` frontmatter (see
`TypeResolver.get_layout`) - not a hand-maintained Python dict.
"""
from __future__ import annotations
from pathlib import Path
import datetime
from typing import Any, Dict, Optional
import re
import typer
from chemenu import config
from chemenu.commands._util import (
check_collision,
check_raw_files_exist,
fail,
parse_set_fields,
rel_path,
success,
)
from chemenu.frontmatter_io import write_page
from chemenu.type_resolver import resolver
def _default_summary(summary: str) -> str:
"""Scaffold-time placeholder for an unfilled --summary, so schema
validation's `summary` minLength requirement doesn't block page creation.
Matches the same "TODO: add summary" text index_build.py already falls
back to when a page has no frontmatter summary."""
return summary.strip() or "TODO: add summary"
def _resolve_type_and_get_template(type_path: str, source_dir: Path = None):
"""Resolve a type path, load the type-spec, and extract its template."""
type_spec = resolver.load_type_spec(type_path, source_dir)
template = resolver.extract_template(type_spec)
return type_spec, template
def _enum_help(type_path: str, field_name: str) -> str:
"""Build a help string from the schema's own enum, so any text listing a
field's valid values can never drift from what the schema accepts."""
return f"One of: {'|'.join(resolver.get_enum(type_path, field_name))}"
def _parse_date(field_name: str, text: str) -> datetime.date:
"""A `--set <date field>=<value>` as a real date, or a refusal naming it."""
try:
return datetime.date.fromisoformat(text)
except ValueError:
fail(f"--set {field_name}={text!r} must be YYYY-MM-DD.")
def _build_frontmatter(
type_path: str, schema: Optional[Dict[str, Any]], today: datetime.date, explicit: Dict[str, Any]
) -> Dict[str, Any]:
"""Build a page's frontmatter dict in schema-declaration order.
`explicit` supplies every CLI-derived value the caller already has;
fields not in `explicit` get a type-appropriate default (today's date for
date-formatted fields, the scaffold placeholder for `summary`, the
schema's own `default:` where declared *and the field is required*, an
empty list for arrays), or are omitted entirely if optional with no
sensible default (e.g. `source_url`). This is what lets frontmatter
shape - and scaffold-time defaults like `provenance: general` - follow
the schema instead of being hand-declared per CLI command.
A `default:` on an *optional* field (e.g. `instruction.obligation`) is
deliberately not materialized here: it documents what a reader should
assume when the field is absent, not what the scaffold should write.
Writing it anyway turned every scaffolded instruction into one that
falsely claims `obligation: required` - a migration-only field - and
the same read/write distinction is what the schema's own `default:`
doc-comment (`types/instruction.schema.yaml`) already draws (Gitea
#109).
"""
required = set((schema or {}).get("required") or [])
frontmatter: Dict[str, Any] = {"type": type_path}
for field_name, field_schema in (schema or {}).get("properties", {}).items():
if field_name == "type":
continue
if field_name in explicit:
value = explicit[field_name]
# A `--set date=2026-08-23` arrives as a string; the corpus stores
# dates as `datetime.date`, and the schema already says which fields
# those are. Converting here keeps one representation on disk instead
# of leaving it to whoever wrote the CLI call.
if field_schema.get("format") == "date" and isinstance(value, str):
value = _parse_date(field_name, value)
frontmatter[field_name] = value
elif field_name == "summary":
frontmatter[field_name] = _default_summary("")
elif field_name == "author":
resolved_author = config.default_author()
if resolved_author is None:
fail(
"No author configured for this instance. Set `git config user.name`, "
"or export WIKI_AUTHOR to override it, then retry."
)
frontmatter[field_name] = resolved_author
elif field_schema.get("format") == "date":
frontmatter[field_name] = today
elif "default" in field_schema and field_name in required:
frontmatter[field_name] = field_schema["default"]
elif field_schema.get("type") == "array":
frontmatter[field_name] = []
# Carry through any caller-supplied field the schema doesn't declare,
# rather than silently dropping it: a typo'd `--set` must surface as a
# validation error (via `additionalProperties: false`) instead of being
# quietly ignored, and a schema that does allow extra properties should
# keep them.
for field_name, value in explicit.items():
if field_name not in frontmatter:
frontmatter[field_name] = value
return frontmatter
def _filter_bullets(value: Any) -> str:
"""Render a list frontmatter field as `- [[item]]` bullet lines."""
items = value or []
return "\n".join(f"- [[{item}]]" for item in items) if items else "- None identified"
def _filter_join(value: Any) -> str:
"""Comma-join a list frontmatter field."""
return ", ".join(value or [])
def _filter_capitalize(value: Any) -> str:
return str(value).capitalize()
def _filter_table_header(value: Any) -> str:
"""Render an array field as wikilinked markdown table column headers."""
return " | ".join(f"[[{item}]]" for item in (value or []))
def _filter_table_sep(value: Any) -> str:
"""Render the markdown table separator row for an array field, one
column per item."""
return "|".join("--------" for _ in (value or [])) or "--------"
def _filter_table_cells(value: Any) -> str:
"""Render a placeholder table body row, one cell per array item."""
return " | ".join("..." for _ in (value or []))
_TEMPLATE_FILTERS = {
"bullets": _filter_bullets,
"join": _filter_join,
"capitalize": _filter_capitalize,
"table_header": _filter_table_header,
"table_sep": _filter_table_sep,
"table_cells": _filter_table_cells,
}
def _apply_template_variables(template: str, variables: Dict[str, Any]) -> str:
"""Apply variable substitutions to a template string.
Supports:
- `{field}` - plain substitution from `variables[field]`
- `{field|filter}` - apply a named filter (bullets, join, capitalize)
to `variables[field]`'s value, so templates can render list/enum
frontmatter fields directly instead of the caller precomputing a
separate display-only variable for each one
- `{field|literal text}` - literal fallback if `field` isn't in
`variables` at all and the suffix isn't a recognized filter name
"""
def replace_match(match: re.Match) -> str:
full_match = match.group(0)
var_name = match.group(1)
if '|' in var_name:
var_name, suffix = var_name.split('|', 1)
var_name = var_name.strip()
suffix = suffix.strip()
if var_name not in variables:
return suffix
value = variables[var_name]
filter_fn = _TEMPLATE_FILTERS.get(suffix)
return filter_fn(value) if filter_fn else str(value)
return str(variables.get(var_name, full_match))
pattern = r'\{([^}]+)\}'
return re.sub(pattern, replace_match, template)
def _page_subdir(subtype: Optional[str], type_path: str) -> Optional[str]:
"""Return the subtype-driven subdirectory under a type's `base_dir`, from
the type-spec's own `layout:` frontmatter. Thin wrapper over
`TypeResolver.subtype_dir` - the one place this computation lives, shared
with `move` and `lint`'s misplaced-page finding."""
return resolver.subtype_dir(type_path, subtype)
def _target_dir(type_path: str, frontmatter: Dict[str, Any]) -> Path:
"""Resolve where an instance of this type is written: `<root>/<base_dir>`,
plus a subtype subdirectory when the type declares a `layout:`.
Thin wrapper over `TypeResolver.compute_target_dir` - the one placement
rule, also used by `move` and `lint`'s misplaced-page finding -
converting its `ValueError` into the CLI's normal friendly-failure path.
`base_dir` is resolved against `config.KB_DIR` by default, so tests that
point KB_DIR at a temporary fixture wiki can never write into the real
`kb/`. A type-spec declaring `root: repo` resolves against `config.ROOT`
instead - for artifacts that are agent-directed material rather than
knowledge, and so live outside the knowledge layer."""
try:
return resolver.compute_target_dir(type_path, frontmatter)
except ValueError as exc:
fail(str(exc))
def _validate_or_fail(frontmatter: Dict[str, Any], type_path: str, source_dir: Path) -> None:
"""Validate frontmatter against its type-spec schema, converting a
ValueError into the CLI's normal friendly-failure path instead of an
uncaught traceback. The schema's own error message (which already names
the offending field and, for enums, lists the valid values) is shown
as-is - there is no separate hand-maintained validity check to keep in
sync with it."""
try:
resolver.validate_frontmatter(frontmatter, type_path, source_dir)
except ValueError as exc:
fail(str(exc))
def _load_type_or_fail(type_path: str, source_dir: Path):
"""Resolve a type path and load its type-spec + template, converting an
unresolvable/invalid `--type` into the CLI's normal friendly-failure path."""
try:
return _resolve_type_and_get_template(type_path, source_dir)
except ValueError as exc:
fail(str(exc))
def new_page_command(
type_name: str = typer.Argument(
...,
help="Type name, e.g. entity|concept|source|comparison (see `wikitool types list`)",
),
name: str = typer.Option(..., "--name", help="Page title (a type's title_prefix is added automatically)"),
type_path_override: str = typer.Option(
"", "--type", help="Override the type-spec path (defaults to the one named by TYPE_NAME)"
),
set_fields: Optional[list[str]] = typer.Option(
None,
"--set",
help="Frontmatter field, repeatable: --set entity_type=tool --set tags=a,b. Array values split on commas (escape a literal one as \\,); repeating --set for an array field appends instead of replacing",
),
):
"""Scaffold a new wiki page of any type.
The type's own type-spec drives everything: which frontmatter fields
exist and are required (its `.schema.yaml`), their scaffold defaults
(schema `default:`), where the page is written (`base_dir` + `layout`),
what prefixes its title (`title_prefix`), and its body skeleton (the
type-spec's template). Adding a new type therefore needs no change here.
"""
type_path = type_path_override or resolver.find_type_by_name(type_name)
if not type_path:
available = sorted(fm.get("name") for _, fm in resolver.list_type_specs())
fail(f"No type-spec named '{type_name}'. Available: {', '.join(available)}")
today = datetime.date.today()
try:
title_prefix = resolver.get_title_prefix(type_path)
root = resolver.get_root(type_path)
except ValueError as exc:
fail(str(exc))
page_title = f"{title_prefix}{name}"
if root == "kb":
# Title collisions matter because wikilinks resolve by title alone, so
# two pages sharing a stem are indistinguishable to every link in the
# wiki. Artifacts outside kb/ are not addressed by title and are not
# part of that namespace, so the check does not apply to them.
check_collision(page_title)
schema = resolver.get_schema(type_path)
explicit = parse_set_fields(set_fields, schema)
declared = (schema or {}).get("properties", {})
if "summary" in declared:
explicit.setdefault("summary", _default_summary(""))
if "name" in declared:
# The CLI already has this value; a type that stores its own name in
# frontmatter should not have to be told it twice.
explicit.setdefault("name", name)
if "description" in declared:
explicit.setdefault("description", "TODO: add description")
frontmatter = _build_frontmatter(type_path, schema, today, explicit)
# Capture fields (Gitea #67, e.g. `fidelity`/`authority` on a source page)
# are deliberately absent from `required:` - putting them there would make
# every existing instance's source pages stop validating, a
# boundary-crossing change (version-parts.md). The requirement is instead
# enforced here, in the tool, exactly like `source_type`'s no-default
# refusal (#66) reads from the schema alone: without a `default:` and
# omitted from `explicit`, a capture field simply never lands in
# `frontmatter`, so its absence has to be caught before it is silently
# written as a page with no capture record at all.
try:
capture_fields = resolver.get_capture_fields(type_path)
except ValueError as exc:
fail(str(exc))
missing_capture = [f for f in capture_fields if not frontmatter.get(f)]
if missing_capture:
fail(
f"Type {type_path} requires capture field(s) {', '.join(missing_capture)} - pass them "
"explicitly, e.g. --set fidelity=verbatim --set authority=reporting. "
"new does not guess them (see raw/CONTRACT.md)."
)
guessed_unknown = [f for f in capture_fields if frontmatter.get(f) == "unknown"]
if guessed_unknown:
fail(
f"Capture field(s) {', '.join(guessed_unknown)} cannot be set to 'unknown' here - that "
"value is backfill-only, written only by `wikitool touch` on a page predating this rule."
)
target_dir = _target_dir(type_path, frontmatter)
_type_spec, template = _load_type_or_fail(type_path, target_dir)
_validate_or_fail(frontmatter, type_path, target_dir)
if "raw_files" in frontmatter:
check_raw_files_exist(frontmatter["raw_files"])
path = target_dir / f"{page_title}.md"
body = _apply_template_variables(
template,
{
**frontmatter,
"name": name,
"today": today.isoformat(),
},
)
write_page(path, frontmatter, body)
success(f"Created {rel_path(path)}")