Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <verb>` 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 <verb>` 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

Expand Down
69 changes: 69 additions & 0 deletions tests/test_index_head.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 4 additions & 2 deletions wiki/wikilint/derived.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,15 +123,17 @@ 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"))
if GENERATED_MARKER in existing:
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")
Expand Down
7 changes: 7 additions & 0 deletions wiki/wikilint/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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"]:
Expand Down
Loading