stack: Konfidenz-Mechanismus ersatzlos entfernt, Korpus migriert (schliesst #60, #86)
CI / verify (push) Successful in 56s
Release / release (push) Successful in 36s

Files changed:
- .wikitool-kb.json
- AGENTS.md
- CHANGES.md
- INSTALL-MCP.md
- INSTALL.md
- README.md
- VERSION
- instructions/capture-session.md
- instructions/dev/issue-tracking.md
- instructions/german-terminology.md
- instructions/kb-profiles.md
- instructions/migrate-corpus.md
- instructions/migrations/5.0.0-confidence-removal.md
- instructions/private-instance.md
- instructions/setup-instance.md
- instructions/wiki-lint/SKILL.md
- instructions/wiki-manage/SKILL.md
- instructions/wiki-query/SKILL.md
- kb/CONTRACT.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- kb/concepts/architectures/Consolidation Tiers.md
- kb/concepts/architectures/Context Isolation.md
- kb/concepts/architectures/Cross-platform Agent Skills.md
- kb/concepts/architectures/Episodic Memory.md
- kb/concepts/architectures/Hybrid Search.md
- kb/concepts/architectures/Implementation Spectrum.md
- kb/concepts/architectures/Knowledge Graph.md
- kb/concepts/architectures/LLM Wiki Pattern.md
- kb/concepts/architectures/MCP-Leseserver.md
- kb/concepts/architectures/Memory Lifecycle.md
- kb/concepts/architectures/OKF Compatibility.md
- kb/concepts/architectures/Optional Instance Context File.md
- kb/concepts/architectures/Personalization Plane.md
- kb/concepts/architectures/Procedural Memory.md
- kb/concepts/architectures/RAG.md
- kb/concepts/architectures/Scale Ceiling.md
- kb/concepts/architectures/Semantic Memory.md
- kb/concepts/architectures/Three-Layer Architecture.md
- kb/concepts/architectures/Token Economics.md
- kb/concepts/architectures/Working Memory.md
- kb/concepts/decisions/Delete Rather Than Anonymize.md
- kb/concepts/decisions/Denylist over Allowlist.md
- kb/concepts/decisions/Diff-Reviewable Agent Edits.md
- kb/concepts/decisions/Dual Licensing by File Plan.md
- kb/concepts/decisions/Issue Label Scheme.md
- kb/concepts/decisions/KB Stack Versioning.md
- kb/concepts/decisions/Structural Enforcement over Documented Rule.md
- kb/concepts/patterns/Audit Trail.md
- kb/concepts/patterns/BM25.md
- kb/concepts/patterns/Command Round-Trip Integrity.md
- kb/concepts/patterns/Confidence Scoring.md
- kb/concepts/patterns/Contradiction Resolution.md
- kb/concepts/patterns/Entity Extraction.md
- kb/concepts/patterns/Filter on Ingest.md
- kb/concepts/patterns/Forgetting.md
- kb/concepts/patterns/Graph Traversal.md
- kb/concepts/patterns/Mesh Sync.md
- kb/concepts/patterns/Quality Scoring.md
- kb/concepts/patterns/Reciprocal Rank Fusion.md
- kb/concepts/patterns/Self-Healing.md
- kb/concepts/patterns/Shared vs Private.md
- kb/concepts/patterns/Typed Relationships.md
- kb/concepts/patterns/Vector Search.md
- kb/concepts/patterns/Work Coordination.md
- kb/concepts/problems/Ambient Environment Dependency.md
- kb/concepts/problems/Detect-Repair Asymmetry.md
- kb/concepts/problems/Green Suite Blind Spot.md
- kb/concepts/problems/Naming Convention Conflict.md
- kb/concepts/problems/Write-Once Frontmatter Fields.md
- kb/concepts/protocols/CPPC.md
- kb/concepts/protocols/Modbus.md
- kb/concepts/protocols/SSD TRIM.md
- kb/concepts/workflows/Anti-Cramming Heuristic.md
- kb/concepts/workflows/Bulk Operations.md
- kb/concepts/workflows/CI Integration.md
- kb/concepts/workflows/Checkpoint Audit.md
- kb/concepts/workflows/Claude Code Auto Mode.md
- kb/concepts/workflows/Content Quality Control.md
- kb/concepts/workflows/Crystallization.md
- kb/concepts/workflows/Event-Driven Automation.md
- kb/concepts/workflows/Hooks.md
- kb/concepts/workflows/Index Scaling.md
- kb/concepts/workflows/Iteration and Cost Limits.md
- kb/concepts/workflows/KB Migration.md
- kb/concepts/workflows/Knowledge Compounding.md
- kb/concepts/workflows/Lint Workflow.md
- kb/concepts/workflows/Mass-Update Gate.md
- kb/concepts/workflows/Multi-Agent Collaboration.md
- kb/concepts/workflows/Privacy and Governance.md
- kb/concepts/workflows/Publish-Remote Gate.md
- kb/concepts/workflows/Quality and Self-Correction.md
- kb/concepts/workflows/Semantic Lint Automation.md
- kb/concepts/workflows/Session Orientation.md
- kb/concepts/workflows/Split Merge Reclassify.md
- kb/concepts/workflows/Split Threshold.md
- kb/concepts/workflows/Stub Threshold.md
- kb/concepts/workflows/Supersession.md
- kb/concepts/workflows/User Management.md
- kb/concepts/workflows/Workflow Extraction.md
- kb/concepts/workflows/Workflow Orchestration.md
- kb/entities/people/Andrej Karpathy.md
- kb/entities/people/E3DC GmbH.md
- kb/entities/people/Rohit Gupta.md
- kb/entities/people/Vannevar Bush.md
- kb/entities/projects/BCDModule.md
- kb/entities/projects/Chemenu.md
- kb/entities/projects/andybalholm-edl.md
- kb/entities/projects/goresponsiveness.md
- kb/entities/projects/ha-core.md
- kb/entities/projects/hacs-e3dc.md
- kb/entities/projects/hacs-integration-blueprint.md
- kb/entities/projects/llm-wiki-skills.md
- kb/entities/projects/plugnburn-edl.md
- kb/entities/projects/wiki-skills-vanillaflava.md
- kb/entities/projects/wiki-skills.md
- kb/entities/systems/AGENTS.md.md
- kb/entities/systems/CLAUDE.md.md
- kb/entities/systems/E3DC.md
- kb/entities/systems/ENVIRONMENT.md.md
- kb/entities/systems/Memex.md
- kb/entities/systems/Tolkien Gateway.md
- kb/entities/technologies/Arch Linux.md
- kb/entities/technologies/Disk Encryption.md
- kb/entities/technologies/Docker.md
- kb/entities/technologies/GRUB.md
- kb/entities/technologies/Gitea Actions.md
- kb/entities/technologies/Gitea.md
- kb/entities/technologies/Go.md
- kb/entities/technologies/Home Assistant.md
- kb/entities/technologies/Kernel PM Governors.md
- kb/entities/technologies/LVM.md
- kb/entities/technologies/Linux Kernel.md
- kb/entities/technologies/MQTT.md
- kb/entities/technologies/OPC UA.md
- kb/entities/technologies/Python.md
- kb/entities/technologies/Rust.md
- kb/entities/technologies/Wine GE.md
- kb/entities/technologies/Wine-Staging.md
- kb/entities/technologies/acpi-cpufreq.md
- kb/entities/technologies/amd-pstate.md
- kb/entities/technologies/iii Engine.md
- kb/entities/tools/AUR.md
- kb/entities/tools/Act Runner.md
- kb/entities/tools/Agent Memory.md
- kb/entities/tools/Aura.md
- kb/entities/tools/Bottles.md
- kb/entities/tools/ChatGPT.md
- kb/entities/tools/Claude Code.md
- kb/entities/tools/Codex CLI.md
- kb/entities/tools/Dataview.md
- kb/entities/tools/GPG.md
- kb/entities/tools/GitHub Copilot.md
- kb/entities/tools/Gitea MCP Server.md
- kb/entities/tools/Lutris.md
- kb/entities/tools/Marp.md
- kb/entities/tools/Mistral Vibe.md
- kb/entities/tools/NotebookLM.md
- kb/entities/tools/Obsidian Web Clipper.md
- kb/entities/tools/Obsidian.md
- kb/entities/tools/OpenAI Codex.md
- kb/entities/tools/OpenCode.md
- kb/entities/tools/Pi.md
- kb/entities/tools/Proton.md
- kb/entities/tools/Steam.md
- kb/entities/tools/Wine.md
- kb/entities/tools/awesome-llm-wiki.md
- kb/entities/tools/farzaa gist.md
- kb/entities/tools/gdeploy.md
- kb/entities/tools/makepkg.md
- kb/entities/tools/pascalandy schema.md
- kb/entities/tools/qmd.md
- kb/entities/tools/wikitool.md
- kb/index.md
- kb/log.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/api.py
- tools/chemenu/cli.py
- tools/chemenu/commands/confidence_decay.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/index_build.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/search.py
- tools/chemenu/commands/touch.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/conventions.py
- tools/chemenu/corpus_diff.py
- tools/chemenu/frontmatter_io.py
- tools/chemenu/lint_core.py
- tools/chemenu/mcp/server.py
- tools/chemenu/page.py
- tools/chemenu/search/base.py
- tools/chemenu/search/filters.py
- tools/chemenu/search/ripgrep.py
- tools/chemenu/search/service.py
- tools/chemenu/search/types.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_api.py
- tools/chemenu/tests/test_confidence_decay.py
- tools/chemenu/tests/test_corpus_diff.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_frontmatter_io.py
- tools/chemenu/tests/test_index_build.py
- tools/chemenu/tests/test_kb_scan.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_page_ops.py
- tools/chemenu/tests/test_provenance.py
- tools/chemenu/tests/test_raw_cmd.py
- tools/chemenu/tests/test_search.py
- tools/chemenu/tests/test_touch.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/tests/test_xref.py
- tools/chemenu/version.py
- types/concept.md
- types/concept.schema.yaml
- types/entity.md
- types/entity.schema.yaml
- types/instruction.md
- types/type-spec.md
This commit is contained in:
2026-09-10 19:51:48 +02:00
parent bb8f956719
commit 54d9540c08
225 changed files with 637 additions and 1145 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ class Corpus:
"""`wikitool search --json`, as a value.
`predicates` takes the raw `--field` strings, so the CLI and this share
one parser and cannot drift on what `confidence<0.6` means.
one parser and cannot drift on what `entity_type=system` means.
"""
raw = list(predicates)
if not text and not raw:
-2
View File
@@ -12,7 +12,6 @@ try:
from chemenu.commands import (
_util,
cite_cmd,
confidence_decay,
dist_cmd,
doctor,
docs_verify,
@@ -60,7 +59,6 @@ app.add_typer(cite_cmd.app, name="cite")
app.add_typer(links_cmd.app, name="links")
app.add_typer(index_build.app, name="index")
app.add_typer(log_append.app, name="log")
app.add_typer(confidence_decay.app, name="confidence")
app.add_typer(provenance_cmd.app, name="sources")
app.add_typer(raw_cmd.app, name="raw")
app.add_typer(instructions_cmd.app, name="instructions")
-169
View File
@@ -1,169 +0,0 @@
"""Apply the confidence decay formula defined in the wiki contract's
"Confidence Scoring" section: confidence decays at 1% per month since last
confirmation (the page's `modified` / `date` / `created` field), floored at 0.2.
This is pure arithmetic - previously left to the LLM's judgment even though
the contract specifies it exactly. Dry-run by default; `--apply` writes changes.
`confidence` is a *derived* field: it is always recomputed as
`confidence_base * (1 - 0.01 * months)`, never from its own previous value.
Keeping the undecayed anchor in `confidence_base` is what makes repeated runs
idempotent - decaying the stored `confidence` in place (the pre-2026-08-13
behavior) compounded on every run, because the elapsed-months factor kept
growing while the multiplicand had already shrunk.
Pages with `concept_type: decision` are skipped structurally, not as an
interim measure. The formula models staleness - a claim that nobody has
re-checked in a while becomes less trustworthy - and a decision is not a
claim about the world that time can falsify. What retires a decision is a
later decision superseding it, never elapsed months on its own; that is a
category the decay formula does not have a term for, so it does not apply
one.
"""
from __future__ import annotations
import datetime
from typing import Optional
import typer
from chemenu import config
from chemenu.commands._util import success
from chemenu.frontmatter_io import write_page
from chemenu.kb_scan import load_kb_pages
app = typer.Typer(help="Apply confidence decay per the wiki contract's Confidence Scoring formula.")
DECAY_RATE_PER_MONTH = 0.01
FLOOR = 0.2
DAYS_PER_MONTH = 30.44
def _parse_date(value) -> Optional[datetime.date]:
if isinstance(value, datetime.datetime):
return value.date()
if isinstance(value, datetime.date):
return value
if isinstance(value, str):
try:
return datetime.date.fromisoformat(value)
except ValueError:
return None
return None
def compute_decay(confidence: float, last_confirmed: datetime.date, today: datetime.date) -> float:
months = max(0.0, (today - last_confirmed).days / DAYS_PER_MONTH)
decayed = confidence * (1 - DECAY_RATE_PER_MONTH * months)
return round(max(FLOOR, decayed), 2)
def _last_confirmed(frontmatter: dict) -> Optional[datetime.date]:
return (
_parse_date(frontmatter.get("modified"))
or _parse_date(frontmatter.get("date"))
or _parse_date(frontmatter.get("created"))
)
@app.command("init-base")
def confidence_init_base(
apply: bool = typer.Option(False, "--apply", help="Write changes; default is dry-run (preview only)"),
):
"""Backfill `confidence_base` from the current `confidence` on pages that
don't have one yet.
Needed once, when a wiki predates the derived-`confidence` model. Pages
already carrying a base are left untouched, so this is safe to re-run.
"""
pages = load_kb_pages(config.KB_DIR)
changes = []
for title, page in sorted(pages.items()):
confidence = page.frontmatter.get("confidence")
if confidence is None or page.frontmatter.get("confidence_base") is not None:
continue
changes.append((title, page, float(confidence)))
if not changes:
success("Every page with a confidence already has a confidence_base.")
return
for title, page, base in changes:
typer.echo(f"{title}: confidence_base <- {base:.2f}")
if apply:
_set_after(page.frontmatter, "confidence", "confidence_base", round(base, 2))
write_page(page.path, page.frontmatter, page.body)
if apply:
success(f"Set confidence_base on {len(changes)} page(s).")
else:
typer.echo(f"\n{len(changes)} page(s) would change. Re-run with --apply to write.")
def _set_after(frontmatter: dict, after_key: str, key: str, value) -> None:
"""Insert `key` immediately after `after_key`, preserving frontmatter order
(write_page serializes in dict insertion order, and the schemas list
confidence_base right after confidence)."""
if key in frontmatter or after_key not in frontmatter:
frontmatter[key] = value
return
items = list(frontmatter.items())
frontmatter.clear()
for existing_key, existing_value in items:
frontmatter[existing_key] = existing_value
if existing_key == after_key:
frontmatter[key] = value
@app.command("decay")
def confidence_decay(
apply: bool = typer.Option(False, "--apply", help="Write changes; default is dry-run (preview only)"),
):
pages = load_kb_pages(config.KB_DIR)
today = datetime.date.today()
changes = []
missing_base = []
for title, page in sorted(pages.items()):
if page.frontmatter.get("concept_type") == "decision":
continue
confidence = page.frontmatter.get("confidence")
if confidence is None:
continue
base = page.frontmatter.get("confidence_base")
if base is None:
missing_base.append(title)
continue
last_confirmed = _last_confirmed(page.frontmatter)
if last_confirmed is None:
continue
new_confidence = compute_decay(float(base), last_confirmed, today)
if abs(new_confidence - round(float(confidence), 2)) >= 0.01:
changes.append((title, page, float(confidence), new_confidence))
if missing_base:
typer.echo(
f"Skipped {len(missing_base)} page(s) with a confidence but no confidence_base "
"- run `wikitool confidence init-base --apply` first:"
)
for title in missing_base[:10]:
typer.echo(f" - {title}")
if len(missing_base) > 10:
typer.echo(f" ... and {len(missing_base) - 10} more")
typer.echo("")
if not changes:
success("No confidence values need decaying.")
return
for title, page, old, new in changes:
typer.echo(f"{title}: {old:.2f} -> {new:.2f}")
if apply:
page.frontmatter["confidence"] = new
write_page(page.path, page.frontmatter, page.body)
if apply:
success(f"Updated confidence on {len(changes)} page(s).")
else:
typer.echo(f"\n{len(changes)} page(s) would change. Re-run with --apply to write.")
+1 -1
View File
@@ -209,7 +209,7 @@ def check_cli_readme() -> list[str]:
table, and every command documented there must exist.
The reverse check matches a documented cell against the full registered
command path (e.g. `xref add`, `confidence init-base`), not just its first
command path (e.g. `xref add`, `migrate verify`), not just its first
token - checking only the top-level word would let a typo'd or invented
subcommand (`xref frobnicate`) sit undetected next to a real command group
(`xref`) forever.
+1 -1
View File
@@ -218,7 +218,7 @@ def check_conventions() -> Check:
`kb/CONVENTIONS.md` carries the decisions `kb/CONTRACT.md` deliberately no
longer makes: the KB language and the headings its two generated regions
render under, the tone examples, the confidence rubric, the naming forms.
render under, the tone examples, the hedging rule, the naming forms.
`FAIL` rather than `WARN` because those decisions bind every page, and
because it has the same two failure modes the personalization pair has: the
+1 -1
View File
@@ -132,7 +132,7 @@ def build_index_map(collections: list[Collection]) -> str:
"To *find* a page, search instead of reading this file:",
"",
'- `tools/wikitool search "<text>"` - ranked text search, with summaries',
"- `tools/wikitool search --field entity_type=system --field 'confidence<0.6'`"
"- `tools/wikitool search --field entity_type=system --field '!sources'`"
" - structured query over frontmatter",
"",
"The page tables live in a generated `INDEX.md` inside each collection, linked below.",
+2 -2
View File
@@ -77,8 +77,8 @@ def _build_frontmatter(
schema's own `default:` where declared, an empty list for arrays), or are
omitted entirely if optional with no sensible default (e.g.
`source_url`). This is what lets frontmatter shape - and scaffold-time
defaults like `provenance: general` or `confidence: 0.5` - follow the
schema instead of being hand-declared per CLI command.
defaults like `provenance: general` - follow the schema instead of being
hand-declared per CLI command.
"""
frontmatter: Dict[str, Any] = {"type": type_path}
for field_name, field_schema in (schema or {}).get("properties", {}).items():
+2 -2
View File
@@ -11,7 +11,7 @@ Two halves, deliberately kept separate:
`chemenu/search/`.
- Frontmatter predicates (`--field`) are evaluated here, in-process, on the
structured YAML rather than on its rendering. With no text at all this is a
pure structured query, which is how "systems below 0.6 confidence, oldest
pure structured query, which is how "systems with no sources, oldest
first" is asked without a second command.
Scope is `kb/` only. `instructions/` is discovered through
@@ -101,7 +101,7 @@ def search_command(
),
limit: int = typer.Option(20, "--limit", help="Maximum number of results. 0 for no limit."),
sort: str = typer.Option(
None, "--sort", help="Sort by a result field; prefix with '-' to reverse, e.g. -confidence."
None, "--sort", help="Sort by a result field; prefix with '-' to reverse, e.g. -modified."
),
backend: str = typer.Option(
None,
+10 -26
View File
@@ -1,12 +1,12 @@
"""`wikitool touch` - update the self-describing frontmatter fields of a page.
`modified:`, `summary:`, `provenance:` and `confidence_base:` describe the page
itself rather than its relationships, so they were the one part of frontmatter
the skills still told the LLM to edit by hand - a carve-out in the otherwise
absolute "never hand-write frontmatter" rule. Bumping a date and rewriting a
one-line summary are mechanical, so they belong here: the field name is chosen
from the type's own schema (`modified` for entity/concept, `date` for source),
and the result is schema-validated before it is written.
`modified:`, `summary:` and `provenance:` describe the page itself rather than
its relationships, so they were the one part of frontmatter the skills still
told the LLM to edit by hand - a carve-out in the otherwise absolute "never
hand-write frontmatter" rule. Bumping a date and rewriting a one-line summary
are mechanical, so they belong here: the field name is chosen from the type's
own schema (`modified` for entity/concept, `date` for source), and the result
is schema-validated before it is written.
Only `modified:` is bumped automatically. A source's `date:` is the publication
date of the material itself, not a record of when we last edited the page, so it
@@ -49,10 +49,6 @@ UNSETTABLE = {
"changing it changes the page's schema *and* the directory it belongs in - "
"see instructions/page-lifecycle.md"
),
"confidence": (
"derived, not authored: set `--confidence-base` and run "
"`wikitool confidence decay --apply` to recompute it"
),
"related": "page-reference field - use `wikitool xref add` / `xref remove`",
"sources": (
"page-reference field - written from the other side by "
@@ -193,12 +189,6 @@ def touch_command(
provenance: Optional[str] = typer.Option(
None, "--provenance", help="Replace the page's provenance marker (sourced|general|mixed)"
),
confidence_base: Optional[float] = typer.Option(
None,
"--confidence-base",
help="Re-assess the page's undecayed confidence (0.0-1.0). `confidence` itself is derived - "
"run `wikitool confidence decay --apply` afterwards to recompute it.",
),
date: Optional[str] = typer.Option(
None, "--date",
help="Date to record (YYYY-MM-DD). `modified:` defaults to today; a source's "
@@ -230,9 +220,9 @@ def touch_command(
):
"""Bump a page's `modified:` date and optionally rewrite its other frontmatter fields.
`--summary`/`--provenance`/`--confidence-base` are shorthands for the three
fields worth their own flag; `--set`/`--add`/`--remove` reach every other
field the page's type declares. Before they existed, a field `new` wrote
`--summary`/`--provenance` are shorthands for the two fields worth their
own flag; `--set`/`--add`/`--remove` reach every other field the page's
type declares. Before they existed, a field `new` wrote
once - `tags:`, `raw_files:` - could never be corrected: `touch` did not
know it, hand-editing frontmatter is what the tool exists to prevent, and
deleting the page to recreate it breaks every reference already pointing at
@@ -291,12 +281,6 @@ def touch_command(
frontmatter["provenance"] = provenance
touched.add("provenance")
changes.append(f"provenance: {page.frontmatter.get('provenance')} -> {provenance}")
if confidence_base is not None:
frontmatter["confidence_base"] = round(confidence_base, 2)
touched.add("confidence_base")
changes.append(
f"confidence_base: {page.frontmatter.get('confidence_base')} -> {round(confidence_base, 2)}"
)
# --set/--add/--remove last, so an explicit field always wins over the
# shorthand flags rather than depending on option order.
+43 -1
View File
@@ -203,6 +203,13 @@ def bump_command(
"--no-migration",
help="Why the escalation to a boundary crossing needs no content migration (recorded in CHANGES.md)",
),
migration_required: bool = typer.Option(
False,
"--migration-required",
help="Retract this candidate's earlier --no-migration line: a migration is needed after all. "
"Requires a migration document already targeting the new base, and refuses when the entry "
"carries no --no-migration line to retract.",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
):
"""Raise or continue the running candidate, and open or update its
@@ -221,7 +228,13 @@ def bump_command(
migration document for the new base or `--no-migration "<reason>"`. Both
lines are written into the entry once and then persist across every later
bump at the same stage: a follow-up bump need not repeat them, and passing
either on a bump that crosses nothing at all is refused."""
either on a bump that crosses nothing at all is refused.
A later bump of the same candidate that finds out `--no-migration` was
wrong after all retracts it with `--migration-required` - write the
migration document first, then re-run with this flag instead of
`--no-migration`. There is no other way to take the line back: it is
machine-written, and invariant 1 forbids hand-editing it."""
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
if len(selected) != 1:
fail("Pass exactly one of --major / --minor / --patch")
@@ -299,6 +312,34 @@ def bump_command(
)
return
if migration_required and no_migration:
fail("--migration-required and --no-migration contradict each other on the same bump.")
return
if migration_required and not current.is_prerelease:
fail(
"--migration-required only makes sense on a bump that continues an already-open "
"candidate - there is no running candidate here to retract a no-migration line from."
)
return
if migration_required:
current_section = version_mod.changes_section(text, current) or ""
if version_mod.MIGRATION_NONE_MARKER not in current_section:
fail(
f"{current}'s {version_mod.CHANGES_FILENAME} entry carries no "
f"`{version_mod.MIGRATION_NONE_MARKER}` line to retract - nothing to do."
)
return
from chemenu import kb_state
if not any(m.target == new_version.base for m in kb_state.load_migrations()):
fail(
f"--migration-required retracts the no-migration line, so a migration document must "
f"target {new_version.base} first - write one under "
f"{rel_path(kb_state.migrations_dir())}/{new_version.base}-<slug>.md "
f"(see instructions/migrate-corpus.md), then re-run with --migration-required."
)
return
if dry_run:
success(f"Dry run: {current} -> {new_version}{boundary}. Nothing written.")
return
@@ -309,6 +350,7 @@ def bump_command(
text, new_version, today_iso(), title.strip(), author,
no_migration_reason=no_migration.strip() if no_migration else None,
breaking_reason=breaking.strip() if breaking else None,
migration_required=migration_required,
),
encoding="utf-8",
)
+6 -6
View File
@@ -2,12 +2,12 @@
`kb/CONTRACT.md` and this file answer two different questions. The contract
holds what the code enforces - what a collection is, which files are generated,
how `provenance:` and `confidence_base` work - and is identical in every
instance, so `dist export` ships it verbatim. `kb/CONVENTIONS.md` holds what
each instance decides for itself: the language its pages are written in, the
relationship-label vocabulary, the tone examples, the confidence rubric, the
ADR prefix. The distribution ships only `kb/CONVENTIONS.md.template`, exactly
the split `USER.md`/`SOUL.md` already use one directory up.
how `provenance:` works - and is identical in every instance, so `dist export`
ships it verbatim. `kb/CONVENTIONS.md` holds what each instance decides for
itself: the language its pages are written in, the relationship-label
vocabulary, the tone examples, the hedging rule, the ADR prefix. The
distribution ships only `kb/CONVENTIONS.md.template`, exactly the split
`USER.md`/`SOUL.md` already use one directory up.
Only one part of it is machine-read, and it is the part that used to be Python:
the three section headings `xref add` and `cite add` write. While
-1
View File
@@ -41,7 +41,6 @@ STRUCTURAL_FIELDS = (
"type",
"created",
"date",
"confidence_base",
"provenance",
"source_type",
"source_language",
+12 -8
View File
@@ -1,6 +1,6 @@
"""Read/write markdown files with YAML frontmatter, matching the formatting
conventions already used across wiki/ (inline flow-style lists, unquoted
dates, two-decimal confidence values).
dates).
We deliberately avoid a generic yaml.dump() for the frontmatter block because
PyYAML's default block-style output does not match the existing convention
@@ -104,10 +104,10 @@ def read_page_with_error(path: Path) -> tuple[dict[str, Any], str, str | None]:
permissive handed back instead of dropped.
A page whose YAML is broken reads as `{}`, and a `{}` page then has no
`confidence` and no `kind`: it drops out of `--field confidence<0.6` -
precisely the query meant to find pages in bad shape - while looking to the
caller like a page that simply did not match. Returning the reason is what
lets a caller say so instead of losing the page quietly.
`sources` and no `kind`: it drops out of `--field '!sources'` - precisely
the query meant to find pages in bad shape - while looking to the caller
like a page that simply did not match. Returning the reason is what lets a
caller say so instead of losing the page quietly.
"""
text = path.read_text(encoding="utf-8")
match = FRONTMATTER_RE.match(text)
@@ -168,9 +168,13 @@ def _format_scalar(value: Any, flow: bool = False) -> str:
return '""'
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, float):
return f"{value:.2f}"
if isinstance(value, int):
if isinstance(value, (int, float)):
# Bare, not routed through the string-quoting logic below: that logic
# asks "would this text read back as the same *string*", which is the
# wrong question for a value that was never a string - a real float
# like 0.9 would fail that probe (it reads back as a float) and get
# wrongly quoted into the string "0.9", silently changing its type on
# the next read.
return str(value)
if isinstance(value, (datetime.date, datetime.datetime)):
return value.isoformat()
-69
View File
@@ -15,7 +15,6 @@ from __future__ import annotations
from datetime import date
from pathlib import Path
from typing import Optional
from collections import Counter
@@ -144,59 +143,6 @@ def unclassified_source_pages(pages: dict[str, Page]) -> list[dict]:
]
# Capture-field ceilings (Gitea #67, kb/CONTRACT.md § "Confidence against
# source standing"): the weakest `authority`/`fidelity` among a page's cited
# sources bounds how high its `confidence_base` may honestly sit. Stack
# vocabulary, not instance configuration - `fidelity`/`authority` are defined
# by `raw/CONTRACT.md`, not by `kb/CONVENTIONS.md`. `unknown` and every axis
# value not listed here carry no ceiling: a backfilled "we don't know" is not
# a claim about the source, and `normative`/`verbatim`/`published` are simply
# not weaker than hand-set confidence gets to be.
_AUTHORITY_CEILING = {"reporting": 0.8, "opinion": 0.6}
_FIDELITY_CEILING = {"secondhand": 0.7, "nontextual": 0.7}
def _capture_ceiling(source_pages: list[Page]) -> Optional[float]:
"""The tightest ceiling implied by `source_pages`' capture fields, or
None if none of them carry a value with a ceiling at all."""
ceilings = [
ceiling
for src in source_pages
for field_map, value in (
(_AUTHORITY_CEILING, src.frontmatter.get("authority")),
(_FIDELITY_CEILING, src.frontmatter.get("fidelity")),
)
if (ceiling := field_map.get(value)) is not None
]
return min(ceilings) if ceilings else None
def confidence_exceeds_source_standing(pages: dict[str, Page]) -> list[dict]:
"""Pages whose `confidence_base` sits above what their cited sources'
capture standing can honestly carry.
Advisory, not a formula (kb/CONTRACT.md § "Confidence against source
standing" has the reasoning): `confidence_base` stays a human judgment,
and authority is a ceiling a page may sit under by independent
verification, not a value a formula could compute outright.
"""
findings: list[dict] = []
for title, page in sorted(pages.items()):
if page.kind not in ("entity", "concept"):
continue
confidence_base = page.frontmatter.get("confidence_base")
source_titles = page.frontmatter.get("sources") or []
if confidence_base is None or not source_titles:
continue
source_pages = [
pages[t] for t in source_titles if t in pages and pages[t].kind == "source"
]
ceiling = _capture_ceiling(source_pages)
if ceiling is not None and confidence_base > ceiling:
findings.append({"page": title, "confidence_base": confidence_base, "ceiling": ceiling})
return findings
def nested_pages(kb_dir: Path, pages: dict[str, Page]) -> list[dict]:
"""Report form of `find_nested_pages`: `{"page", "at", "depth"}` per
finding.
@@ -545,7 +491,6 @@ def run_lint(kb_dir: Path) -> dict:
"nested_pages": nested,
"unsharded_collections": unsharded_collections(kb_dir, pages),
"unclassified_source_pages": unclassified_source_pages(pages),
"confidence_exceeds_source_standing": confidence_exceeds_source_standing(pages),
"uncovered_raw_files": find_uncovered_raw_files(config.RAW_DIR, pages),
"broken_raw_refs": find_broken_raw_refs(pages),
"duplicate_raw_file_owners": find_duplicate_raw_file_owners(pages),
@@ -645,13 +590,6 @@ def render_markdown(report: dict) -> str:
lambda i: f"[[{i['page']}]] - `wikitool touch --set source_type=<value>` once its "
"category is known",
)
_section(
lines, "Confidence Above Source Standing (ceiling, not a formula) - recommendation, not an error",
report.get("confidence_exceeds_source_standing", []),
lambda i: f"[[{i['page']}]] confidence_base={i['confidence_base']} exceeds the "
f"{i['ceiling']} ceiling its cited sources' fidelity/authority carry - "
"kb/CONTRACT.md § \"Confidence against source standing\"",
)
_section(
lines, "Uncovered Raw Files (no source page)", report["uncovered_raw_files"],
lambda i: f"`{i}`",
@@ -819,13 +757,6 @@ def default_report_path(report: dict) -> Path:
# would penalise the honest "I don't know yet" that the slot exists to allow,
# where the old silent `default: notes` hid the same uncertainty for free.
#
# `confidence_exceeds_source_standing` is advisory by construction, like
# `unsharded_collections` above: `confidence_base` stays a hand-set judgment
# call (kb/CONTRACT.md § Confidence), and a source's capture standing is a
# ceiling a page may sit under by independent verification, not a value a
# formula could compute outright - see kb/CONTRACT.md § "Confidence against
# source standing" (Gitea #67).
#
# `malformed_edges` and `unbalanced_markers` are hard from the start: neither
# describes an unconverted page, only a broken one.
#
+6 -6
View File
@@ -125,9 +125,9 @@ def build_server(
name="search",
description=(
"Find pages in kb/ by text, by frontmatter, or by both. Returns "
"title, path, kind, summary and confidence per hit, so a result can "
"be judged without fetching the page. Prefer this over listing "
"files: the answer is a few hundred tokens instead of a whole index."
"title, path, kind and summary per hit, so a result can be judged "
"without fetching the page. Prefer this over listing files: the "
"answer is a few hundred tokens instead of a whole index."
),
)
def search(
@@ -140,9 +140,9 @@ def build_server(
"""Search the wiki.
`predicates` are frontmatter filters in the CLI's own `--field` syntax,
ANDed: `confidence<0.6`, `entity_type=system`, `tags~k8s`, `source_url:*`
(present), `!source_url` (absent). With no `query` this is a pure
structured query over frontmatter.
ANDed: `entity_type=system`, `tags~k8s`, `source_url:*` (present),
`!source_url` (absent). With no `query` this is a pure structured
query over frontmatter.
`regex` applies the pattern with ripgrep's linear engine. It is off by
default, so an accidental `.*` is a literal.
+2 -2
View File
@@ -19,8 +19,8 @@ class Page:
# Why the frontmatter above is empty, when it is empty for a reason. A page
# whose YAML does not parse reads back as `{}`, and a `{}` page has no
# `confidence` and no `kind`: it then drops out of `--field confidence<0.6`
# - the query whose whole purpose is to find pages in bad shape - looking
# `sources` and no `kind`: it then drops out of `--field '!sources'` - the
# query whose whole purpose is to find pages in bad shape - looking
# exactly like a page that did not match. Loaders that know the reason put
# it here so a caller can report the page instead of losing it.
frontmatter_error: Optional[str] = None
+2 -2
View File
@@ -2,8 +2,8 @@
A backend answers the *text* half of a query and nothing else. Frontmatter
predicates are applied afterwards, in-process, by `filters.py` - so a new
backend never has to reimplement `confidence>=0.8`, and filtering behaves
identically no matter who found the page.
backend never has to reimplement `modified>=2026-01-01`, and filtering
behaves identically no matter who found the page.
"""
from __future__ import annotations
+5 -5
View File
@@ -2,10 +2,10 @@
Predicates run in-process against `Page` objects rather than being pushed into
the backend. Two reasons: every backend gets the same filter semantics for
free, and the values being filtered on (`confidence`, `modified`, `tags`) are
free, and the values being filtered on (`modified`, `tags`, `provenance`) are
structured YAML, not text - a lexical backend can only ever match their
*rendering*, which is how `confidence: 0.8` starts matching a query for `0.8`
in a page's body.
*rendering*, which is how `modified: 2026-08-01` starts matching a query for
`2026-08-01` in a page's body.
"""
from __future__ import annotations
@@ -18,8 +18,8 @@ from chemenu.errors import ValidationError
from chemenu.page import Page
from chemenu.search.types import Predicate
# Longest first: `>=` must be tried before `>`, or `confidence>=0.8` parses as
# field `confidence` op `>` value `=0.8`.
# Longest first: `>=` must be tried before `>`, or `modified>=2026-08-01`
# parses as field `modified` op `>` value `=2026-08-01`.
_COMPARISON_OPS = (">=", "<=", ">", "<", "~", "=")
# Fields that are not in the frontmatter but are what an agent actually asks
-2
View File
@@ -215,7 +215,6 @@ def build_hit(
tags = page.frontmatter.get("tags") or []
modified = page.frontmatter.get("modified") or page.frontmatter.get("date")
confidence = page.frontmatter.get("confidence")
return SearchHit(
title=page.title,
@@ -225,7 +224,6 @@ def build_hit(
subtype=page.subtype,
summary=summary,
tags=[str(t) for t in tags] if isinstance(tags, (list, tuple)) else [str(tags)],
confidence=float(confidence) if isinstance(confidence, (int, float)) else None,
modified=str(modified) if modified else None,
score=score,
backend=backend,
+7 -6
View File
@@ -52,11 +52,12 @@ def load_pages_by_path(kb_dir: Path | None = None, root: Path | None = None) ->
def unreadable_pages(pages: dict[str, Page]) -> list[dict[str, str]]:
"""The pages whose frontmatter could not be used, as `{path, reason}`.
Reported rather than swallowed. Such a page has no `confidence` and no
`kind`, so it silently drops out of every positive `--field` predicate -
including the low-confidence sweep that exists to find pages in exactly
that state. Saying nothing makes it look like a page that did not match;
an empty block is excluded, because a page can legitimately carry one.
Reported rather than swallowed. Such a page has no `kind` and no readable
frontmatter at all, so it silently drops out of every positive `--field`
predicate - including the sweeps that exist to find pages in exactly that
state (`!sources`, `provenance=general`). Saying nothing makes it look
like a page that did not match; an empty block is excluded, because a
page can legitimately carry one.
"""
return [
{"path": key, "reason": page.frontmatter_error}
@@ -77,7 +78,7 @@ def _sort_key(hit: SearchHit, field: str):
def sort_hits(hits: list[SearchHit], sort: str | None) -> list[SearchHit]:
"""Sort by a hit field. A leading `-` reverses, e.g. `--sort -confidence`."""
"""Sort by a hit field. A leading `-` reverses, e.g. `--sort -modified`."""
if not sort:
return hits
descending = sort.startswith("-")
+3 -5
View File
@@ -7,7 +7,7 @@ from typing import Any, Optional
@dataclass(frozen=True)
class Predicate:
"""One frontmatter condition, e.g. `entity_type=system` or `confidence>=0.8`."""
"""One frontmatter condition, e.g. `entity_type=system` or `modified>=2026-01-01`."""
field: str
op: str # one of: = ~ >= <= > < exists absent
@@ -24,8 +24,8 @@ class Predicate:
@dataclass
class SearchQuery:
"""A search request. `text` is optional: with no text this is a pure
structured query over frontmatter, which is how "every system with
confidence below 0.6" is asked without inventing a second command."""
structured query over frontmatter, which is how "every system with no
sources" is asked without inventing a second command."""
text: Optional[str] = None
predicates: tuple[Predicate, ...] = ()
@@ -58,7 +58,6 @@ class SearchHit:
subtype: Optional[str] = None
summary: str = ""
tags: list[str] = field(default_factory=list)
confidence: Optional[float] = None
modified: Optional[str] = None
score: float = 0.0
backend: str = ""
@@ -73,7 +72,6 @@ class SearchHit:
"subtype": self.subtype,
"summary": self.summary,
"tags": self.tags,
"confidence": self.confidence,
"modified": self.modified,
"score": round(self.score, 3),
"backend": self.backend,
+4 -4
View File
@@ -278,7 +278,7 @@ def kb_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
{
"type": "types/entity.md", "entity_type": "system",
"tags": ["server"], "created": "2026-07-31", "modified": "2026-07-31",
"related": ["Borealis"], "sources": [], "confidence": 0.9,
"related": ["Borealis"], "sources": [],
"summary": "Server hosting DocStore with ZFS storage",
},
"\n# aurora\n\n## Description\n\nHosts things.\n\n## Relationships\n\n- **Related to:** [[Borealis]]\n\n## See Also\n\n- [[Borealis]]\n",
@@ -288,7 +288,7 @@ def kb_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
{
"type": "types/entity.md", "entity_type": "system",
"tags": ["workstation"], "created": "2026-08-02", "modified": "2026-08-02",
"related": ["aurora"], "sources": [], "confidence": 0.9,
"related": ["aurora"], "sources": [],
},
"\n# Borealis\n\n## Description\n\nA workstation.\n\n## Relationships\n\n- **Related to:** [[aurora]]\n",
)
@@ -297,7 +297,7 @@ def kb_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
{
"type": "types/entity.md", "entity_type": "tool",
"tags": [], "created": "2026-07-25", "modified": "2026-07-25",
"related": [], "sources": [], "confidence": 0.8,
"related": [], "sources": [],
},
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
)
@@ -306,7 +306,7 @@ def kb_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
{
"type": "types/concept.md", "concept_type": "protocol",
"tags": [], "created": "2026-07-25", "modified": "2026-07-25",
"related": [], "sources": [], "confidence": 0.7,
"related": [], "sources": [],
},
"\n# Modbus\n\n## Definition\n\nIndustrial protocol.\n",
)
+2 -2
View File
@@ -33,7 +33,7 @@ def foreign_corpus(tmp_path: Path) -> Path:
kb.mkdir(parents=True)
(root / "kb" / "entities" / "COLLECTION.md").write_text("# entities\n", encoding="utf-8")
(kb / "Peregrine.md").write_text(
"---\ntype: types/entity.md\nentity_type: system\nconfidence: 0.42\n"
"---\ntype: types/entity.md\nentity_type: system\n"
"summary: A system that exists only in this fixture.\n---\n\n"
"# Peregrine\n\nPeregrine is the fixture's own system.\n",
encoding="utf-8",
@@ -81,7 +81,7 @@ def test_no_path_of_this_checkout_is_read_while_a_foreign_root_is_set(foreign_co
monkey.setattr(Path, "rglob", watched_rglob)
try:
Corpus(foreign_corpus).search("Peregrine")
Corpus(foreign_corpus).search(predicates=["confidence<0.6"])
Corpus(foreign_corpus).search(predicates=["entity_type=system"])
finally:
monkey.undo()
@@ -1,89 +0,0 @@
import datetime
import pytest
from chemenu import config
from chemenu.commands import confidence_decay
from chemenu.commands.confidence_decay import FLOOR, compute_decay
from chemenu.frontmatter_io import read_page, write_page
def test_no_decay_at_zero_months():
today = datetime.date(2026, 8, 2)
assert compute_decay(0.9, today, today) == 0.9
def test_decay_after_ten_months():
today = datetime.date(2026, 8, 2)
last_confirmed = datetime.date(2025, 10, 2) # ~10 months earlier
result = compute_decay(0.9, last_confirmed, today)
# 0.9 * (1 - 0.01 * ~10) ~= 0.9 * 0.90 = 0.81
assert 0.80 <= result <= 0.82
def test_decay_floors_at_0_2():
today = datetime.date(2026, 8, 2)
long_ago = datetime.date(2015, 1, 1)
result = compute_decay(0.5, long_ago, today)
assert result == FLOOR
@pytest.fixture
def decay_wiki(kb_dir, monkeypatch):
monkeypatch.setattr(config, "KB_DIR", kb_dir)
return kb_dir
def test_init_base_backfills_from_current_confidence(decay_wiki):
confidence_decay.confidence_init_base(apply=True)
frontmatter, _ = read_page(decay_wiki / "entities/systems/aurora.md")
assert frontmatter["confidence_base"] == 0.9
# Written next to `confidence`, keeping the schema's field order.
keys = list(frontmatter)
assert keys.index("confidence_base") == keys.index("confidence") + 1
def test_init_base_is_idempotent(decay_wiki):
confidence_decay.confidence_init_base(apply=True)
before = (decay_wiki / "entities/systems/aurora.md").read_text(encoding="utf-8")
confidence_decay.confidence_init_base(apply=True)
assert (decay_wiki / "entities/systems/aurora.md").read_text(encoding="utf-8") == before
def test_decay_is_idempotent_across_runs(decay_wiki):
"""The regression that motivated `confidence_base`: decaying the stored
`confidence` in place compounded on every run, because the elapsed-months
factor kept growing while the multiplicand had already shrunk."""
confidence_decay.confidence_init_base(apply=True)
confidence_decay.confidence_decay(apply=True)
after_first = (decay_wiki / "entities/systems/aurora.md").read_text(encoding="utf-8")
confidence_decay.confidence_decay(apply=True)
confidence_decay.confidence_decay(apply=True)
assert (decay_wiki / "entities/systems/aurora.md").read_text(encoding="utf-8") == after_first
def test_decay_skips_pages_without_a_base(decay_wiki):
"""Falling back to the stored `confidence` would silently reintroduce the
compounding bug, so pages without a base are skipped instead."""
confidence_decay.confidence_decay(apply=True)
frontmatter, _ = read_page(decay_wiki / "entities/systems/aurora.md")
assert frontmatter["confidence"] == 0.9
def test_decay_skips_decision_pages(decay_wiki):
"""A decision is not falsified by elapsed time, only by a later decision
superseding it - `concept_type: decision` is a categorical skip, not
something an old `modified` date should ever decay (Gitea #38)."""
decision_path = decay_wiki / "concepts" / "some-decision.md"
write_page(
decision_path,
{
"type": "types/concept.md", "concept_type": "decision",
"tags": [], "created": "2015-01-01", "modified": "2015-01-01",
"related": [], "sources": [], "confidence": 0.9, "confidence_base": 0.9,
},
"\n# some-decision\n",
)
confidence_decay.confidence_decay(apply=True)
frontmatter, _ = read_page(decision_path)
assert frontmatter["confidence"] == 0.9
-1
View File
@@ -30,7 +30,6 @@ def page(body: str, **frontmatter) -> Page:
"entity_type": "system",
"created": "2026-07-31",
"provenance": "sourced",
"confidence_base": 0.9,
"related": ["Borealis"],
"sources": ["Source - Aurora"],
}
+1 -1
View File
@@ -16,7 +16,7 @@ def test_registered_commands_include_groups_and_top_level():
assert "new" in commands
assert "touch" in commands
assert "xref add" in commands
assert "confidence init-base" in commands
assert "migrate verify" in commands
assert "docs verify" in commands
+9 -1
View File
@@ -94,7 +94,15 @@ def test_scalar_quoting_is_unchanged_by_the_flow_fix(tmp_path):
up a diff on its next touch."""
assert dump_frontmatter({"year": "1945"}) == "year: '1945'"
assert dump_frontmatter({"summary": "He said hi"}) == "summary: He said hi"
assert dump_frontmatter({"confidence": 0.85}) == "confidence: 0.85"
def test_a_real_float_is_written_bare_not_quoted():
"""A float is not a string that happens to look numeric - the opposite of
what `year: '1945'` above tests. Routing it through the string-quoting
logic would ask "does '0.9' read back as the string '0.9'", which is
false (it reads back as the float 0.9), so it would get wrongly quoted -
silently turning the value into a string on the next read."""
assert dump_frontmatter({"weight": 0.9}) == "weight: 0.9"
# --- Read-path limits (Gitea #33) -------------------------------------------
+1 -1
View File
@@ -235,7 +235,7 @@ def test_rebuild_warns_about_a_nested_page_without_failing(kb_dir, capsys):
kb_dir / "entities/projects/someowner/nested-tool.md",
{"type": "types/entity.md", "entity_type": "project", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25",
"related": [], "sources": [], "confidence": 0.8},
"related": [], "sources": []},
"\n# nested-tool\n",
)
index_rebuild(dry_run=False)
+1 -1
View File
@@ -51,7 +51,7 @@ def test_find_nested_pages_flags_a_page_below_its_area(kb_dir):
kb_dir / "entities/projects/someowner/nested-tool.md",
{"type": "types/entity.md", "entity_type": "project", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25",
"related": [], "sources": [], "confidence": 0.8},
"related": [], "sources": []},
"\n# nested-tool\n",
)
pages = load_kb_pages(kb_dir)
+29 -163
View File
@@ -56,7 +56,7 @@ def test_lint_detects_broken_wikilink(kb_dir):
write_page(
path,
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"modified": "2026-07-25", "related": [], "sources": []},
"\n# gdeploy\n\n## Description\n\nSee [[Nonexistent Page]] for details.\n",
)
report = run_lint(kb_dir)
@@ -74,7 +74,7 @@ def test_lint_detects_dangling_related_ref(kb_dir):
write_page(
kb_dir / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": ["Renamed Away"], "sources": [], "confidence": 0.8},
"modified": "2026-07-25", "related": ["Renamed Away"], "sources": []},
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
)
report = run_lint(kb_dir)
@@ -88,8 +88,7 @@ def test_lint_detects_url_pasted_into_sources(kb_dir):
write_page(
kb_dir / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["https://example.com/x/"],
"confidence": 0.8},
"modified": "2026-07-25", "related": [], "sources": ["https://example.com/x/"]},
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
)
targets = {i["target"] for i in run_lint(kb_dir)["dangling_frontmatter_refs"]}
@@ -118,8 +117,7 @@ def test_lint_ignores_tags_and_raw_files_as_page_refs(kb_dir):
write_page(
kb_dir / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": ["not-a-page"],
"created": "2026-07-25", "modified": "2026-07-25", "related": [], "sources": [],
"confidence": 0.8},
"created": "2026-07-25", "modified": "2026-07-25", "related": [], "sources": []},
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
)
targets = {i["target"] for i in run_lint(kb_dir)["dangling_frontmatter_refs"]}
@@ -130,7 +128,7 @@ def test_lint_detects_orphan_page(kb_dir):
write_page(
kb_dir / "entities/tools/isolated.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"modified": "2026-07-25", "related": [], "sources": []},
"\n# isolated\n\n## Description\n\nNothing links here.\n",
)
report = run_lint(kb_dir)
@@ -144,7 +142,7 @@ def test_lint_detects_missing_frontmatter_fields(kb_dir):
"""Missing-field detection now comes solely from the type's schema (its
`required:` list), not a separately hand-maintained REQUIRED_FIELDS dict -
so only fields the schema actually requires (created/modified/provenance/
summary) are flagged, not schema-optional ones like tags/confidence."""
summary) are flagged, not schema-optional ones like tags."""
write_page(
kb_dir / "entities/tools/incomplete.md",
{"type": "types/entity.md", "entity_type": "tool"},
@@ -160,7 +158,7 @@ def test_lint_detects_duplicate_titles(kb_dir):
write_page(
kb_dir / "concepts/gdeploy.md",
{"type": "types/concept.md", "concept_type": "pattern", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.5},
"modified": "2026-07-25", "related": [], "sources": []},
"\n# gdeploy\n\nDuplicate stem with the tool page.\n",
)
report = run_lint(kb_dir)
@@ -172,7 +170,7 @@ def test_lint_detects_title_mismatch(kb_dir):
write_page(
kb_dir / "entities/tools/mismatched.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"modified": "2026-07-25", "related": [], "sources": []},
"\n# Totally Different Title\n",
)
report = run_lint(kb_dir)
@@ -190,7 +188,7 @@ def test_lint_detects_a_misplaced_page(kb_dir):
write_page(
kb_dir / "entities/systems/misplaced-tool.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"modified": "2026-07-25", "related": [], "sources": []},
"\n# misplaced-tool\n",
)
report = run_lint(kb_dir)
@@ -223,7 +221,7 @@ def test_lint_detects_a_nested_page_as_a_hard_error(kb_dir):
write_page(
kb_dir / "entities/projects/someowner/nested-tool.md",
{"type": "types/entity.md", "entity_type": "project", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"modified": "2026-07-25", "related": [], "sources": []},
"\n# nested-tool\n",
)
report = run_lint(kb_dir)
@@ -411,7 +409,7 @@ def test_lint_flags_legacy_citation_marker_as_hard_error(kb_dir):
write_page(
kb_dir / "concepts/protocols/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"], "confidence": 0.7},
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"]},
"\n# Modbus\n\n## Definition\n\nUses port 502 ^[[Source - Aurora]].\n",
)
report = run_lint(kb_dir)
@@ -423,7 +421,7 @@ def test_lint_flags_undefined_footnote_ref_as_hard_error(kb_dir):
write_page(
kb_dir / "concepts/protocols/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.7},
"modified": "2026-07-25", "related": [], "sources": []},
"\n# Modbus\n\n## Definition\n\nUses port 502 [^s-ghost].\n",
)
report = run_lint(kb_dir)
@@ -437,7 +435,7 @@ def test_lint_flags_orphan_footnote_def_as_hard_error(kb_dir):
write_page(
kb_dir / "concepts/protocols/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"], "confidence": 0.7},
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"]},
f"\n# Modbus\n\n## Definition\n\nIndustrial protocol, no citation here.\n\n{block}",
)
report = run_lint(kb_dir)
@@ -451,7 +449,7 @@ def test_lint_clean_footnote_citation_has_no_hard_errors(kb_dir):
write_page(
kb_dir / "concepts/protocols/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"], "confidence": 0.7},
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"]},
f"\n# Modbus\n\n## Definition\n\nUses port 502 [^{cid}].\n\n{block}",
)
report = run_lint(kb_dir)
@@ -464,7 +462,7 @@ def test_lint_detects_quote_limit_violation(kb_dir):
write_page(
kb_dir / "entities/tools/quotey.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"modified": "2026-07-25", "related": [], "sources": []},
"\n# quotey\n\n> First quote\n\n> Second quote\n\n> Third quote\n",
)
report = run_lint(kb_dir)
@@ -487,7 +485,7 @@ def test_lint_flags_invalid_type_path(kb_dir):
write_page(
kb_dir / "entities/tools/bad-type.md",
{"type": "not-a-path", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"modified": "2026-07-25", "related": [], "sources": []},
"\n# bad-type\n",
)
report = run_lint(kb_dir)
@@ -499,7 +497,7 @@ def test_lint_flags_unresolvable_type_path(kb_dir):
write_page(
kb_dir / "entities/tools/unresolvable-type.md",
{"type": "types/does-not-exist.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"modified": "2026-07-25", "related": [], "sources": []},
"\n# unresolvable-type\n",
)
report = run_lint(kb_dir)
@@ -513,7 +511,7 @@ def test_lint_flags_schema_validation_error(kb_dir):
{
"type": "types/entity.md", "entity_type": "not-a-real-entity-type", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25", "related": [], "sources": [],
"confidence": 0.8, "provenance": "general", "summary": "x",
"provenance": "general", "summary": "x",
},
"\n# bad-schema\n",
)
@@ -538,7 +536,7 @@ def test_render_summary_keeps_sections_that_found_something(kb_dir):
write_page(
kb_dir / "entities/tools/dangling.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"modified": "2026-07-25", "related": [], "sources": []},
"\n# dangling\n\nPoints at [[No Such Page]].\n",
)
summary = render_summary(run_lint(kb_dir))
@@ -609,8 +607,7 @@ def test_lint_ignores_citation_syntax_shown_as_code(kb_dir):
write_page(
kb_dir / "concepts/Citation Mechanism.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-08-31",
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
"provenance": "general", "summary": "Notation shown as code, not used."},
"modified": "2026-08-31", "related": [], "sources": [], "provenance": "general", "summary": "Notation shown as code, not used."},
"\n# Citation Mechanism\n\n## Definition\n\n"
"`cite add` prints a `[^cite-id]` marker to paste at the fact, and upserts\n"
"its definition into the trailing block:\n\n"
@@ -629,8 +626,7 @@ def test_lint_still_sees_a_real_citation_beside_a_mentioned_one(kb_dir):
write_page(
kb_dir / "concepts/Mixed Citation.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-08-31",
"modified": "2026-08-31", "related": [], "sources": ["Source - Aurora"], "confidence": 0.8,
"provenance": "sourced", "summary": "A real citation beside a mentioned one."},
"modified": "2026-08-31", "related": [], "sources": ["Source - Aurora"], "provenance": "sourced", "summary": "A real citation beside a mentioned one."},
f"\n# Mixed Citation\n\n## Definition\n\nThe marker `[^s-mentioned]` is written like "
f"this one [^{cid}].\n\n{block}",
)
@@ -647,8 +643,7 @@ def test_lint_ignores_wikilink_examples_in_code(kb_dir):
write_page(
kb_dir / "concepts/Wikilink Syntax.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-08-31",
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
"provenance": "general", "summary": "Notation shown as code, not used."},
"modified": "2026-08-31", "related": [], "sources": [], "provenance": "general", "summary": "Notation shown as code, not used."},
"\n# Wikilink Syntax\n\n## Definition\n\nA link is written `[[Page Title]]`:\n\n"
"```markdown\nSee [[Some Page That Does Not Exist]] for details.\n```\n",
)
@@ -663,8 +658,7 @@ def test_lint_counts_a_wrapped_quote_once(kb_dir):
write_page(
kb_dir / "entities/tools/wrapped.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-08-31",
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
"provenance": "general", "summary": "One wrapped quotation."},
"modified": "2026-08-31", "related": [], "sources": [], "provenance": "general", "summary": "One wrapped quotation."},
"\n# wrapped\n\n> One quotation, wrapped across four lines,\n> which is how the rest of\n"
"> this repository wraps its prose, and\n> therefore not four quotations.\n",
)
@@ -678,8 +672,7 @@ def test_lint_counts_separated_quotes_separately(kb_dir):
write_page(
kb_dir / "entities/tools/blocky.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-08-31",
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
"provenance": "general", "summary": "Three quotations, two wrapped."},
"modified": "2026-08-31", "related": [], "sources": [], "provenance": "general", "summary": "Three quotations, two wrapped."},
"\n# blocky\n\n> First quote, wrapped\n> over two lines.\n\n> Second quote.\n\n"
"> Third quote, also\n> wrapped.\n",
)
@@ -694,8 +687,7 @@ def test_lint_does_not_count_a_shell_prompt_as_a_quote(kb_dir):
write_page(
kb_dir / "entities/tools/shelly.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-08-31",
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
"provenance": "general", "summary": "A shell transcript, not a quotation."},
"modified": "2026-08-31", "related": [], "sources": [], "provenance": "general", "summary": "A shell transcript, not a quotation."},
"\n# shelly\n\n```bash\n> line one\n\n> line two\n\n> line three\n```\n",
)
report = run_lint(kb_dir)
@@ -717,8 +709,7 @@ def _page_with_an_unlabelled_edge(kb_dir):
write_page(
kb_dir / "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."},
"modified": "2026-09-03", "related": ["Modbus"], "sources": [], "provenance": "general", "summary": "One edge whose label was never declared."},
"\n# bare-edge\n\nAn edge without a label.\n",
)
@@ -756,7 +747,7 @@ def test_unauthorised_label_is_hard_at_kb_version_4(kb_dir):
kb_dir / "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."},
"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), [])
@@ -782,7 +773,7 @@ def test_unauthorised_label_is_judged_in_a_tree_that_is_not_the_configured_kb(
kb_dir / "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."},
"provenance": "general", "summary": "A label off this menu."},
"\n# off-menu\n\nA label the source collection never authorised.\n",
)
elsewhere = tmp_path / "elsewhere"
@@ -822,7 +813,7 @@ def _pair(kb_dir, forward, backward):
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-09-04", "modified": "2026-09-04",
"related": [] if edge is None else [{edge: other}],
"sources": [], "confidence": 0.8, "provenance": "general",
"sources": [], "provenance": "general",
"summary": f"One half of a pair, asserting {edge} about the other."},
f"\n# {name}\n\nHalf a pair.\n",
)
@@ -892,128 +883,3 @@ def test_redundant_see_also_reaches_the_rendered_report_and_the_summary(kb_dir):
summary = render_summary(report)
assert "Redundant see-also" in summary
assert "[[nearside]]" in summary and "depends-on" in summary
# --- Confidence against source standing (Gitea #67) --------------------------
def _write_capped_source(kb_dir, title, *, fidelity=None, authority=None, raw_path=None):
"""A distinct, never-colliding raw_files: path per title by default - the
fixture's own 'Source - Aurora' already claims raw/notes/Aurora.md, and
two of these in one test must not claim the same path either."""
if raw_path is None:
raw_path = f"raw/notes/{title.replace(' ', '-')}.md"
frontmatter = {
"type": "types/source.md", "source_type": "notes", "author": "Torben",
"raw_files": [raw_path], "date": "2026-09-01",
"tags": [], "entities": [], "concepts": [], "summary": "Test source.",
}
if fidelity is not None:
frontmatter["fidelity"] = fidelity
if authority is not None:
frontmatter["authority"] = authority
write_page(kb_dir / "sources" / f"{title}.md", frontmatter, f"\n# {title}\n\n## Summary\n\nTest.\n")
def test_confidence_exceeds_source_standing_flags_a_high_confidence_opinion_source(kb_dir):
_write_capped_source(kb_dir, "Source - Weak", fidelity="secondhand", authority="opinion")
write_page(
kb_dir / "concepts/protocols/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Weak"],
"confidence": 0.9, "confidence_base": 0.9},
"\n# Modbus\n\n## Definition\n\nx.\n",
)
report = run_lint(kb_dir)
assert {"page": "Modbus", "confidence_base": 0.9, "ceiling": 0.6} in report["confidence_exceeds_source_standing"]
# Advisory, not a hard error - unlike the fixture's own baseline issues,
# this finding on its own must never appear in `hard_error_keys()`.
assert "confidence_exceeds_source_standing" not in HARD_ERROR_KEYS
def test_confidence_exceeds_source_standing_is_silent_at_or_under_the_ceiling(kb_dir):
_write_capped_source(kb_dir, "Source - Weak", fidelity="secondhand", authority="opinion")
write_page(
kb_dir / "concepts/protocols/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Weak"],
"confidence": 0.6, "confidence_base": 0.6},
"\n# Modbus\n\n## Definition\n\nx.\n",
)
report = run_lint(kb_dir)
assert report["confidence_exceeds_source_standing"] == []
def test_confidence_exceeds_source_standing_is_silent_on_normative_verbatim_sources(kb_dir):
"""`normative`/`verbatim` carry no ceiling at all - a page may sit as
confident as its own hand-set judgment allows."""
_write_capped_source(kb_dir, "Source - Strong", fidelity="verbatim", authority="normative")
write_page(
kb_dir / "concepts/protocols/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Strong"],
"confidence": 0.99, "confidence_base": 0.99},
"\n# Modbus\n\n## Definition\n\nx.\n",
)
report = run_lint(kb_dir)
assert report["confidence_exceeds_source_standing"] == []
def test_confidence_exceeds_source_standing_is_silent_when_capture_fields_are_unknown(kb_dir):
"""A backfilled `unknown` is not a claim about the source - firing on it
would report every page citing a pre-#67 source at once."""
_write_capped_source(kb_dir, "Source - Backfilled", fidelity="unknown", authority="unknown")
write_page(
kb_dir / "concepts/protocols/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Backfilled"],
"confidence": 0.99, "confidence_base": 0.99},
"\n# Modbus\n\n## Definition\n\nx.\n",
)
report = run_lint(kb_dir)
assert report["confidence_exceeds_source_standing"] == []
def test_confidence_exceeds_source_standing_is_silent_without_capture_fields_at_all(kb_dir):
"""A source page predating Gitea #67 carries neither field yet - same
silence as the explicit `unknown` case, not a finding by omission."""
_write_capped_source(kb_dir, "Source - Predates 67")
write_page(
kb_dir / "concepts/protocols/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Predates 67"],
"confidence": 0.99, "confidence_base": 0.99},
"\n# Modbus\n\n## Definition\n\nx.\n",
)
report = run_lint(kb_dir)
assert report["confidence_exceeds_source_standing"] == []
def test_confidence_exceeds_source_standing_takes_the_tightest_ceiling_among_several_sources(kb_dir):
_write_capped_source(kb_dir, "Source - A", fidelity="published", authority="reporting")
_write_capped_source(kb_dir, "Source - B", fidelity="secondhand", authority="opinion")
write_page(
kb_dir / "concepts/protocols/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - A", "Source - B"],
"confidence": 0.7, "confidence_base": 0.7},
"\n# Modbus\n\n## Definition\n\nx.\n",
)
report = run_lint(kb_dir)
assert {"page": "Modbus", "confidence_base": 0.7, "ceiling": 0.6} in report["confidence_exceeds_source_standing"]
def test_confidence_exceeds_source_standing_reaches_the_rendered_report_and_the_summary(kb_dir):
_write_capped_source(kb_dir, "Source - Weak", fidelity="secondhand", authority="opinion")
write_page(
kb_dir / "concepts/protocols/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Weak"],
"confidence": 0.9, "confidence_base": 0.9},
"\n# Modbus\n\n## Definition\n\nx.\n",
)
report = run_lint(kb_dir)
assert "Confidence Above Source Standing" in render_markdown(report)
summary = render_summary(report)
assert "Confidence Above Source Standing" in summary
assert "[[Modbus]]" in summary
+2 -2
View File
@@ -51,7 +51,7 @@ def corpus(tmp_path: Path) -> Path:
shutil.copytree(config._PACKAGE_ROOT / "types", root / "types")
(root / "kb" / "entities" / "COLLECTION.md").write_text("# entities\n", encoding="utf-8")
(entities / "Kingfisher.md").write_text(
"---\ntype: types/entity.md\nentity_type: system\nconfidence: 0.55\n"
"---\ntype: types/entity.md\nentity_type: system\n"
"summary: The fixture's own system.\n---\n\n"
"# Kingfisher\n\nKingfisher is the system this fixture is about.\n",
encoding="utf-8",
@@ -100,7 +100,7 @@ def test_no_tool_writes_anything_into_the_corpus_or_git(corpus):
).stdout
_call(server, "search", {"query": "Kingfisher"})
_call(server, "search", {"predicates": ["confidence<0.6"]})
_call(server, "search", {"predicates": ["entity_type=system"]})
_call(server, "types")
_call(server, "describe_type", {"name": "entity"})
_call(server, "lint")
+6 -9
View File
@@ -63,7 +63,7 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "gateway.example.net",
"--set", "entity_type=system", "--set", "tags=gateway,firewall",
"--set", "related=Borealis", "--set", "confidence=0.9",
"--set", "related=Borealis",
"--set", "provenance=general",
])
assert result.exit_code == 0, result.output
@@ -74,7 +74,6 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
assert fm["entity_type"] == "system"
assert fm["tags"] == ["gateway", "firewall"]
assert fm["related"] == ["Borealis"]
assert fm["confidence"] == 0.9
assert "# gateway.example.net" in body
@@ -95,16 +94,14 @@ def test_a_scaffolded_body_carries_no_tool_owned_region(monkeypatch, kb_dir):
def test_new_entity_applies_schema_declared_defaults(monkeypatch, kb_dir):
"""provenance and confidence are no longer Typer flag defaults - they
come from the schema's own `default:`, so omitting them still yields a
valid page."""
"""provenance is no longer a Typer flag default - it comes from the
schema's own `default:`, so omitting it still yields a valid page."""
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "Defaulted", "--set", "entity_type=tool",
])
assert result.exit_code == 0, result.output
fm, _body = read_page(kb_dir / "entities/tools/Defaulted.md")
assert fm["provenance"] == "general"
assert fm["confidence"] == 0.5
def test_new_entity_rejects_name_collision(monkeypatch, kb_dir):
@@ -370,13 +367,13 @@ def test_new_comparison_rejects_single_entity(monkeypatch, kb_dir):
def test_repeated_set_appends_for_array_fields():
"""The separator-free way to pass an element containing a comma. The
schema type decides: only array fields append."""
schema = {"properties": {"raw_files": {"type": "array"}, "confidence": {"type": "number"}}}
schema = {"properties": {"raw_files": {"type": "array"}, "weight": {"type": "number"}}}
parsed = parse_set_fields(
["raw_files=raw/a.md", "raw_files=raw/b, with comma.md", "confidence=0.5", "confidence=0.9"],
["raw_files=raw/a.md", "raw_files=raw/b, with comma.md", "weight=0.5", "weight=0.9"],
schema,
)
assert parsed["raw_files"] == ["raw/a.md", "raw/b", "with comma.md"]
assert parsed["confidence"] == 0.9
assert parsed["weight"] == 0.9
def test_repeated_set_with_escaped_comma_keeps_one_element():
+8 -8
View File
@@ -86,7 +86,7 @@ def test_rename_refreshes_stale_slug_derived_cite_id(patched_wiki):
{
"type": "types/concept.md", "concept_type": "protocol",
"tags": [], "created": "2026-07-25", "modified": "2026-07-25",
"related": [], "sources": [], "confidence": 0.7,
"related": [], "sources": [],
},
f"\n# uses-borealis\n\nRuns on it [^{old_id}].\n\n## Footnotes\n\n[^{old_id}]: [[Borealis]]\n",
)
@@ -138,7 +138,7 @@ def test_rename_repoints_references_to_an_existing_page(patched_wiki):
patched_wiki / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25",
"related": ["borealis"], "sources": [], "confidence": 0.8},
"related": ["borealis"], "sources": []},
"\n# gdeploy\n\n## See Also\n\n- [[borealis]]\n",
)
page_ops.rename_command(old="borealis", new="Borealis", dry_run=False)
@@ -154,7 +154,7 @@ def test_rename_reference_only_mode_requires_the_target_to_exist(patched_wiki):
patched_wiki / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25",
"related": ["ghost"], "sources": [], "confidence": 0.8},
"related": ["ghost"], "sources": []},
"\n# gdeploy\n",
)
with pytest.raises(typer.Exit):
@@ -178,7 +178,7 @@ def test_rename_fixes_a_dangling_source_reference(patched_wiki):
patched_wiki / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25", "related": [],
"sources": ["Source - Aurora"], "confidence": 0.8},
"sources": ["Source - Aurora"]},
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
)
page_ops.rename_command(old="Source - Aurora", new="Source - Aurora Notes", dry_run=False)
@@ -246,7 +246,7 @@ def test_rm_of_unreferenced_page_needs_no_confirmation(patched_wiki):
patched_wiki / "entities/tools/isolated.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25", "related": [],
"sources": [], "confidence": 0.8},
"sources": []},
"\n# isolated\n\n## Description\n\nNothing links here.\n",
)
page_ops.rm_command(page_title="isolated", yes=False, dry_run=False)
@@ -260,7 +260,7 @@ def test_rm_leaves_prose_references_and_reports_them(patched_wiki, capsys):
patched_wiki / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25", "related": [],
"sources": [], "confidence": 0.8},
"sources": []},
"\n# gdeploy\n\n## Description\n\nRuns on [[Borealis]] nightly.\n",
)
page_ops.rm_command(page_title="Borealis", yes=True, dry_run=False)
@@ -284,7 +284,7 @@ def test_inbound_pages_sees_frontmatter_only_references(patched_wiki):
patched_wiki / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25",
"related": ["Modbus"], "sources": [], "confidence": 0.8},
"related": ["Modbus"], "sources": []},
"\n# gdeploy\n\n## Description\n\nNo body link at all.\n",
)
assert "gdeploy" in page_ops.inbound_pages(load_kb_pages(patched_wiki), "Modbus")
@@ -299,7 +299,7 @@ def _write_misplaced(kb: Path, relative: str, title: str, entity_type: str) -> N
{
"type": "types/entity.md", "entity_type": entity_type, "tags": [],
"created": "2026-07-25", "modified": "2026-07-25",
"related": [], "sources": [], "confidence": 0.8,
"related": [], "sources": [],
},
f"\n# {title}\n",
)
+6 -6
View File
@@ -304,7 +304,7 @@ def test_citing_pages_via_frontmatter_and_inline(kb_dir, raw_dir):
kb_dir / "entities/tools/gdeploy.md",
{
"type": "entity", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"], "confidence": 0.8,
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"],
},
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
)
@@ -313,7 +313,7 @@ def test_citing_pages_via_frontmatter_and_inline(kb_dir, raw_dir):
kb_dir / "concepts/protocols/Modbus.md",
{
"type": "concept", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.7,
"modified": "2026-07-25", "related": [], "sources": [],
},
f"\n# Modbus\n\n## Definition\n\nUses port 502 {refs}.\n\n{block}",
)
@@ -328,7 +328,7 @@ def test_page_raw_files_resolves_through_sources_and_inline(kb_dir, raw_dir):
kb_dir / "concepts/protocols/Modbus.md",
{
"type": "concept", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"], "confidence": 0.7,
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"],
},
"\n# Modbus\n\n## Definition\n\nIndustrial protocol.\n",
)
@@ -369,7 +369,7 @@ def test_sources_trace_by_raw_and_by_page(kb_dir, raw_dir, monkeypatch):
{
"type": "entity", "entity_type": "system", "tags": ["server"],
"created": "2026-07-31", "modified": "2026-07-31", "related": ["Borealis"],
"sources": ["Source - Aurora"], "confidence": 0.9,
"sources": ["Source - Aurora"],
},
"\n# aurora\n\n## Description\n\nHosts things.\n",
)
@@ -434,7 +434,7 @@ def test_lint_flags_citation_not_in_frontmatter_sources(kb_dir, raw_dir, monkeyp
kb_dir / "concepts/protocols/Modbus.md",
{
"type": "concept", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.7,
"modified": "2026-07-25", "related": [], "sources": [],
"provenance": "sourced",
},
f"\n# Modbus\n\n## Definition\n\nUses port 502 {refs}.\n\n{block}",
@@ -456,7 +456,7 @@ def test_lint_no_drift_when_source_declared(kb_dir, raw_dir, monkeypatch):
kb_dir / "concepts/protocols/Modbus.md",
{
"type": "concept", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"], "confidence": 0.7,
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"],
"provenance": "sourced",
},
f"\n# Modbus\n\n## Definition\n\nUses port 502 {refs}.\n\n{block}",
+2 -2
View File
@@ -494,7 +494,7 @@ def test_replaces_reports_source_and_both_citing_pages(tree, capsys):
{
"type": "types/concept.md", "concept_type": "protocol", "tags": [],
"created": "2026-09-01", "modified": "2026-09-01", "related": [],
"sources": ["Source - Handbuch"], "confidence": 0.7,
"sources": ["Source - Handbuch"],
},
"\n# Handbuch-Konzept\n\n## Definition\n\nx.\n",
)
@@ -503,7 +503,7 @@ def test_replaces_reports_source_and_both_citing_pages(tree, capsys):
{
"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-09-01", "modified": "2026-09-01", "related": [],
"sources": ["Source - Handbuch"], "confidence": 0.7,
"sources": ["Source - Handbuch"],
},
"\n# handbuch-tool\n\n## Description\n\nx.\n",
)
+34 -17
View File
@@ -18,6 +18,7 @@ from chemenu.search.fuse import reciprocal_rank_fusion
from chemenu.search.registry import UnknownBackend, resolve
from chemenu.search.ripgrep import RipgrepBackend, build_argv
from chemenu.search.types import Match, Predicate, SearchHit, SearchQuery
from chemenu.frontmatter_io import write_page
@pytest.fixture
@@ -56,9 +57,9 @@ def _q(*raw, **kwargs):
[
("entity_type=system", Predicate("entity_type", "=", "system")),
("summary~storage", Predicate("summary", "~", "storage")),
("confidence>=0.8", Predicate("confidence", ">=", "0.8")),
("confidence<=0.8", Predicate("confidence", "<=", "0.8")),
("confidence>0.8", Predicate("confidence", ">", "0.8")),
("weight>=0.8", Predicate("weight", ">=", "0.8")),
("weight<=0.8", Predicate("weight", "<=", "0.8")),
("weight>0.8", Predicate("weight", ">", "0.8")),
("modified<2026-08-01", Predicate("modified", "<", "2026-08-01")),
("summary:*", Predicate("summary", "exists", None)),
("!summary", Predicate("summary", "absent", None)),
@@ -70,7 +71,7 @@ def test_parse_predicate_forms(raw, expected):
def test_parse_predicate_prefers_longest_operator():
"""`>=` must be tried before `>`, or the value keeps a stray `=`."""
assert parse_predicate("confidence>=0.8").value == "0.8"
assert parse_predicate("weight>=0.8").value == "0.8"
@pytest.mark.parametrize("raw", ["", "nonsense", "=value", "field=", "!", ":*"])
@@ -94,8 +95,25 @@ def test_substring_match_is_case_insensitive(search):
assert _titles(search(_q("summary~ZFS STORAGE"))) == ["aurora"]
def test_numeric_comparison(search):
assert _titles(search(_q("confidence>=0.9"))) == ["aurora", "Borealis"]
def test_numeric_comparison(kb_dir, tmp_path):
"""`_compare` tries a float parse before falling back to lexicographic -
exercised here on an arbitrary numeric field, since no frontmatter field
in the schema is numeric any more (Gitea #60)."""
write_page(
kb_dir / "entities/tools/heavy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "weight": 0.9},
"\n# heavy\n",
)
write_page(
kb_dir / "entities/tools/light.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "weight": 0.5},
"\n# light\n",
)
pages = load_pages_by_path(kb_dir, tmp_path)
matched = filters.apply_predicates(pages, (parse_predicate("weight>=0.9"),))
assert {page.title for page in matched.values()} == {"heavy"}
def test_date_comparison_handles_yaml_date_objects(search):
@@ -164,7 +182,6 @@ def test_hits_carry_frontmatter_so_the_page_need_not_be_opened(search, backend):
assert hit.kind == "entity"
assert hit.subtype == "system"
assert hit.collection == "entities"
assert hit.confidence == 0.9
assert "ZFS" in hit.summary
@@ -189,17 +206,17 @@ def test_no_matches_is_an_empty_result_not_an_error(search, backend):
def test_limit_and_sort(search):
hits = search(_q("kind=entity", sort="-confidence"))
assert [hit.confidence for hit in hits] == [0.9, 0.9, 0.8]
hits = search(_q("kind=entity", sort="-modified"))
assert [hit.title for hit in hits] == ["Borealis", "aurora", "gdeploy"]
assert len(search(_q("kind=entity", limit=2))) == 2
def test_sort_puts_missing_values_last():
hits = [
SearchHit(title="b", path="b", confidence=None),
SearchHit(title="a", path="a", confidence=0.5),
SearchHit(title="b", path="b", modified=None),
SearchHit(title="a", path="a", modified="2026-01-01"),
]
assert [hit.title for hit in sort_hits(hits, "confidence")] == ["a", "b"]
assert [hit.title for hit in sort_hits(hits, "modified")] == ["a", "b"]
# --- fusion and registry ----------------------------------------------------
@@ -246,7 +263,7 @@ def test_hit_serialises_for_json():
def test_known_fields_includes_virtual_and_real(pages):
fields = filters.known_fields(pages)
assert {"title", "kind", "subtype", "collection"} <= fields
assert {"entity_type", "confidence", "tags"} <= fields
assert {"entity_type", "sources", "tags"} <= fields
# --- Read-path limits (Gitea #33) -------------------------------------------
@@ -299,16 +316,16 @@ def test_a_hanging_ripgrep_is_reported_as_a_failure_not_a_hang(monkeypatch, kb_d
def test_a_page_with_broken_frontmatter_is_reported_not_lost(kb_dir, tmp_path):
"""It matches no positive predicate - including the low-confidence sweep
meant to find pages in exactly that state - so silence reads as 'did not
match'. The page has to be nameable."""
"""It matches no positive predicate - including the sweeps meant to find
pages in exactly that state (`!sources`, `provenance=general`) - so
silence reads as 'did not match'. The page has to be nameable."""
broken = kb_dir / "entities" / "Broken.md"
broken.write_text("---\ntype: [unclosed\n---\n\n# Broken\n", encoding="utf-8")
pages = load_pages_by_path(kb_dir, tmp_path)
key = page_key(broken, tmp_path)
assert pages[key].frontmatter == {}
assert filters.apply_predicates(pages, (parse_predicate("confidence<0.6"),)) .get(key) is None
assert filters.apply_predicates(pages, (parse_predicate("entity_type=system"),)) .get(key) is None
reported = unreadable_pages(pages)
assert [entry["path"] for entry in reported] == [key]
+16 -5
View File
@@ -2,11 +2,14 @@ import datetime
import pytest
import typer
from typer.testing import CliRunner
from chemenu import config
from chemenu.commands.touch import touch_command
from chemenu.frontmatter_io import read_page
runner = CliRunner()
@pytest.fixture
def touch_wiki(kb_dir, monkeypatch):
@@ -27,7 +30,6 @@ def _touch(**overrides):
page_title=None,
summary=None,
provenance=None,
confidence_base=None,
date=None,
set_fields=None,
add_fields=None,
@@ -176,14 +178,23 @@ def test_page_reference_fields_are_refused_and_name_xref(touch_wiki, capsys):
assert "xref" in capsys.readouterr().out
def test_type_and_confidence_are_refused_with_their_owner(touch_wiki, capsys):
def test_type_is_refused_with_its_owner(touch_wiki, capsys):
with pytest.raises(typer.Exit):
_touch(page_title="aurora", set_fields=["type=types/concept.md"])
assert "page-lifecycle" in capsys.readouterr().out
with pytest.raises(typer.Exit):
_touch(page_title="aurora", set_fields=["confidence=0.99"])
assert "confidence-base" in capsys.readouterr().out
def test_confidence_base_flag_no_longer_exists(touch_wiki, monkeypatch):
"""The confidence mechanism is gone (Gitea #60): `--confidence-base` is not
a denylisted field owned elsewhere, it simply does not exist as an option
any more - refused by the CLI parser itself, before `touch_command` runs."""
import chemenu.config as cfg
from chemenu.cli import app
monkeypatch.setattr(cfg, "KB_DIR", touch_wiki)
result = runner.invoke(app, ["touch", "--page", "aurora", "--confidence-base", "0.9"])
assert result.exit_code != 0
assert "confidence-base" in result.output.lower() or "no such option" in result.output.lower()
def test_unknown_field_lists_what_the_page_actually_has(touch_wiki, capsys):
+3 -3
View File
@@ -248,7 +248,7 @@ def test_validate_frontmatter_accepts_conforming_instance():
resolver.validate_frontmatter(
{
"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8,
"modified": "2026-07-25", "related": [], "sources": [],
"provenance": "general", "summary": "A tool.",
},
"types/entity.md",
@@ -267,7 +267,7 @@ def test_validate_frontmatter_reports_invalid_enum_with_field_name():
{
"type": "types/entity.md", "entity_type": "not-a-real-type", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25", "related": [], "sources": [],
"confidence": 0.8, "provenance": "general", "summary": "x",
"provenance": "general", "summary": "x",
},
"types/entity.md",
)
@@ -278,7 +278,7 @@ def test_validate_frontmatter_reports_wrong_field_type():
resolver.validate_frontmatter(
{
"type": "types/entity.md", "entity_type": "tool", "tags": ["a", 3], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8,
"modified": "2026-07-25", "related": [], "sources": [],
"provenance": "general", "summary": "x",
},
"types/entity.md",
+116 -23
View File
@@ -324,7 +324,7 @@ def test_release_entry_can_replace_the_title():
def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Something happened",
breaking=None, no_migration=None, dry_run=False,
breaking=None, no_migration=None, migration_required=False, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -336,11 +336,11 @@ def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First",
breaking=None, no_migration=None, dry_run=False,
breaking=None, no_migration=None, migration_required=False, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second",
breaking=None, no_migration=None, dry_run=False,
breaking=None, no_migration=None, migration_required=False, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -350,7 +350,7 @@ def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(t
def test_bump_dry_run_writes_nothing(tree):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, dry_run=True
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, migration_required=False, dry_run=True
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
assert "1.0.1" not in (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -363,7 +363,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
major, minor, patch = flags
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=major, minor=minor, patch=patch, title="x", breaking=None, no_migration=None, dry_run=False
major=major, minor=minor, patch=patch, title="x", breaking=None, no_migration=None, migration_required=False, dry_run=False
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -371,7 +371,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
def test_bump_refuses_an_empty_title(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=False, patch=True, title=" ", breaking=None, no_migration=None, dry_run=False
major=False, minor=False, patch=True, title=" ", breaking=None, no_migration=None, migration_required=False, dry_run=False
)
@@ -384,7 +384,7 @@ def test_bump_refuses_when_version_and_changelog_disagree(tree):
)
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="x", breaking=None, no_migration=None, dry_run=False
major=False, minor=False, patch=True, title="x", breaking=None, no_migration=None, migration_required=False, dry_run=False
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -398,7 +398,7 @@ def test_a_boundary_crossing_bump_without_a_migration_is_refused(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration=None, dry_run=False,
breaking="the feed moved", no_migration=None, migration_required=False, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -413,7 +413,7 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, dry_run=False,
breaking="every page is retyped", no_migration=None, migration_required=False, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.1"
@@ -430,11 +430,11 @@ def test_a_follow_up_bump_at_the_same_stage_need_not_repeat_breaking_or_migratio
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, dry_run=False,
breaking="every page is retyped", no_migration=None, migration_required=False, dry_run=False,
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Follow-up",
breaking=None, no_migration=None, dry_run=False,
breaking=None, no_migration=None, migration_required=False, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -446,7 +446,7 @@ def test_no_migration_records_the_reason_in_the_changelog(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the release feed moved",
no_migration="no distributed instance exists yet", dry_run=False,
no_migration="no distributed instance exists yet", migration_required=False, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.MIGRATION_NONE_MARKER in changes
@@ -458,10 +458,103 @@ def test_no_migration_is_refused_on_a_compatible_bump(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Fix",
breaking=None, no_migration="not needed", dry_run=False,
breaking=None, no_migration="not needed", migration_required=False, dry_run=False,
)
# --- version bump: retracting --no-migration --------------------------------
def _migration_document(tree: Path, target: str, slug: str = "retype") -> None:
(tree / "instructions" / "migrations" / f"{target}-{slug}.md").write_text(
f"---\ntype: types/instruction.md\nname: {target}-{slug}\n"
f"description: Retype every page.\nmanual: true\n"
f"migrates_to: {target}\nmigration_kind: assisted\n---\n\n# M\n",
encoding="utf-8",
)
def test_migration_required_retracts_the_no_migration_line(tree):
"""A candidate that recorded --no-migration and later turns out to need
one after all has no other way to take that statement back."""
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.MIGRATION_NONE_MARKER in changes
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Turns out it migrates",
breaking=None, no_migration=None, migration_required=True, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.MIGRATION_NONE_MARKER not in changes
assert version_mod.BREAKING_CHANGE_MARKER in changes # untouched by the retraction
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
def test_migration_required_is_refused_without_a_migration_document(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, dry_run=False,
)
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Turns out it migrates",
breaking=None, no_migration=None, migration_required=True, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.MIGRATION_NONE_MARKER in changes # unchanged
def test_migration_required_is_refused_with_no_no_migration_line_to_retract(tree):
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration=None, migration_required=False, dry_run=False,
)
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Nothing to retract",
breaking=None, no_migration=None, migration_required=True, dry_run=False,
)
def test_migration_required_is_refused_together_with_no_migration(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Contradiction",
breaking="the feed moved", no_migration="kb/ untouched",
migration_required=True, dry_run=False,
)
def test_migration_required_is_refused_without_a_running_candidate(tree):
"""Nothing to retract before any candidate has ever crossed the boundary."""
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Fix",
breaking=None, no_migration=None, migration_required=True, dry_run=False,
)
def test_insert_changes_entry_migration_required_clears_the_no_migration_line():
text = CHANGES_HEADER + "## 1.4.0 - 2026-08-29 - Older\n\nBody.\n"
first = version_mod.insert_changes_entry(
text, Version(2, 0, 0, beta=1), "2026-09-01", "Breaking bump", "Someone",
breaking_reason="the feed moved", no_migration_reason="kb untouched",
)
second = version_mod.insert_changes_entry(
first, Version(2, 0, 0, beta=2), "2026-09-02", "Follow-up", "Someone",
migration_required=True,
)
assert version_mod.MIGRATION_NONE_MARKER not in second
assert version_mod.BREAKING_CHANGE_MARKER in second
assert "the feed moved" in second
# --- version bump: the breaking-change note --------------------------------
@@ -471,7 +564,7 @@ def test_a_boundary_crossing_bump_without_breaking_is_refused(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Renamed the feed",
breaking=None, no_migration="kb/ keeps its shape", dry_run=False,
breaking=None, no_migration="kb/ keeps its shape", migration_required=False, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -480,7 +573,7 @@ def test_breaking_records_what_stops_working_in_the_changelog(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Renamed the feed",
breaking="update_url points at a repo path that no longer exists",
no_migration="kb/ keeps its shape", dry_run=False,
no_migration="kb/ keeps its shape", migration_required=False, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.BREAKING_CHANGE_MARKER in changes
@@ -497,7 +590,7 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="New command",
breaking="nothing, really", no_migration=None, dry_run=False,
breaking="nothing, really", no_migration=None, migration_required=False, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
@@ -508,7 +601,7 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
def test_release_fixes_version_and_the_changelog_heading(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
breaking=None, no_migration=None, migration_required=False, dry_run=False,
)
version_cmd.release_command(title=None, dry_run=False)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
@@ -521,11 +614,11 @@ def test_release_fixes_version_and_the_changelog_heading(tree):
def test_release_can_replace_the_title(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
breaking=None, no_migration=None, migration_required=False, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, dry_run=False,
breaking=None, no_migration=None, migration_required=False, dry_run=False,
)
version_cmd.release_command(title="Summary of both bumps", dry_run=False)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -539,7 +632,7 @@ def test_release_can_replace_the_title(tree):
def test_release_dry_run_writes_nothing(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
breaking=None, no_migration=None, migration_required=False, dry_run=False,
)
version_cmd.release_command(title=None, dry_run=True)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
@@ -554,7 +647,7 @@ def test_release_refuses_when_version_is_already_a_release(tree):
def test_release_refuses_when_version_and_changelog_disagree(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
breaking=None, no_migration=None, migration_required=False, dry_run=False,
)
(tree / "VERSION").write_text("9.9.9-beta.1\n", encoding="utf-8")
with pytest.raises(typer.Exit):
@@ -572,11 +665,11 @@ def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
breaking=None, no_migration=None, migration_required=False, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, dry_run=False,
breaking=None, no_migration=None, migration_required=False, dry_run=False,
)
version_cmd.notes_command(version=None)
out = capsys.readouterr().out
+1 -1
View File
@@ -181,7 +181,7 @@ def test_xref_remove_clears_a_ref_to_a_page_that_no_longer_exists(kb_dir):
kb_dir / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25",
"related": ["Ghost Page"], "sources": [], "confidence": 0.8},
"related": ["Ghost Page"], "sources": []},
"\n# gdeploy\n\n## See Also\n\n- [[Ghost Page]]\n",
)
+26 -1
View File
@@ -493,6 +493,18 @@ def _set_marker_line(section: str, marker: str, line: str) -> str:
return section[:insert_at] + f"\n{line}\n" + section[insert_at:]
def _clear_marker_line(section: str, marker: str) -> str:
"""Remove the one-line `marker ...` paragraph from `section`, if present.
The retraction counterpart to `_set_marker_line`. A candidate that
recorded `--no-migration` and later turns out to need one after all has no
other way to take that statement back - the line is machine-managed, and
invariant 1 forbids hand-editing it.
"""
pattern = re.compile(rf"^{re.escape(marker)}.*\n?", re.MULTILINE)
return pattern.sub("", section, count=1)
def _entry_span(text: str) -> tuple[int, int]:
"""Start/end offsets of the topmost entry, heading included."""
match = re.search(r"^## ", text, re.MULTILINE)
@@ -511,10 +523,15 @@ def _update_open_candidate(
title: str,
breaking_reason: Optional[str],
no_migration_reason: Optional[str],
migration_required: bool = False,
) -> str:
"""Move the topmost entry's heading to `version`/`date`/`title`, append
`title` to its machine-managed bump list, and set the breaking/no-migration
lines only where this call supplies them - see `insert_changes_entry`."""
lines only where this call supplies them - see `insert_changes_entry`.
`migration_required` retracts an earlier `--no-migration` line instead of
setting one - the two are mutually exclusive on a single bump, enforced by
the caller (`version_cmd.bump_command`), not here."""
start, end = _entry_span(text)
section = text[start:end]
@@ -529,6 +546,8 @@ def _update_open_candidate(
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
if no_migration_reason:
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
elif migration_required:
section = _clear_marker_line(section, MIGRATION_NONE_MARKER)
return text[:start] + section + text[end:]
@@ -541,6 +560,7 @@ def insert_changes_entry(
author: str,
no_migration_reason: Optional[str] = None,
breaking_reason: Optional[str] = None,
migration_required: bool = False,
) -> str:
"""Open a new entry above the newest existing one, or - when the topmost
entry is still an open candidate (a pre-release heading) - update that
@@ -560,12 +580,17 @@ def insert_changes_entry(
release notes has to act on, and the migration line only qualifies it. The
entry's actual prose is written afterwards by whoever made the change,
which is also why `bump` refuses to invent a title.
`migration_required` only has anything to retract on an already-open
candidate, so a fresh entry ignores it - there is no earlier
`--no-migration` line in a skeleton that was just opened.
"""
top = top_changes_version(text)
if top is not None and top.is_prerelease:
return _update_open_candidate(
text, version, date, title,
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
migration_required=migration_required,
)
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]