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:
+7
-19
@@ -2,7 +2,7 @@
|
||||
|
||||
`wikitool` is a deterministic CLI for Chemenu, used by agents (and humans) so
|
||||
mechanical wiki operations - frontmatter, index statistics, cross-references, log
|
||||
formatting, confidence decay, git publishing - never have to be re-derived by
|
||||
formatting, git publishing - never have to be re-derived by
|
||||
an LLM. The root [`AGENTS.md`](../AGENTS.md) holds the invariants that say when
|
||||
using these commands is mandatory; this file is the full reference. Changes to
|
||||
`wikitool` itself are tracked in the repo root [`CHANGES.md`](../CHANGES.md),
|
||||
@@ -46,11 +46,11 @@ tools/wikitool <command> --help
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `new <type-name> --name "<Name>" [--type <path>] [--set field=value ...]` | Scaffold a page of any type. The type-spec drives fields, defaults, directory (`base_dir`/`layout`), title prefix, and template - `--set` is repeatable, and comma-separated values fill array fields. An element that itself contains a comma is written `\,`, or passed as its own repeated `--set` for that field - repeating an array field appends. See `types list`/`types describe`. |
|
||||
| `new entity --name "<Name>" --set entity_type=<t> [--set tags=a,b] [--set related=X,Y] [--set sources="Source - Z"] [--set confidence=0.7] [--set provenance=sourced\|general\|mixed]` | Scaffold `kb/entities/<subdir>/<Name>.md` |
|
||||
| `new entity --name "<Name>" --set entity_type=<t> [--set tags=a,b] [--set related=X,Y] [--set sources="Source - Z"] [--set provenance=sourced\|general\|mixed]` | Scaffold `kb/entities/<subdir>/<Name>.md` |
|
||||
| `new concept --name "<Name>" --set concept_type=<t> ...` | Scaffold `kb/concepts/<Name>.md` |
|
||||
| `new source --name "<Name>" --set raw_files=raw/notes/x.md,raw/notes/y.md [--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]` | Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) |
|
||||
| `new comparison --name "X vs Y" --set entities=X,Y` | Scaffold `kb/comparisons/X vs Y.md` |
|
||||
| `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--confidence-base <n>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` | Update a page's own frontmatter: bump `modified:` and optionally rewrite any field its type declares. `--summary`/`--provenance`/`--confidence-base` are shorthands; `--set` reaches every other field and **replaces** its value, while `--add`/`--remove` change single elements of an array field (removing an absent element succeeds and says so). Repeating `--set` for one array field appends *within the call*, and `\,` is a literal comma - same rules as `new --set`. Refused with the command that owns them instead: `type:` (page-lifecycle), `confidence:` (derived - set `--confidence-base`), and the page-ref arrays `related:`/`sources:`/`entities:`/`concepts:` (`xref`). Everything else the schema declares is settable, and an unknown field lists what the page actually has. Schema-validates the fields it writes, and `raw_files:` entries must exist on disk. A source declares `date:` instead of `modified:`, and that is the *publication* date of the raw material - it is never bumped to today, and changes only when `--date` names a value explicitly. |
|
||||
| `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` | Update a page's own frontmatter: bump `modified:` and optionally rewrite any field its type declares. `--summary`/`--provenance` are shorthands; `--set` reaches every other field and **replaces** its value, while `--add`/`--remove` change single elements of an array field (removing an absent element succeeds and says so). Repeating `--set` for one array field appends *within the call*, and `\,` is a literal comma - same rules as `new --set`. Refused with the command that owns them instead: `type:` (page-lifecycle), and the page-ref arrays `related:`/`sources:`/`entities:`/`concepts:` (`xref`). Everything else the schema declares is settable, and an unknown field lists what the page actually has. Schema-validates the fields it writes, and `raw_files:` entries must exist on disk. A source declares `date:` instead of `modified:`, and that is the *publication* date of the raw material - it is never bumped to today, and changes only when `--date` names a value explicitly. |
|
||||
| `rename --from "<Old>" --to "<New>" [--dry-run]` | Rename a page and repoint every reference to it: body `[[wikilinks]]` (aliases and anchors preserved), a `[^cite-id]` whose id was derived from the old title (refreshed to match the new one, both in its Footnotes definition and every reference to it), the page's own H1, and every page-ref frontmatter array declared by the type's `page_ref_fields:`. If `--from` is *not* a page but is referenced, it instead repoints those references onto the existing `--to` page and moves nothing - the fix for a reference spelled `act_runner` when the page is `Act Runner` |
|
||||
| `rm --page "<Title>" [--yes] [--dry-run]` | Delete a page and mechanically de-link it. Refuses without `--yes` while other pages still reference it. Strips ref-array entries and bare `- [[Title]]` / `- **label:** [[Title]]` bullets; leaves prose and inline citations in place and reports them |
|
||||
| `move --page "<Title>"` \| `move --reconcile` `[--dry-run]` | Move a page to the directory its type-spec computes for its current frontmatter (`base_dir` + `layout` - the same rule `new` places a page by, via `TypeResolver.compute_target_dir`), never a hand-chosen destination - there is no `--to <dir>`. `--reconcile` applies it corpus-wide: every misplaced page moves in one call, and a second run reports nothing left to do (`lint`'s `Misplaced Pages` finding is the advisory that this fixes, and its `Nested Pages` finding the hard one - see `lint`). Neither mode touches a body or a frontmatter field, and the page's title (its only identity in the wiki) never changes - only the file moves. A directory a move empties is removed along with it, so a page that was nested below its area leaves no leftover directory behind. A destination already occupied (a pre-existing duplicate-stem collision) is refused rather than silently skipped |
|
||||
@@ -65,9 +65,7 @@ tools/wikitool <command> --help
|
||||
| `log append --op ingest\|query\|lint\|create\|update\|delete\|rename\|move --title "..." [--body "..."\|--body-file path]` | Append a formatted entry to `kb/log.md` |
|
||||
| `log status` | Read-only: count `ingest` entries logged since the last `lint` entry - the deterministic trigger behind the Maintenance Schedule's "every 10 sources" full-lint cadence |
|
||||
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested more than one directory below their collection (hard - the generated catalog folds these into their area silently rather than merely reading it), uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), `see-also` edges whose reverse direction already carries a specific label (advisory only - redundant rather than wrong, and never migration-gated, since no version turns the redundancy into an error), a collection past the catalog's per-area shard threshold that has no areas to shard (advisory only - sharding is automatic but per *area*, so a collection nobody gave areas keeps one table however large it grows; reported with the split its subtype field would produce, and only when that split puts every resulting area at or under the threshold, so a lopsided or small collection stays silent), source pages sitting in the `unclassified` catalog slot (advisory only - `unclassified` is the visible fallback for a genuinely unclear source, not a defect), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
||||
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. Results carry kind/summary/confidence so a hit can be judged without opening the page. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
|
||||
| `confidence decay [--apply]` | Recompute every page's derived `confidence` as `confidence_base * (1 - 0.01/month)`, floored at 0.2; dry-run by default |
|
||||
| `confidence init-base [--apply]` | One-time backfill: set `confidence_base` from the current `confidence` on pages that predate the derived-confidence model |
|
||||
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. Results carry kind/summary so a hit can be judged without opening the page. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
|
||||
| `sources coverage [--json]` | List raw files with no source page, broken `raw_files:` references, and legacy directory/URL-only source pages |
|
||||
| `sources trace --raw <path>` \| `--page "<Title>"` | Trace provenance in either direction: raw file -> source page(s) -> citing pages, or page -> its sources -> their raw files |
|
||||
| `sources rebuild-index [--dry-run]` | Regenerate the `kb/provenance.md` reverse index (raw file -> source page -> citing pages) |
|
||||
@@ -96,7 +94,7 @@ never ran this step), `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/
|
||||
| `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** |
|
||||
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
|
||||
| `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate |
|
||||
| `version bump --major\|--minor\|--patch --title "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. Refuses more or fewer than one part, an empty title, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both lines are written once and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
||||
| `version bump --major\|--minor\|--patch --title "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. Refuses more or fewer than one part, an empty title, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both lines are written once and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
||||
| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending the pre-release phase `version bump` started. Without `--title` the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a release (no running candidate to fix), or when the changelog's newest entry does not match `VERSION` |
|
||||
| `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind and obligation. Read-only and **exempt from the Iteration Budget Gate** |
|
||||
| `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: every **required** document whose `migrates_to` lies in `(kb_version, VERSION]`. `offered` documents are listed separately above the chain and never block, never count as owed, and are bounded by the applied ledger rather than by `kb_version` - taking one deliberately does not move the version, so the version cannot say whether it was taken. When a release stamp is present, also reports which shipped files this instance has since edited (from the per-file sha256 in `.wikitool-release.json`), which is what says whether an offer may be copied over or has to be reconciled by hand; without a stamp that question is reported as unanswerable rather than answered. Exits 1 only when `.wikitool-kb.json` is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate |
|
||||
@@ -143,14 +141,6 @@ never ran this step), `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/
|
||||
- `lint` only reports what's mechanically verifiable. Contradictions, staleness
|
||||
judgment, and "what's worth writing next" remain the LLM's job; `lint`
|
||||
produces a markdown skeleton with a "Semantic Review" section for that.
|
||||
- `confidence decay` applies a linear 1%/month reduction to `confidence_base`
|
||||
since `modified`/`date`/`created`, floored at 0.2, and writes the result to
|
||||
the derived `confidence` field. Keeping the undecayed anchor separate 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 no `confidence_base` are skipped rather than guessed at - run
|
||||
`confidence init-base --apply` once to backfill them.
|
||||
- **Iteration Budget Gate / Loop-Breaker** (see the root `AGENTS.md` "Gates"
|
||||
section): every invocation is recorded and checked in `main()` (`cli.py`)
|
||||
before Typer dispatches to any subcommand, so it applies uniformly without
|
||||
@@ -190,7 +180,7 @@ is atomic, and whether a retry is safe.
|
||||
| Command | Exit 1 means | Atomic? | Retry policy |
|
||||
|---------|--------------|---------|--------------|
|
||||
| `new <type>` | Duplicate page title, unknown type, invalid `--set` value, or a `raw_files` path that doesn't exist | Yes - single file write | Not transient; fix the argument and retry once. Never hand-craft the page instead |
|
||||
| `touch` | Page not found; an invalid value for a field it writes; a field owned by another command (`type:`, `confidence:`, a page-ref array) or absent from the type's schema; `--add`/`--remove` on a non-array field; a `raw_files:` path that doesn't exist | Yes - single file write, and every refusal happens before it | Fix the argument and retry once. Safe to re-run as-is: `--set` and `--add` are idempotent, and `--remove` of an already-absent element succeeds while reporting it |
|
||||
| `touch` | Page not found; an invalid value for a field it writes; a field owned by another command (`type:`, a page-ref array) or absent from the type's schema; `--add`/`--remove` on a non-array field; a `raw_files:` path that doesn't exist | Yes - single file write, and every refusal happens before it | Fix the argument and retry once. Safe to re-run as-is: `--set` and `--add` are idempotent, and `--remove` of an already-absent element succeeds while reporting it |
|
||||
| `rename` | Neither `--from` nor `--to` is a page, target title already taken, or `--from` equals `--to` | No - one write per referencing page, then the file move | Safe to retry once as-is; each page's rewrite is idempotent. Use `--dry-run` first to see the blast radius. Never fix up references by hand instead |
|
||||
| `rm` | Page not found, **or** other pages still reference it and `--yes` was not passed | No - one write per referencing page, then the delete | For "still referenced": show the user the inbound list, get approval, then re-run with `--yes`. Prose references it reports afterwards are an editorial fix, not a retry |
|
||||
| `move` | Neither or both of `--page`/`--reconcile` given, the named page not found, it has no `type:` to compute a placement from, or the destination already exists | `--page`: yes, a single file move. `--reconcile`: no - one file move per page, each idempotent | Safe to retry once as-is; a page already at its computed location is reported and left alone, and `--reconcile` only re-moves what is still misplaced. Use `--dry-run` first to see the blast radius. Never choose a directory by hand instead |
|
||||
@@ -204,7 +194,6 @@ is atomic, and whether a retry is safe.
|
||||
| `log status` | Never fails (reports 0 if `kb/log.md` is missing or empty) | Read-only | Safe to retry freely |
|
||||
| `lint` | Only with `--fail-on-error`: hard findings exist | Writes one report file (single atomic write) unless `--json` | Safe to retry freely, but re-run it to re-*measure*, never to re-read: the printed path holds the full report. Exit 1 means "act on the findings", not "the tool is broken" |
|
||||
| `search` | `rg` is not installed or did not finish within 30 s, a malformed `--field` predicate, an unknown field name, or an unknown `--backend` | Read-only | Fix the argument and retry. A timeout is a pathological pattern or an unresponsive corpus directory, not a slow answer - narrow the query or drop `--regex` rather than retrying it unchanged. An unknown field name is reported with the list of fields that do exist - it is never answered with an empty result, because that would read as "no such pages" |
|
||||
| `confidence decay --apply` / `init-base --apply` | Rare I/O error mid-loop | No - one write per page | Safe to retry freely; both recompute from `confidence_base` and never compound |
|
||||
| `sync` | The automatic rebase hit a real conflict (git failed) | No - fetch, then at most one merge/rebase attempt, aborted cleanly on failure | For a conflict: **do not retry, do not force** - resolve manually and re-run. **Exit 42, not 1**, when the rebase-review gate needs clearance: show the user the command's full output verbatim (upstream commits, the overlapping files, their diff) and stop; re-running with `--confirm-rebase <token>` clears it, and a wrong, invented, or superseded token exits 42 again with the current state. No remote configured, or one that cannot be reached, is not a failure - reported and skipped |
|
||||
| `publish` | git failed, **or** `--yes`/`-y` was passed. **Exit 42, not 1**, when the Mass-Update Gate, the rebase-review gate (raised by the same reconcile `sync` performs), or the Publish-Remote Gate refuses | No - sequential git operations, but both gates run before staging | For git failures: **do not retry, do not force** - report and ask the user (the reconcile step already retried the push once on its own, if a rebase resolved the rejection). For exit 42: show the user the command's full output verbatim and stop; it names the evidence and the `--confirm <token>` or `--confirm-rebase <token>` line to re-run, and re-running without it exits 42 again. The Publish-Remote Gate is the exception with no such line: it names the push URL that would have been written to and the ones this checkout allows, and only the user resolves it |
|
||||
| `work new` | Neither or both of `--input`/`--key` given, `--input` outside `raw/`, a `--key` that is empty or starts with `ingest-`, or the workshop already exists | Yes - one directory with two files | A collision is not transient: resume the existing run instead, or pass `--again` if the tree itself changed. Never create a numbered variant by hand |
|
||||
@@ -220,7 +209,7 @@ is atomic, and whether a retry is safe.
|
||||
| `dist upgrade` | Missing local `VERSION`/`.wikitool-release.json`(`files`)/`.wikitool-kb.json`, a migration already outstanding against the installed machinery, a dirty working tree, a source with no `VERSION`/stamp/`files` block, a source version that is older than, equal to, or (without `--pre`) a pre-release relative to the installed one, or one or more locally changed files without `--keep-local` | **Yes for the refusal cases above - nothing is written.** Once writing starts it is a plain sequential file copy with no partial-state cleanup: an interruption mid-copy (killed process, disk full) can leave the tree part-old, part-new | For every refusal above: fix the named precondition and retry - none of them are transient. For locally changed files: reconcile them by hand and retry, or re-run with `--keep-local` to proceed and leave them untouched (repeatable - it reports the same files again on every subsequent run until they stop diverging). An interrupted write is not resumed automatically; compare the tree against the printed classification and finish or revert by hand |
|
||||
| `version show` / `version notes` | `VERSION` is missing or unparseable; for `notes`, no `CHANGES.md` entry names the version asked for | Read-only | Fix `VERSION`, or write the changelog entry (`version bump` writes its heading). Safe to retry |
|
||||
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
|
||||
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, an escalation to a boundary crossing without `--breaking` or with neither a migration document nor `--no-migration`, or `--breaking`/`--no-migration` on a bump that crosses nothing | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
||||
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, an escalation to a boundary crossing without `--breaking` or with neither a migration document nor `--no-migration`, `--breaking`/`--no-migration` on a bump that crosses nothing, or `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
||||
| `version release` | A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running candidate), or `VERSION` and the changelog's newest entry naming different versions | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run fails outright once the suffix is gone. If the outcome is uncertain, read `VERSION` before retrying - a release-shaped `VERSION` means it already ran |
|
||||
| `links show` | Page not found | Read-only | Check the exact title with `search`; a wikilink target is not always the page's stem |
|
||||
| `migrate list` / `migrate status` | `list` never fails; `status` exits 1 when `.wikitool-kb.json` is missing or unreadable, or `VERSION` is | Read-only | For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise |
|
||||
@@ -247,7 +236,6 @@ Run by the LLM through the skills, on this cadence:
|
||||
| Publish | After any change worth persisting | `publish --message "<op>: <desc>"` |
|
||||
| Full lint | Every 10 sources (checked via `log status`), or on request | `lint`, then carry the semantic findings into `kb/log.md` via `log append --op lint` - the report itself is gitignored |
|
||||
| Raw coverage check | Every 10 sources | `sources coverage` |
|
||||
| Confidence decay | Every 30 days | `confidence decay --apply` |
|
||||
| Docs/instruction verification | After changing the CLI, a contract, or an instruction | `docs verify`, `instructions verify` |
|
||||
| Budget check | Any time a session feels long | `budget status` |
|
||||
| Retention review | Every 90 days | Manual |
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ belongs there.
|
||||
|
||||
**Deterministic by default.** Anything an LLM would otherwise re-derive -
|
||||
frontmatter, index statistics, cross-reference bookkeeping, log formatting,
|
||||
confidence arithmetic - is computed here so it comes out the same every time.
|
||||
version arithmetic - is computed here so it comes out the same every time.
|
||||
|
||||
**Gates are code, not prompts.** The Mass-Update Gate (`git_publish.py`) and the
|
||||
Iteration Budget Gate (`run_budget.py`) refuse in-process, because a
|
||||
|
||||
@@ -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