feat: Link-Taxonomie abgeschlossen - Lint hart ab kb_version 4.0.0, outbound: an Type-Spec gebunden, part-of/composition als Inversenpaar (4.1.0, #40)
Files changed: - CHANGES.md - VERSION - instructions/link-taxonomy.md - instructions/migrations/4.0.0-link-taxonomy.md - kb/comparisons/amd-pstate vs acpi-cpufreq.md - kb/concepts/Episodic Memory.md - kb/concepts/Memory Lifecycle.md - kb/concepts/Mesh Sync.md - kb/concepts/Procedural Memory.md - kb/concepts/Reciprocal Rank Fusion.md - kb/concepts/Semantic Memory.md - kb/concepts/Shared vs Private.md - kb/concepts/Split Threshold.md - kb/concepts/Stub Threshold.md - kb/concepts/Supersession.md - kb/concepts/Typed Relationships.md - kb/concepts/Vector Search.md - kb/concepts/Work Coordination.md - kb/concepts/Working Memory.md - kb/entities/technologies/Wine-Staging.md - kb/entities/tools/pascalandy schema.md - kb/index.md - kb/log.md - kb/sources/COLLECTION.md - tools/CONTRACT.md - tools/chemenu/commands/lint.py - tools/chemenu/kb_collections.py - tools/chemenu/lint_core.py - tools/chemenu/tests/test_conventions.py - tools/chemenu/tests/test_lint.py - tools/chemenu/tests/test_type_resolver.py - types/comparison.md - types/comparison.schema.yaml
This commit is contained in:
+1
-1
@@ -50,7 +50,7 @@ tools/wikitool <command> --help
|
||||
| `index rebuild [--dry-run]` | Regenerate the catalog from every page's frontmatter: `kb/index.md` becomes a map (statistics, one row per collection and per area, links to the shards) and the page tables are written to a generated `INDEX.md` in each collection. An area past 50 rows gets its own shard. Stale shards from removed collections/areas are deleted in the same pass |
|
||||
| `log append --op ingest\|query\|lint\|create\|update\|delete\|rename --title "..." [--body "..."\|--body-file path]` | Append a formatted entry to `kb/log.md` |
|
||||
| `log status` | Read-only: count `ingest` entries logged since the last `lint` entry - the deterministic trigger behind the Maintenance Schedule's "every 10 sources" full-lint cadence |
|
||||
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
||||
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
||||
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. Results carry kind/summary/confidence so a hit can be judged without opening the page. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
|
||||
| `confidence decay [--apply]` | Recompute every page's derived `confidence` as `confidence_base * (1 - 0.01/month)`, floored at 0.2; dry-run by default |
|
||||
| `confidence init-base [--apply]` | One-time backfill: set `confidence_base` from the current `confidence` on pages that predate the derived-confidence model |
|
||||
|
||||
@@ -16,10 +16,12 @@ from chemenu import config
|
||||
from chemenu.commands._util import rel_path, success
|
||||
from chemenu.lint_core import (
|
||||
HARD_ERROR_KEYS,
|
||||
MIGRATION_GATED_KEYS,
|
||||
MOST_LINKED_COUNT,
|
||||
QUOTE_LIMIT,
|
||||
count_quote_blocks,
|
||||
default_report_path,
|
||||
hard_error_keys,
|
||||
has_hard_errors,
|
||||
render_markdown,
|
||||
render_summary,
|
||||
@@ -30,10 +32,12 @@ from chemenu.lint_core import (
|
||||
# so does every other name the tests and sibling commands already import.
|
||||
__all__ = [
|
||||
"HARD_ERROR_KEYS",
|
||||
"MIGRATION_GATED_KEYS",
|
||||
"MOST_LINKED_COUNT",
|
||||
"QUOTE_LIMIT",
|
||||
"count_quote_blocks",
|
||||
"default_report_path",
|
||||
"hard_error_keys",
|
||||
"has_hard_errors",
|
||||
"render_markdown",
|
||||
"render_summary",
|
||||
|
||||
@@ -183,6 +183,33 @@ def authorised_labels(source: str, destination: str, kb_dir: Path | None = None)
|
||||
return labels
|
||||
|
||||
|
||||
LABELLED_EDGE_FIELD = "related"
|
||||
|
||||
|
||||
def collections_that_can_carry_labelled_edges() -> set[str]:
|
||||
"""Collection names whose offered page types actually have somewhere to put
|
||||
a labelled edge.
|
||||
|
||||
Derived from `page_ref_fields:`, the same way `stack_required_collections()`
|
||||
is derived from `base_dir:`: a type that does not offer `related:` cannot
|
||||
carry a label, no matter what its collection's contract authorises.
|
||||
"""
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
names: set[str] = set()
|
||||
for type_path, _frontmatter in resolver.list_type_specs():
|
||||
try:
|
||||
if resolver.get_root(type_path) != "kb":
|
||||
continue
|
||||
base_dir = resolver.get_base_dir(type_path)
|
||||
fields = resolver.get_page_ref_fields(type_path)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if base_dir and LABELLED_EDGE_FIELD in fields:
|
||||
names.add(str(base_dir).strip("/"))
|
||||
return names
|
||||
|
||||
|
||||
def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
||||
"""What each `COLLECTION.md` fails to declare about itself.
|
||||
|
||||
@@ -200,6 +227,7 @@ def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
||||
issues: list[str] = []
|
||||
|
||||
required = stack_required_collections()
|
||||
can_label = collections_that_can_carry_labelled_edges()
|
||||
present = {path.name for path in iter_kb_collections(root)}
|
||||
for name in required:
|
||||
if name not in present:
|
||||
@@ -244,6 +272,20 @@ def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
||||
)
|
||||
+ f" - it must be {str(expected).lower()}"
|
||||
)
|
||||
|
||||
# An `outbound:` block on a collection whose types offer no `related:`
|
||||
# authorises labels that no page there can write. That is not a harmless
|
||||
# extra: it reads as a licence, so the label gets written into the prose
|
||||
# by hand instead - an identifier back in free text, which is the exact
|
||||
# thing the labelled-edge model exists to end. The two halves have to
|
||||
# move together, so the check names both directions of the fix.
|
||||
if declared.get(OUTBOUND_FIELD) and collection.name not in can_label:
|
||||
issues.append(
|
||||
f"{relative}: `{OUTBOUND_FIELD}:` authorises labels, but no page type writing "
|
||||
f"into kb/{collection.name}/ offers a `{LABELLED_EDGE_FIELD}:` field - so no "
|
||||
f"page here can carry a labelled edge. Either drop the block, or give the "
|
||||
f"type-spec a `{LABELLED_EDGE_FIELD}:` in its `page_ref_fields:` and schema"
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from chemenu.provenance import legacy_source_pages as find_legacy_source_pages
|
||||
from chemenu.provenance import orphan_footnote_defs as find_orphan_footnote_defs
|
||||
from chemenu.provenance import uncovered_raw_files as find_uncovered_raw_files
|
||||
from chemenu.provenance import undefined_footnote_refs as find_undefined_footnote_refs
|
||||
from chemenu.version import Version
|
||||
from chemenu.kb_scan import (
|
||||
GENERATED_INDEX,
|
||||
WIKILINK_RE,
|
||||
@@ -478,13 +479,6 @@ def default_report_path(report: dict) -> Path:
|
||||
# through the index or navigation only. `quote_limit_violations` is advisory
|
||||
# too - it flags a habit, not a broken tree.
|
||||
#
|
||||
# `unlabelled_edges` and `unauthorised_labels` are advisory **for now**, and
|
||||
# that is a dated decision rather than a judgment about severity: they describe
|
||||
# exactly the state a corpus is in between the 4.0.0 machinery landing and the
|
||||
# migration reaching each page, which is the window `.wikitool-kb.json` exists
|
||||
# to represent. They become hard errors once the migration is recorded - the
|
||||
# same path `legacy_citation_markers` took.
|
||||
#
|
||||
# `malformed_edges` and `unbalanced_markers` are hard from the start: neither
|
||||
# describes an unconverted page, only a broken one.
|
||||
#
|
||||
@@ -505,11 +499,57 @@ HARD_ERROR_KEYS = (
|
||||
"dangling_frontmatter_refs",
|
||||
"malformed_edges",
|
||||
"unbalanced_markers",
|
||||
"unlabelled_edges",
|
||||
"unauthorised_labels",
|
||||
"invalid_type_paths",
|
||||
"type_resolution_errors",
|
||||
"schema_validation_errors",
|
||||
)
|
||||
|
||||
# Findings that only become hard once the corpus has reached a given shape.
|
||||
#
|
||||
# `unlabelled_edges` and `unauthorised_labels` describe exactly the state a
|
||||
# corpus is in between the 4.0.0 machinery landing and the migration reaching
|
||||
# each page - the window `.wikitool-kb.json` exists to represent. Failing on
|
||||
# them during that window would refuse the very corpus that
|
||||
# `instructions/migrations/4.0.0-link-taxonomy.md` tells an instance to publish
|
||||
# unit by unit. So the promotion is tied to `kb_version` rather than to a
|
||||
# release date: below 4.0.0 they are advisory, at or above it an unlabelled
|
||||
# edge is no longer a page awaiting conversion but an edge whose author did not
|
||||
# say what it asserts.
|
||||
#
|
||||
# Gated rather than simply promoted, which is where this departs from
|
||||
# `legacy_citation_markers`: that one was flipped in a later version and any
|
||||
# instance still owing the citation migration had to live with a red lint. The
|
||||
# ledger can answer the question now, so it does.
|
||||
MIGRATION_GATED_KEYS: dict[str, Version] = {
|
||||
"unlabelled_edges": Version(4, 0, 0),
|
||||
"unauthorised_labels": Version(4, 0, 0),
|
||||
}
|
||||
|
||||
_ALWAYS_HARD = Version(0, 0, 0)
|
||||
|
||||
|
||||
def hard_error_keys(kb_version: Version | None = None) -> tuple[str, ...]:
|
||||
"""`HARD_ERROR_KEYS` minus the findings this corpus has not grown into yet.
|
||||
|
||||
`kb_version` defaults to what `.wikitool-kb.json` records. A tree without
|
||||
one - a fresh instance, which starts at the current shape rather than
|
||||
migrating into it - keeps every key: there is no outstanding migration for
|
||||
a gated finding to be the noise of.
|
||||
"""
|
||||
from chemenu import kb_state
|
||||
|
||||
if kb_version is None:
|
||||
kb_version = kb_state.read_kb_version()
|
||||
if kb_version is None:
|
||||
return HARD_ERROR_KEYS
|
||||
return tuple(
|
||||
key
|
||||
for key in HARD_ERROR_KEYS
|
||||
if kb_version >= MIGRATION_GATED_KEYS.get(key, _ALWAYS_HARD)
|
||||
)
|
||||
|
||||
|
||||
def has_hard_errors(report: dict) -> bool:
|
||||
return any(report.get(key) for key in HARD_ERROR_KEYS)
|
||||
return any(report.get(key) for key in hard_error_keys())
|
||||
|
||||
@@ -192,3 +192,29 @@ def test_an_undeclared_destination_authorises_nothing(kb_root):
|
||||
missing declaration to be filled in with a permissive default."""
|
||||
_authorising(kb_root, "entities", " concepts: [implements]")
|
||||
assert kb_collections.authorised_labels("entities", "sources") == set()
|
||||
|
||||
|
||||
def test_outbound_on_a_collection_that_cannot_carry_labels_is_a_finding(kb_root):
|
||||
"""`kb/sources/` is the live case: the `source` type-spec offers no
|
||||
`related:`, so an `outbound:` block there authorises labels no page can
|
||||
write. Left unchecked it reads as a licence and the label gets written into
|
||||
the prose by hand instead - an identifier back in free text, which is what
|
||||
labelled edges exist to end."""
|
||||
_authorising(kb_root, "sources", " any: [is-evidence-for]", required=True)
|
||||
issues = kb_collections.declaration_issues(kb_root)
|
||||
assert any(
|
||||
"kb/sources/COLLECTION.md" in issue and "no page type writing into kb/sources/" in issue
|
||||
for issue in issues
|
||||
)
|
||||
|
||||
|
||||
def test_outbound_is_fine_on_a_collection_whose_type_offers_related(kb_root):
|
||||
_collection(kb_root, "sources", profile="sources", required=True)
|
||||
_authorising(kb_root, "entities", " any: [uses]")
|
||||
assert kb_collections.declaration_issues(kb_root) == []
|
||||
|
||||
|
||||
def test_a_collection_without_outbound_is_not_a_finding(kb_root):
|
||||
"""Absence is the declaration `kb/sources/` makes: no authored edges here."""
|
||||
_collection(kb_root, "sources", profile="sources", required=True)
|
||||
assert kb_collections.declaration_issues(kb_root) == []
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
from chemenu import config
|
||||
import pytest
|
||||
|
||||
from chemenu import config, kb_state
|
||||
from chemenu.commands.lint import (
|
||||
HARD_ERROR_KEYS,
|
||||
hard_error_keys,
|
||||
has_hard_errors,
|
||||
lint_command,
|
||||
render_markdown,
|
||||
@@ -11,6 +15,8 @@ from chemenu.commands.lint import (
|
||||
)
|
||||
from chemenu.frontmatter_io import write_page
|
||||
from chemenu.provenance import cite_id, render_cite_block
|
||||
from chemenu.tests.conftest import use_shipped_type_specs
|
||||
from chemenu.version import Version
|
||||
|
||||
|
||||
def test_lint_detects_unparsable_frontmatter(kb_dir):
|
||||
@@ -472,3 +478,83 @@ def test_lint_does_not_count_a_shell_prompt_as_a_quote(kb_dir):
|
||||
)
|
||||
report = run_lint(kb_dir)
|
||||
assert [i for i in report["quote_limit_violations"] if i["page"] == "shelly"] == []
|
||||
|
||||
|
||||
# --- the migration gate on `unlabelled_edges` / `unauthorised_labels` -------
|
||||
#
|
||||
# These four repoint `config.ROOT` at the fixture tree, which the `kb_dir`
|
||||
# fixture alone does not do. Two things need it: `write_kb_state()` writes
|
||||
# `.wikitool-kb.json` relative to `ROOT`, and `lint`'s collection lookup
|
||||
# resolves a page against `config.KB_DIR` rather than against the directory it
|
||||
# was handed - so without the repoint the gate would be read off the real
|
||||
# repository's state file.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rooted_kb(kb_dir, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
use_shipped_type_specs(monkeypatch)
|
||||
return kb_dir
|
||||
|
||||
|
||||
def _page_with_an_unlabelled_edge(rooted_kb):
|
||||
"""A `related:` entry that is a bare title rather than a `label: title`
|
||||
mapping - the shape every page was in before the 4.0.0 migration."""
|
||||
write_page(
|
||||
rooted_kb / "entities/tools/bare-edge.md",
|
||||
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||
"modified": "2026-09-03", "related": ["Modbus"], "sources": [], "confidence": 0.8,
|
||||
"provenance": "general", "summary": "One edge whose label was never declared."},
|
||||
"\n# bare-edge\n\nAn edge without a label.\n",
|
||||
)
|
||||
|
||||
|
||||
def test_unlabelled_edge_is_advisory_below_kb_version_4(rooted_kb):
|
||||
"""The window the migration document describes: the machinery has landed,
|
||||
the corpus has not been converted yet, and `lint --fail-on-error` must not
|
||||
refuse the very tree the migration tells the instance to publish unit by
|
||||
unit."""
|
||||
_page_with_an_unlabelled_edge(rooted_kb)
|
||||
kb_state.write_kb_state(Version(3, 0, 0), [])
|
||||
report = run_lint(rooted_kb)
|
||||
assert report["unlabelled_edges"] != []
|
||||
assert "unlabelled_edges" not in hard_error_keys()
|
||||
# Narrowed to the finding under test: the fixture corpus carries unrelated
|
||||
# hard errors of its own, so asserting on the whole report would prove
|
||||
# nothing about the gate.
|
||||
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is False
|
||||
|
||||
|
||||
def test_unlabelled_edge_is_hard_at_kb_version_4(rooted_kb):
|
||||
"""Once the migration is recorded, a bare title is no longer a page waiting
|
||||
its turn - it is an edge whose author did not say what it asserts."""
|
||||
_page_with_an_unlabelled_edge(rooted_kb)
|
||||
kb_state.write_kb_state(Version(4, 0, 0), [])
|
||||
report = run_lint(rooted_kb)
|
||||
assert report["unlabelled_edges"] != []
|
||||
assert "unlabelled_edges" in hard_error_keys()
|
||||
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is True
|
||||
|
||||
|
||||
def test_unauthorised_label_is_hard_at_kb_version_4(rooted_kb):
|
||||
"""The fixture contracts authorise `depends-on` but not `contradicts`."""
|
||||
write_page(
|
||||
rooted_kb / "entities/tools/off-menu.md",
|
||||
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
|
||||
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
|
||||
"\n# off-menu\n\nA label the source collection never authorised.\n",
|
||||
)
|
||||
kb_state.write_kb_state(Version(4, 0, 0), [])
|
||||
report = run_lint(rooted_kb)
|
||||
assert report["unauthorised_labels"] != []
|
||||
assert "unauthorised_labels" in hard_error_keys()
|
||||
assert has_hard_errors({"unauthorised_labels": report["unauthorised_labels"]}) is True
|
||||
|
||||
|
||||
def test_a_tree_that_never_declared_a_kb_version_keeps_every_key(rooted_kb):
|
||||
"""No `.wikitool-kb.json` means a fresh instance, which starts at the
|
||||
current shape rather than migrating into it - so there is no outstanding
|
||||
migration for a gated finding to be the noise of."""
|
||||
assert kb_state.read_kb_version() is None
|
||||
assert hard_error_keys() == HARD_ERROR_KEYS
|
||||
|
||||
@@ -45,7 +45,7 @@ def test_get_page_ref_fields_reads_the_type_spec():
|
||||
assert resolver.get_page_ref_fields("types/entity.md") == ["related", "sources"]
|
||||
assert resolver.get_page_ref_fields("types/concept.md") == ["related", "sources"]
|
||||
assert resolver.get_page_ref_fields("types/source.md") == ["entities", "concepts"]
|
||||
assert resolver.get_page_ref_fields("types/comparison.md") == ["entities"]
|
||||
assert resolver.get_page_ref_fields("types/comparison.md") == ["entities", "related"]
|
||||
|
||||
|
||||
def test_page_ref_fields_exist_in_the_type_schema():
|
||||
|
||||
Reference in New Issue
Block a user