new: scaffold materializes a schema default only for a required field
CI / verify (push) Successful in 57s
Release / release (push) Successful in 37s

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
This commit is contained in:
2026-09-16 21:38:20 +02:00
parent 4284f101c8
commit aa31d431fc
7 changed files with 118 additions and 9 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ tools/wikitool <command> --help
| Command | Purpose |
|---------|---------|
| `new <type-name> --name "<Name>" [--type <path>] [--set field=value ...]` | Scaffold a page of any type. The type-spec drives fields, defaults, directory (`base_dir`/`layout`), title prefix, and template - `--set` is repeatable, and comma-separated values fill array fields. An element that itself contains a comma is written `\,`, or passed as its own repeated `--set` for that field - repeating an array field appends. See `types list`/`types describe`. |
| `new <type-name> --name "<Name>" [--type <path>] [--set field=value ...]` | Scaffold a page of any type. The type-spec drives fields, directory (`base_dir`/`layout`), title prefix, and template - 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, not a scaffold-time value) - `--set` is repeatable, and comma-separated values fill array fields. An element that itself contains a comma is written `\,`, or passed as its own repeated `--set` for that field - repeating an array field appends. See `types list`/`types describe`. |
| `new entity --name "<Name>" --set entity_type=<t> [--set tags=a,b] [--set related=X,Y] [--set sources="Source - Z"] [--set provenance=sourced\|general\|mixed]` | Scaffold `kb/entities/<subdir>/<Name>.md` |
| `new concept --name "<Name>" --set concept_type=<t> ...` | Scaffold `kb/concepts/<Name>.md` |
| `new source --name "<Name>" --set raw_files=raw/notes/x.md,raw/notes/y.md [--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]` | Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) |
+20 -6
View File
@@ -11,6 +11,10 @@ deterministic and stored in /types/; the content is judgment and provided by the
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.
@@ -74,12 +78,22 @@ def _build_frontmatter(
`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, 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.
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":
@@ -105,7 +119,7 @@ def _build_frontmatter(
frontmatter[field_name] = resolved_author
elif field_schema.get("format") == "date":
frontmatter[field_name] = today
elif "default" in field_schema:
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] = []
+59
View File
@@ -77,6 +77,23 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
assert "# gateway.example.net" in body
def test_new_entity_still_materializes_empty_arrays_for_unset_optional_fields(monkeypatch, kb_dir):
"""Gitea #109 stops materializing an optional field's schema `default:`,
but `tags`/`related`/`sources` are optional arrays with no `default:` at
all - they must keep landing as `[]`, not disappear. Their absence would
make `_apply_template_variables` fall back to the filter suffix rendered
literally (`{related|bullets}` -> the word "bullets" left in the body)."""
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "Bare", "--set", "entity_type=tool",
])
assert result.exit_code == 0, result.output
fm, body = read_page(kb_dir / "entities/tools/Bare.md")
assert fm["tags"] == []
assert fm["related"] == []
assert fm["sources"] == []
assert "bullets" not in body
def test_a_scaffolded_body_carries_no_tool_owned_region(monkeypatch, kb_dir):
"""A template must not scaffold the links or footnotes regions. They are
generated between markers from frontmatter and re-rendered on every write,
@@ -403,6 +420,48 @@ def test_raw_files_error_points_at_the_comma_split(monkeypatch, kb_dir, raw_dir)
assert "never rename the raw file" in result.output
def _invoke_new_instruction(monkeypatch, tmp_path, args):
"""Invoke `new` for a `root: repo` type. `instruction` resolves its
`base_dir:` against `config.ROOT`, not `config.KB_DIR` - unlike
`_invoke_new`'s callers, patching `KB_DIR` alone would leave the scaffold
writing into this checkout's real `instructions/` (Gitea #109's fixture
note). Repointing `ROOT` pulls `TYPES_DIR` along with it, so
`use_shipped_type_specs` restores the real, shipped type-specs."""
import chemenu.config as config
from chemenu.cli import app
from chemenu.tests.conftest import use_shipped_type_specs
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
(tmp_path / "instructions").mkdir(parents=True, exist_ok=True)
return runner.invoke(app, args)
def test_new_instruction_omits_migration_only_default(monkeypatch, tmp_path):
"""Gitea #109: `obligation:` is a migration-only field (`instructions/
migrations/*`) with a schema `default:` but no `required:` entry. The
scaffold must not materialize it into an ordinary instruction."""
result = _invoke_new_instruction(monkeypatch, tmp_path, [
"new", "instruction", "--name", "probe",
])
assert result.exit_code == 0, result.output
fm, _body = read_page(tmp_path / "instructions/probe.md")
assert "obligation" not in fm
def test_new_instruction_explicit_obligation_is_still_written(monkeypatch, tmp_path):
"""The rule only suppresses the *implicit* default - an explicit
`--set obligation=offered` (as when hand-scaffolding a migration
document) must still land in the frontmatter."""
result = _invoke_new_instruction(monkeypatch, tmp_path, [
"new", "instruction", "--name", "probe-migration",
"--set", "obligation=offered",
])
assert result.exit_code == 0, result.output
fm, _body = read_page(tmp_path / "instructions/probe-migration.md")
assert fm["obligation"] == "offered"
def test_source_page_accepts_a_raw_file_whose_name_has_a_comma(monkeypatch, kb_dir, raw_dir):
import chemenu.config as config