aa31d431fc
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
361 lines
15 KiB
Python
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)}")
|