diff --git a/CHANGES.md b/CHANGES.md index 8dc3f2c..be3c125 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse. --- -## 6.1.0-beta.5 - 2026-09-16 - Session-Id-Fallback: Harness-Variable schliesst die Luecke zwischen Telemetrie-Join und Iteration-Budget-Gate +## 6.1.0-beta.6 - 2026-09-16 - new: scaffold materializes a schema default only for a required field **Author:** Torben Nehmer @@ -72,6 +72,7 @@ concern - readable here, never shipped as something to parse. - Migrationsdokument prueft gegen eine festgehaltene Vorher-Ausgabe, Beispielverweis auf die .template-Form - dist upgrade: --take-release nimmt fuer einen lokal geaenderten Pfad die Release-Fassung - version notes antwortet auf einer ausgelieferten Instanz aus dem Release-Feed +- new: scaffold materializes a schema default only for a required field ### Upgrade-Prozedur als eigene Instruktion statt als Prosa in INSTALL.md @@ -302,6 +303,29 @@ Aenderung waeren beide Tests gruen und blind gewesen. `--minor`: additiv (ein neues optionales `source`-Feld in `budget.json`, die Id faellt weiterhin auf `getppid()` zurueck, wo keine Variable greift), keine der beiden Drop-in-Richtungen verletzt. +### new: scaffold materializes a schema default only for a required field + +`tools/wikitool new instruction --name "x"` schrieb bislang `obligation: required` in jede neue +Instruktion. `obligation:` ist ein Migrationsfeld (`instructions/CONTRACT.md` +ยง `instructions/migrations/`) - eine gewoehnliche Instruktion ist keine Migration und hat nichts, +was laufen muesste. Ursache: `new_page._build_frontmatter()` materialisierte jedes +Schema-`default:` unbesehen; ueber alle acht `types/*.schema.yaml` gibt es genau zwei +(`entity`/`concept`s `provenance`, in `required:`; `instruction`s `obligation:`, nicht). + +Die Regel jetzt: ein Schema-`default:` wird nur fuer ein Feld materialisiert, das das Schema auch +in `required:` fuehrt. Auf einem optionalen Feld ist ein `default:` eine Lese-Annahme (was ein +fehlendes Feld bedeutet), keine Schreib-Vorgabe - sie hinzuschreiben macht aus der stillen +Annahme eine ausgesprochene Behauptung. `instruction.obligation`s eigene Lese-Annahme steht +unveraendert und unabhaengig in `kb_state.py` (`frontmatter.get("obligation") or REQUIRED`). +Der `array`-Zweig direkt daneben (leere Liste fuer ein unbesetztes Array-Feld wie `tags:`) ist +davon ausdruecklich nicht betroffen - er bleibt fuer optionale wie Pflichtfelder gleich, weil ein +fehlender Schluessel sonst den Template-Filter-Suffix woertlich in den Body schreiben wuerde +(`{related|bullets}` -> das Wort "bullets"). + +`--patch`: kein Bestandsdokument aendert sich (`obligation:` stand bislang nur explizit oder auf +den beiden Migrationsdokumenten), keine Migration noetig, und ein zurueckgerolltes Werkzeug +schriebe das Feld nur wieder mit. + --- ## 6.0.1 - 2026-09-16 - docs toc/verify erreichen die .template-Form einer Referenzdatei diff --git a/VERSION b/VERSION index 142cc1f..9aa7fe9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.1.0-beta.5 +6.1.0-beta.6 diff --git a/instructions/CONTRACT.md b/instructions/CONTRACT.md index ef0d04c..ed85b5e 100644 --- a/instructions/CONTRACT.md +++ b/instructions/CONTRACT.md @@ -89,6 +89,11 @@ produces; `migration_kind:` (`mechanical` | `assisted`); and `obligation:` (`required` | `offered`, default `required`). It lives at `instructions/migrations/-.md`. +`wikitool new instruction` scaffolds none of the three: `migrates_to:` and `migration_kind:` +have no schema `default:` at all, and an ordinary instruction's scaffold no longer materializes +`obligation:`'s default either - all three are added by hand when a migration document is +written, per [migrate-corpus.md](migrate-corpus.md). + `migration_kind:` and `obligation:` are **two axes, not one**. The first says how the work is carried out, the second whether it has to happen at all: diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 7ee94bc..ad0cb22 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -89,7 +89,7 @@ tools/wikitool --help | Command | Purpose | |---------|---------| -| `new --name "" [--type ] [--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 --name "" [--type ] [--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 "" --set entity_type= [--set tags=a,b] [--set related=X,Y] [--set sources="Source - Z"] [--set provenance=sourced\|general\|mixed]` | Scaffold `kb/entities//.md` | | `new concept --name "" --set concept_type= ...` | Scaffold `kb/concepts/.md` | | `new source --name "" --set raw_files=raw/notes/x.md,raw/notes/y.md [--set source_url=] [--set entities=A,B] [--set concepts=C,D]` | Scaffold `kb/sources/Source - .md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) | diff --git a/tools/chemenu/commands/new_page.py b/tools/chemenu/commands/new_page.py index 0e32f55..1548e4e 100644 --- a/tools/chemenu/commands/new_page.py +++ b/tools/chemenu/commands/new_page.py @@ -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] = [] diff --git a/tools/chemenu/tests/test_new_page.py b/tools/chemenu/tests/test_new_page.py index 29f6aba..82866a0 100644 --- a/tools/chemenu/tests/test_new_page.py +++ b/tools/chemenu/tests/test_new_page.py @@ -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 diff --git a/types/type-spec.md b/types/type-spec.md index d1b572e..e37018f 100644 --- a/types/type-spec.md +++ b/types/type-spec.md @@ -126,6 +126,13 @@ Each type is at least two files, and a `root: kb` type may be three: | `types/.schema.yaml` | The machine-checkable half: fields, types, enums, defaults, required-ness, `additionalProperties: false` | | `types/.guidance.md` (optional, `root: kb` only) | The stack-owned authoring contract: when to use the type, when not to, and mechanism-level advice that holds regardless of this instance's own enum values or template text - linked from the type-spec's own `guidance:` field. `types/type-guidance.md` is its contract | +**A `default:` is materialized by `wikitool new` only for a field the schema also lists in +`required:`.** On an optional field, `default:` documents what a reader should assume when the +field is absent - writing it into every scaffolded page turns that silent assumption into a +stated claim instead. `instruction.obligation` is the field this was fixed for: a migration-only +axis, unrelated to an ordinary instruction, whose `default: required` used to land in every +newly scaffolded instruction regardless. + This file is the self-referential root contract every type-spec is validated against, and `type-guidance.md` is validated against it the same way `lint-report.md` is - itself a non-instantiable, contract-only type. `tools/wikitool types describe ` composes all of a