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 @@ -39,7 +39,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/knowledge-catalog/blob/main/okf/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. Scriptorium's schema is a strict superset of OKF's (the spec's `generated.at` is our `updated`; its `resource` is our `source_path`; its `title` is optional, with the index prettifying filenames when absent). 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, and `extra_checks` runs custom callables. Every knob's default lives once in `wikilint/settings.py` (`DEFAULTS`) and preserves the original behavior; a wiki's `lint.py` lists a key only to override it. 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), `extra_secret_patterns`/`secret_allow_res` extend the secrets scan, and `extra_checks` runs custom callables. 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
130 changes: 130 additions & 0 deletions tests/test_engine_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Contract tests for the engine's configuration surface: DEFAULTS must
supply every key the engine reads, so a variant's lint.py can list only the
keys it genuinely overrides and no omission can KeyError mid-run.

These are structural tests — they read the engine source for its CONFIG[...]
accesses rather than restating a key list that would drift.
"""

import re
import tempfile
import unittest
from pathlib import Path

from helpers import REPO, findings, gather

ENGINE_DIR = REPO / "wiki" / "wikilint"

# Keys the engine injects itself in configure()/_validate() — never variant
# input, so DEFAULTS must not carry them.
INJECTED_KEYS = {
"index_entry_extra", "secret_extra_compiled", "secret_allow_compiled",
}

# settings._validate reads the raw merged dict as cfg[...] before it becomes
# CONFIG, so both access spellings count as an engine read.
CONFIG_READ_RE = re.compile(r"""(?:CONFIG|cfg)\[['"]([a-z_]+)['"]\]""")


def engine_config_keys():
"""Every CONFIG key the engine reads, gathered from its own source."""
keys = set()
for path in sorted(ENGINE_DIR.glob("*.py")):
keys |= set(CONFIG_READ_RE.findall(path.read_text(encoding="utf-8")))
return keys - INJECTED_KEYS


class TestDefaultsCompleteness(unittest.TestCase):
def test_defaults_supply_every_key_the_engine_reads(self):
from wikilint.settings import DEFAULTS
missing = sorted(engine_config_keys() - set(DEFAULTS))
self.assertEqual(
missing, [],
"engine reads keys with no DEFAULTS entry — a variant omitting "
f"them KeyErrors mid-run: {missing}",
)

def test_defaults_declares_no_key_the_engine_never_reads(self):
from wikilint.settings import DEFAULTS
stale = sorted(set(DEFAULTS) - engine_config_keys())
self.assertEqual(stale, [], f"DEFAULTS carries dead knobs: {stale}")

def test_the_two_tiers_partition_defaults(self):
"""CORE_DEFAULTS and EXTENSION_DEFAULTS are the whole of DEFAULTS and
do not overlap, so every knob is documented in exactly one tier."""
from wikilint.settings import CORE_DEFAULTS, DEFAULTS, EXTENSION_DEFAULTS
self.assertEqual(set(CORE_DEFAULTS) & set(EXTENSION_DEFAULTS), set())
self.assertEqual(set(CORE_DEFAULTS) | set(EXTENSION_DEFAULTS), set(DEFAULTS))

def test_core_defaults_are_all_neutral(self):
"""A core knob's default may not switch a check on: every one is empty,
None, False, or a shape-only value the check itself guards."""
from wikilint.settings import CORE_DEFAULTS
shape_only = {
"claude_md_max_lines", "contested_max_days",
"inbox_warn_count", "inbox_warn_age_days", "index_mode",
}
for key, value in CORE_DEFAULTS.items():
if key in shape_only:
continue
self.assertIn(value, ([], {}, None, False), f"{key} defaults to {value!r}")

def test_extension_defaults_keep_original_wiki_behavior(self):
"""The pre-existing extension points are NOT flipped off by this
change: a variant omitting one must behave as the original wiki did."""
from wikilint.settings import EXTENSION_DEFAULTS
self.assertTrue(EXTENSION_DEFAULTS["orphans"])
self.assertTrue(EXTENSION_DEFAULTS["okf_conformance"])
self.assertTrue(EXTENSION_DEFAULTS["types_glossary"])
self.assertEqual(EXTENSION_DEFAULTS["log_file"], "log.md")
self.assertEqual(EXTENSION_DEFAULTS["index_file"], "index.md")

def test_minimal_config_configures_cleanly(self):
"""A variant config listing one key must yield a complete CONFIG: the
whole point of the DEFAULTS merge."""
from wikilint.settings import CONFIG, configure
saved = dict(CONFIG)
self.addCleanup(lambda: (CONFIG.clear(), CONFIG.update(saved)))
configure({"page_dirs": ["notes"]}, lambda fields: "")
for key in engine_config_keys():
self.assertIn(key, CONFIG)

def test_minimal_config_runs_a_full_check_pass(self):
"""Every neutral default must survive a real check pass — the
regression this guards is `inbox_warn_count`, read by check_inbox but
declared by no DEFAULTS entry at all before this change."""
from wikilint.settings import CONFIG, configure
saved = dict(CONFIG)
self.addCleanup(lambda: (CONFIG.clear(), CONFIG.update(saved)))
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "notes").mkdir()
(root / "CLAUDE.md").write_text("---\ntype: tooling\n---\n\n# stub\n")
# Non-wiki markdown tree: the wiki-shaped extension points off.
configure({"page_dirs": ["notes"], "okf_conformance": False,
"types_glossary": False, "orphans": False,
"index_file": None, "log_file": None},
lambda fields: "")
report = gather(root) # must not raise KeyError
self.assertEqual(findings(report, severity="ERROR"), [])

def test_inbox_thresholds_default_when_only_inbox_dir_is_set(self):
"""The latent KeyError: setting inbox_dir without its two thresholds
used to blow up inside check_inbox."""
from wikilint.settings import CONFIG, configure
saved = dict(CONFIG)
self.addCleanup(lambda: (CONFIG.clear(), CONFIG.update(saved)))
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "raw" / "inbox").mkdir(parents=True)
(root / "CLAUDE.md").write_text("---\ntype: tooling\n---\n\n# stub\n")
configure({"inbox_dir": "raw/inbox", "okf_conformance": False,
"types_glossary": False, "index_file": None,
"log_file": None}, lambda fields: "")
self.assertEqual(CONFIG["inbox_warn_count"], 10)
self.assertEqual(CONFIG["inbox_warn_age_days"], 14)
gather(root) # must not raise KeyError


if __name__ == "__main__":
unittest.main()
10 changes: 10 additions & 0 deletions tests/test_variants.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ def test_membership_references_real_types(self):
self.assertIn(rule["container_field"],
config["path_fields"] + config["edge_fields"], variant)

def test_every_config_key_is_covered_by_defaults(self):
"""A config may only set keys the engine knows about, so a typo'd key
name cannot sit in a lint.py silently doing nothing."""
from wikilint.settings import DEFAULTS
for variant in ALL_CONFIGS:
config, _ = load_variant_config(variant)
unknown = sorted(set(config) - set(DEFAULTS))
self.assertEqual(unknown, [],
f"{variant} sets keys the engine never reads: {unknown}")

def test_extension_defaults_present(self):
"""The template's effective config carries every extension key."""
from wikilint.settings import DEFAULTS, configure, CONFIG
Expand Down
81 changes: 72 additions & 9 deletions wiki/wikilint/settings.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,80 @@
"""Mutable configuration holder and the config-validation boundary.

cli.main() calls configure() once at startup with a variant's CONFIG; every
other module imports the CONFIG dict object and reads it live. Engine
extension points default to original wiki behavior via DEFAULTS, so a variant
only lists a key when it overrides one. configure() validates user-supplied
values (regexes, paths, callables) and fails fast with a clear message.
other module imports the CONFIG dict object and reads it live. DEFAULTS
supplies every key the engine reads, so a variant only lists a key when it
overrides one. configure() validates user-supplied values (regexes, paths,
callables) and fails fast with a clear message.
"""

import re
from pathlib import PurePosixPath

CONFIG = {}

# Engine extension points. Defaults live here, once, and preserve the original
# wiki behavior; a variant's lint.py overrides a key only to change it. Reading
# these bare as CONFIG[key] is safe because configure() merges DEFAULTS under
# every variant config.
DEFAULTS = {
# Core knobs: the schema a variant declares. These have no "before" — they
# were always variant-declared — so each defaults to the neutral/disabled
# value: no pages, no required fields, every optional check off. A default
# here can only ever silence a check, never invent one, so a variant that
# omits a key gets nothing rather than a surprise finding.
CORE_DEFAULTS = {
# Directories containing lint-checked pages, relative to the wiki root.
"page_dirs": [],
# Top-level entries allowed to exist but not scanned as pages (fnmatch).
"non_page_allowed": [],
# Immutable human-owned sources, excluded from the OKF bundle; None
# disables the exclusion.
"raw_dir": None,
# Hot-core size guard: warn when CLAUDE.md exceeds this many lines.
"claude_md_max_lines": 200,
# Frontmatter required on every page, then per-type extras.
"required_fields": [],
"type_required": {},
# Frontmatter fields restricted to a value set, globally then per type.
"enum_fields": {},
"type_enum_fields": {},
# Frontmatter fields whose values must resolve to existing pages, and the
# extra graph edges walked for orphan detection.
"path_fields": [],
"edge_fields": [],
# Fields `reverse-deps` inverts into a reverse-dependency map.
"reverse_fields": [],
# Container-page membership rule; None disables check_membership.
"membership": None,
# Per-field staleness rules: [{field, types, max_days, severity}].
"staleness": [],
# Days a `confidence: contested` page may sit untouched.
"contested_max_days": 30,
# Cross-page sync-drift rule; None disables check_sync_drift.
"sync_drift": None,
# Criticality/owner-review knobs; None disables their checks.
"criticality_field": None,
"owner_review_max_days": None,
# Mermaid diagram checks: types that must carry one, node-count budgets.
"mermaid_required_types": [],
"mermaid_node_warn": None,
"mermaid_node_error": None,
# Directories holding NNNN-slug ADRs; empty disables the ADR checks.
"adr_dirs": [],
# Built-in index generator shape (ignored when index_body_fn is set).
"index_mode": "flat",
"index_sections": [],
# `coverage` verb availability.
"coverage": False,
# Controlled-tag vocabulary file; None disables the tag checks.
"taxonomy_file": None,
# Triage inbox directory (None disables) and its warning thresholds.
"inbox_dir": None,
"inbox_warn_count": 10,
"inbox_warn_age_days": 14,
}

# Engine extension points. Unlike the core knobs above, these default to the
# ORIGINAL wiki behavior — including the non-off `orphans: True`,
# `log_file: "log.md"`, `okf_conformance: True` and `types_glossary: True` —
# so a variant written before the extension existed keeps behaving as it did,
# and a variant that wants one off lists it as a genuine override.
EXTENSION_DEFAULTS = {
# Enforce OKF v0.2 conformance (check_okf) and stamp okf_version
# frontmatter into the rebuilt index. Off for non-OKF markdown trees.
"okf_conformance": True,
Expand Down Expand Up @@ -54,6 +112,11 @@
"extra_checks": [],
}

# Every key the engine reads, with its default. Reading these bare as
# CONFIG[key] is safe because configure() merges DEFAULTS under every variant
# config — that merge is the reason a variant may omit a key entirely.
DEFAULTS = {**CORE_DEFAULTS, **EXTENSION_DEFAULTS}


class ConfigError(Exception):
"""A variant lint.py holds an invalid value for an engine knob."""
Expand Down
Loading