From 96a1eb394a3bcede53f3e8d559d51bf65814074f Mon Sep 17 00:00:00 2001 From: SunsetDrifter Date: Thu, 8 Oct 2026 11:16:15 +0200 Subject: [PATCH] feat: add an index_head knob for the heading a fresh index gets A tree that does not commit its generated index (its entry titles are private) recreates the index from scratch in every clone. rebuild-index then writes the generic "# Index" heading, silently replacing the tree's own heading and do-not-hand-edit guidance on that first rebuild. index_head (default None, keeping "# Index") is the text written above the generated marker when the index does not exist yet. An existing index keeps whatever head it carries, and a marker-less index holding a yaml pinning fence under the configured head keeps the fence, as it already did under the generic one. Non-string values fail at configure(). --- README.md | 2 +- tests/test_index_head.py | 69 +++++++++++++++++++++++++++++++++++++++ wiki/wikilint/derived.py | 6 ++-- wiki/wikilint/settings.py | 7 ++++ 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 tests/test_index_head.py diff --git a/README.md b/README.md index 6278a98..9e515aa 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ The schema follows a few rules, informed by how agent-maintained wikis actually - **Contested is a state to exit.** The documented failure mode of agent wikis is contradictions accumulating faster than they resolve. Lint flags contested pages older than 30 days; the reconcile workflow rewrites in place, moving losing claims to a dated "Superseded claims" section instead of deleting them. - **Autonomous but reversible.** The maintenance workflow runs unattended on a `maintenance` branch with an exhaustively-listed set of safe actions (mechanical fixes, index rebuild, unambiguous cross-links); everything else becomes a proposal. The human reviews the branch diff and merges. Nothing automated ever lands on main directly. - **Native OKF conformance.** Every wiki is an [OKF v0.2](https://github.com/GoogleCloudPlatform/open-knowledge-format/blob/main/SPEC.md) bundle: markdown files with YAML frontmatter, ordinary markdown links as the edge form (bundle-absolute `[title](/dir/page.md)`, the form v0.2 §6.1 recommends), reserved `index.md` (stamped with `okf_version` frontmatter by `rebuild-index`, the mechanism §12 specifies) and `log.md` (date-grouped headings, bold-action-word entries). `check_okf` enforces the spec's three conformance rules: parseable frontmatter on every non-reserved `.md`, a non-empty `type`, and reserved-file structure. Provenance uses the spec's `sources` shape (§5.1): mapping entries with a required `resource`, enforced by `check_sources`, with pre-0.2 string entries downgraded to warnings. Pages carry OKF's own page-level keys directly: a required `title`, `generated: { by, at }` with an offset datetime (§5.2; the spec's last-meaningful-change record), and the §5.4 `status` values (`draft | stable | deprecated`), with `check_okf_fields` validating their shape and a page named in another page's `supersedes` required to be `deprecated`. Everything else (`created`, `confidence`, `supersedes`, and the spec's `resource` as our `source_path`) is a producer extension key, so the schema stays a strict superset of OKF's. One deliberate deviation: `raw/` is excluded from conformance because prime directive 1 makes those sources immutable, and OKF has no concept of a non-concept directory. -- **Opt-in extension points for non-wiki trees.** The engine can also lint markdown trees that aren't wikis (a findings folder, a labs journal): `okf_conformance: False` turns off the OKF rules for trees that aren't bundles, `non_page_allowed` accepts glob patterns, `index_file`/`index_body_fn` relocate and reshape the generated index (`index_file: None` disables it), `extra_secret_patterns`/`secret_allow_res` extend the secrets scan, `extra_checks` runs custom callables, and `extra_commands` registers extra `lint.py ` subcommands (`{verb: (callable(root) -> exit code, help)}`) which dispatch ahead of the wiki-root guard and appear in `lint.py help` alongside the engine's own verbs, so a wiki never has to intercept `argv` and end up with a second, partial usage string. Every knob's default lives once in `wikilint/settings.py` (`DEFAULTS`) and a wiki's `lint.py` lists a key only to override it. `DEFAULTS` covers *every* key the engine reads, in two documented tiers: `EXTENSION_DEFAULTS` (the extension points above) preserves the original wiki behavior, so a wiki written before an extension existed keeps behaving as it did; `CORE_DEFAULTS` (the schema knobs — page dirs, required fields, staleness, ADRs, mermaid, coverage, ...) defaults to the neutral/disabled value, so a default can only ever silence a check, never invent one. A minimal config is therefore a handful of lines rather than a full key list. Bad values (a malformed regex, an out-of-tree `index_file`, a non-callable check) are rejected at startup with a clear message rather than a mid-run traceback. +- **Opt-in extension points for non-wiki trees.** The engine can also lint markdown trees that aren't wikis (a findings folder, a labs journal): `okf_conformance: False` turns off the OKF rules for trees that aren't bundles, `non_page_allowed` accepts glob patterns, `index_file`/`index_body_fn` relocate and reshape the generated index (`index_file: None` disables it), `index_head` sets the heading a freshly created index gets (for trees that don't commit their index), `extra_secret_patterns`/`secret_allow_res` extend the secrets scan, `extra_checks` runs custom callables, and `extra_commands` registers extra `lint.py ` subcommands (`{verb: (callable(root) -> exit code, help)}`) which dispatch ahead of the wiki-root guard and appear in `lint.py help` alongside the engine's own verbs, so a wiki never has to intercept `argv` and end up with a second, partial usage string. Every knob's default lives once in `wikilint/settings.py` (`DEFAULTS`) and a wiki's `lint.py` lists a key only to override it. `DEFAULTS` covers *every* key the engine reads, in two documented tiers: `EXTENSION_DEFAULTS` (the extension points above) preserves the original wiki behavior, so a wiki written before an extension existed keeps behaving as it did; `CORE_DEFAULTS` (the schema knobs — page dirs, required fields, staleness, ADRs, mermaid, coverage, ...) defaults to the neutral/disabled value, so a default can only ever silence a check, never invent one. A minimal config is therefore a handful of lines rather than a full key list. Bad values (a malformed regex, an out-of-tree `index_file`, a non-callable check) are rejected at startup with a clear message rather than a mid-run traceback. ## Unattended maintenance diff --git a/tests/test_index_head.py b/tests/test_index_head.py new file mode 100644 index 0000000..a948f88 --- /dev/null +++ b/tests/test_index_head.py @@ -0,0 +1,69 @@ +"""index_head: the text a fresh index gets above the generated marker. A +tree that does not commit its index (because entry titles are private) +creates it from scratch in every clone, so without this knob the first +rebuild silently replaces the tree's own heading with the generic one.""" + +import tempfile +import unittest + +from helpers import findings, gather, make_wiki, page, use_variant_with +from wikilint.derived import rebuild_index +from wikilint.model import GENERATED_MARKER +from wikilint.settings import ConfigError + +HEAD = "# Findings index\n\nGenerated; never hand-edit below the marker.\n\n" + + +class IndexHeadTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = make_wiki(self.tmp.name, files={"concepts/a.md": page("concept", "A.")}) + + def index_text(self): + return (self.root / "index.md").read_text() + + +class TestIndexHead(IndexHeadTest): + def test_fresh_index_gets_configured_head(self): + use_variant_with("wiki", index_head=HEAD) + rebuild_index(self.root) + self.assertIn(HEAD + GENERATED_MARKER, self.index_text()) + self.assertNotIn("# Index\n", self.index_text()) + + def test_default_keeps_generic_heading(self): + rebuild_index(self.root) + self.assertIn("# Index\n\n" + GENERATED_MARKER, self.index_text()) + + def test_rebuild_is_stable_and_drift_free(self): + use_variant_with("wiki", index_head=HEAD) + rebuild_index(self.root) + first = self.index_text() + rebuild_index(self.root) + self.assertEqual(self.index_text(), first) + self.assertEqual(findings(gather(self.root), "index"), []) + + def test_existing_hand_edited_head_wins(self): + """index_head seeds a missing index; it never overwrites a head an + existing index already carries above its marker.""" + use_variant_with("wiki", index_head=HEAD) + (self.root / "index.md").write_text(f"# Mine\n\n{GENERATED_MARKER}\n\nstale\n") + rebuild_index(self.root) + self.assertTrue(self.index_text().split("---\n\n")[-1].startswith("# Mine\n\n")) + + def test_pinning_fence_under_configured_head_is_preserved(self): + """A marker-less index whose only content above a yaml pinning fence + is the configured head keeps the fence, as with the generic head.""" + use_variant_with("wiki", index_head=HEAD) + fence = "```yaml\nrepo: acme/app\nlast_synced_commit: abc123\n```" + (self.root / "index.md").write_text(HEAD + fence + "\n") + rebuild_index(self.root) + self.assertIn(HEAD + fence + "\n\n" + GENERATED_MARKER, self.index_text()) + + def test_non_string_rejected_at_configure(self): + with self.assertRaisesRegex(ConfigError, "index_head"): + use_variant_with("wiki", index_head=["# Index"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/wikilint/derived.py b/wiki/wikilint/derived.py index 00fc5c0..125739c 100644 --- a/wiki/wikilint/derived.py +++ b/wiki/wikilint/derived.py @@ -123,7 +123,9 @@ def rebuild_index(root): # The okf_version stamp is a single fixed key: always regenerate it so a # deleted or corrupted block self-heals on the next rebuild. front = f'---\nokf_version: "{OKF_VERSION}"\n---\n\n' if CONFIG["okf_conformance"] else "" - head = "# Index\n\n" + # A fresh index gets index_head; an existing one keeps its own head. + default_head = CONFIG["index_head"] or "# Index\n\n" + head = default_head if index.is_file(): existing = _strip_leading_frontmatter( index.read_text(encoding="utf-8", errors="replace")) @@ -131,7 +133,7 @@ def rebuild_index(root): head = existing.split(GENERATED_MARKER)[0] else: m = YAML_FENCE_RE.search(existing) - if m and existing[:m.start()].strip() in ("", "# Index"): + if m and existing[:m.start()].strip() in ("", "# Index", default_head.strip()): head = existing[:m.end()] + "\n\n" body = generate_index_body(pages) index.write_text(f"{front}{head}{GENERATED_MARKER}\n\n{body}", encoding="utf-8") diff --git a/wiki/wikilint/settings.py b/wiki/wikilint/settings.py index 81fa294..89f5df7 100644 --- a/wiki/wikilint/settings.py +++ b/wiki/wikilint/settings.py @@ -103,6 +103,11 @@ "index_file": "index.md", # Callable(pages) -> str replacing the built-in index body generator. "index_body_fn": None, + # Text above the generated marker when the index does not exist yet; None + # keeps the generic "# Index" heading. A tree that does not commit its + # index (entry titles are private) rebuilds it from scratch in every + # clone, so it sets this to keep its own heading and guidance. + "index_head": None, # Frontmatter fields validated as ISO dates when present. The # created/updated ordering check runs regardless of this list. "iso_date_fields": ["created", "updated"], @@ -227,6 +232,8 @@ def _validate(cfg): f"skills_dir must stay within the wiki root: {skills_dir!r}") if not isinstance(cfg["skills_prefix"], str): raise ConfigError("skills_prefix must be a string") + if cfg["index_head"] is not None and not isinstance(cfg["index_head"], str): + raise ConfigError("index_head must be None or a string") if cfg["index_body_fn"] is not None and not callable(cfg["index_body_fn"]): raise ConfigError("index_body_fn must be None or callable") for fn in cfg["extra_checks"]: