From b631748d81aa86ac35d8c41233b2873685b40b1c Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:32:53 +0200 Subject: [PATCH] feat: DEFAULTS covers every config key the engine reads The engine reads 40 CONFIG keys but DEFAULTS supplied only the twelve opt-in extension points, so `CONFIG[key]` raised KeyError for any key a config omitted. Every config therefore had to list all 28 remaining knobs, including ~15 pure "off" switches, contradicting the module's own documented contract ("a variant only lists a key when it overrides one"). DEFAULTS is now the union of two documented tiers: * CORE_DEFAULTS -- the schema knobs, defaulting to the neutral/disabled value (no pages, no required fields, every optional check off). These have no "before": they were always config-declared, so a default can only ever silence a check, never invent one. * EXTENSION_DEFAULTS -- the pre-existing twelve, keeping their original values including the non-off `orphans`, `okf_conformance`, `types_glossary` and `log_file: "log.md"`, so a wiki that omits one still behaves as the original wiki did. Also fixes a latent KeyError: `inbox_warn_count` / `inbox_warn_age_days` were read by check_inbox but declared by no DEFAULTS entry at all, so a config setting `inbox_dir` without both thresholds crashed mid-run. Behavior-neutral: the shipped wiki template and all three extension config bundles set every core knob explicitly, so no new default can reach them. Verified by diffing each one's fully-merged effective CONFIG against main -- identical for all four. --- README.md | 2 +- tests/test_engine_contract.py | 130 ++++++++++++++++++++++++++++++++++ tests/test_variants.py | 10 +++ wiki/wikilint/settings.py | 81 ++++++++++++++++++--- 4 files changed, 213 insertions(+), 10 deletions(-) create mode 100644 tests/test_engine_contract.py diff --git a/README.md b/README.md index fb550b4..7c20e26 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/tests/test_engine_contract.py b/tests/test_engine_contract.py new file mode 100644 index 0000000..8fa778e --- /dev/null +++ b/tests/test_engine_contract.py @@ -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() diff --git a/tests/test_variants.py b/tests/test_variants.py index c8a28a2..a6b7e31 100644 --- a/tests/test_variants.py +++ b/tests/test_variants.py @@ -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 diff --git a/wiki/wikilint/settings.py b/wiki/wikilint/settings.py index 52b1143..f794b46 100644 --- a/wiki/wikilint/settings.py +++ b/wiki/wikilint/settings.py @@ -1,10 +1,10 @@ """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 @@ -12,11 +12,69 @@ 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, @@ -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."""