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:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.")
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,7 +41,6 @@ STRUCTURAL_FIELDS = (
|
||||
"type",
|
||||
"created",
|
||||
"date",
|
||||
"confidence_base",
|
||||
"provenance",
|
||||
"source_type",
|
||||
"source_language",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("-")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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) -------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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}", ""]
|
||||
|
||||
Reference in New Issue
Block a user