Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/konjo-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,19 @@ jobs:
python3 .konjo/scripts/indexing_floor_check.py \
--ceiling-file .konjo/indexing-floor.txt

- name: "Error-taxonomy gate (anyhow:: usage, per-crate ratchet against locked floors)"
# HARD GATE -- Track C. Sprint S13R migrated lopi-core and part of lopi-git off
# anyhow:: onto typed (thiserror) errors but shipped no gate holding that in
# place -- a single workspace-wide ratchet (the shape indexing/function-length
# use) can't tell "regression in an already-migrated crate" apart from "no
# change in a still-unmigrated one", so this ratchet is per-crate:
# `.konjo/error-taxonomy.txt` locks one anyhow::-file-count floor per crate.
# Never regresses any single crate's row above its floor; ratchet a row down
# as that crate migrates off anyhow::.
run: |
python3 .konjo/scripts/error_taxonomy_check.py \
--floor-file .konjo/error-taxonomy.txt

- name: Documentation gate (rustdoc)
# KNOWN DEBT, verified 2026-07-29 (Sprint S13R, Phase B — decision item 2):
# re-measured rather than assumed. The debt grew past what backend-1 Phase 0
Expand Down
48 changes: 48 additions & 0 deletions .konjo/error-taxonomy.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Konjo Forward Pillar 2 ratchet (never regress) -- per-crate, not workspace-total.
# Seeded Track C, from error_taxonomy_check.py's real measured count against this
# branch: number of non-test `.rs` files per crate under `crates/` containing at
# least one non-comment-line `anyhow::` occurrence (files, not occurrences -- see
# that script's own docstring for the exact method).
#
# Sprint S13R's brief carried forward informal counts of 2 (lopi-core), 3
# (lopi-git), 30 (lopi-memory) -- lopi-git and lopi-memory reproduce exactly under
# this script's method; lopi-core measures 1, not 2: `sqlite_pool.rs`'s only
# `anyhow::` text is inside a doc comment (`/// ... anyhow::Error ...`), which this
# script's comment-line stripping correctly excludes as non-code, and `models.rs`'s
# only occurrence is inside a `#[cfg(test)] mod tests { ... }` block -- a real usage
# by this script's file-path-only test exclusion (it does not parse `#[cfg(test)]`
# bodies, the same limitation `indexing_floor_check.py` and
# `function_length_check.py` already carry), so the file still counts. Per the
# brief's own instruction ("if a number here disagrees with what you measure, the
# measurement wins"), 1 is what's recorded below, not 2.
#
# Every crate under `crates/` gets a row, including ones already at 0 -- a crate
# that already hit zero can regress just as easily as one still migrating, and a
# single workspace-wide total (the shape the indexing/function-length ratchets use)
# can't tell "regression in a clean crate" apart from "no change in a dirty one".
# Lower a row's count as that crate migrates off `anyhow::`; a PR must never raise
# any row without a recorded reason.
#
# `lopi-git` lowered 3 -> 0 in this same PR: `manager.rs`, `rebase.rs`, and
# `worktree.rs` (the three files this track's brief named) converted to
# `GitManagerError`/`WorktreeError`, matching `diff.rs`'s already-migrated
# `DiffScopeError` pattern -- see `crates/lopi-git/src/manager.rs` and
# `worktree.rs` for the enums, and `LEDGER.md`'s "Track C" entry for why.
lopi-agent: 19
lopi-context: 0
lopi-core: 1
lopi-demo: 2
lopi-git: 0
lopi-github: 1
lopi-index: 12
lopi-mcp: 4
lopi-memory: 30
lopi-orchestrator: 9
lopi-ratelimit: 0
lopi-remote: 1
lopi-skill: 0
lopi-spec: 4
lopi-tools: 1
lopi-toon: 0
lopi-ui: 8
lopi-webhook: 3
14 changes: 9 additions & 5 deletions .konjo/profile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
# below still matches this repo's real state. See `LEDGER.md`'s `Lopi-Gate-Reconciliation-1`
# (kiban side) for the full per-check promote/keep/delete table.
#
# Two local additions past the kiban-authored version, made directly in this copy (this
# Three local additions past the kiban-authored version, made directly in this copy (this
# session has no push access to kiban to land them upstream first): the
# `function-length` entry (Phase B, alongside `.konjo/scripts/function_length_check.py` +
# `.konjo/function-length-ceiling.txt`) and the `indexing-floor` entry (Phase D,
# alongside `.konjo/scripts/indexing_floor_check.py` + `.konjo/indexing-floor.txt`) in
# `contract_gates` and `gates:` below. Everything else in this file is the unedited
# kiban original.
# `.konjo/function-length-ceiling.txt`), the `indexing-floor` entry (Phase D,
# alongside `.konjo/scripts/indexing_floor_check.py` + `.konjo/indexing-floor.txt`), and
# the `error-taxonomy` entry (Track C, alongside `.konjo/scripts/error_taxonomy_check.py`
# + `.konjo/error-taxonomy.txt`) in `contract_gates` and `gates:` below. Everything else
# in this file is the unedited kiban original.
#
# Original kiban header follows, unedited:
#
Expand Down Expand Up @@ -82,6 +83,7 @@ contract_gates:
- rustdoc # G4: RUSTDOCFLAGS=-D missing_docs, kept repo-native
- function-length # G4: .konjo/scripts/function_length_check.py + ceiling file, added Sprint S13R Phase B, kept repo-native
- indexing-floor # G4: .konjo/scripts/indexing_floor_check.py + ceiling file, added Sprint S13R Phase D, kept repo-native
- error-taxonomy # G4: .konjo/scripts/error_taxonomy_check.py + per-crate floor file, added Track C, kept repo-native
- unsafe-budget # kiban-native, diff-only (already generic; genuinely active here)

# Confirmed from konjo-gate.yml G3: `cargo mutants --in-diff <(git diff)`, survival <= 10%
Expand Down Expand Up @@ -140,6 +142,8 @@ gates:
rejects_test: ".konjo/scripts/test_function_length_killtest.sh"
- name: indexing-floor
rejects_test: ".konjo/scripts/test_indexing_floor_killtest.sh"
- name: error-taxonomy
rejects_test: ".konjo/scripts/test_error_taxonomy_killtest.sh"

# Phase 13, Phase 3: lifted verbatim from the `paths:` front-matter already prototyped in
# lopi's `.claude/rules/security.md` -- one declaration now serves both the CLAUDE.md rules
Expand Down
181 changes: 181 additions & 0 deletions .konjo/scripts/error_taxonomy_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""error_taxonomy_check: per-crate ratchet on `anyhow::` usage in production Rust.

Sprint S13R migrated `lopi-core` and part of `lopi-git` off `anyhow::` onto
typed (`thiserror`) errors, per `.claude/rules/rust-conventions.md` ("Error
types: `thiserror` for library crates, `anyhow` for binary/glue code"), and
left `lopi-memory` untouched. Nothing held that migration in place -- a single
workspace-wide ratchet (the shape `indexing_floor_check.py` and
`function_length_check.py` both use) cannot do it either: a regression in an
already-migrated crate and zero progress in an untouched crate both just move
one total up or down, so the same total could hide a real regression behind
unrelated migration progress elsewhere. This ratchet is **per-crate** instead --
one floor per crate, compared independently, so a crate that already hit 0
can never silently regress even while other crates stay wherever they are.

Method (same filtering convention as `indexing_floor_check.py` and
`function_length_check.py`, so the three ratchets agree on what "non-test
Rust" means): for every crate directory directly under `crates/`, count the
*files* (not occurrences) under that crate's `src/` containing at least one
non-comment line with the literal substring `anyhow::`, excluding:
- any path containing a `/tests/` or `/benches/` directory segment
- any file named `tests.rs`, or ending in `_tests.rs`, `_test.rs`, or `_bench.rs`
- lines whose first non-whitespace characters are `//` (a comment-only line)
A file counts once no matter how many `anyhow::` occurrences it has -- the
signal this ratchet cares about is "how many files still need migrating",
not "how many call sites". This does not look inside `#[cfg(test)] mod
tests { ... }` blocks embedded in an otherwise-production file, the same
simplification `indexing_floor_check.py`'s own docstring names -- a file
whose only match is inside such a block still counts. Good enough for a
ratchet floor; not a claim of byte-exact precision.

Floor file format (`.konjo/error-taxonomy.txt`): one `crate-name: N` row per
crate, comment lines starting with `#`, blank lines ignored.

Usage: python3 .konjo/scripts/error_taxonomy_check.py [--floor-file PATH]
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
_CRATES_DIR = "crates"
_ANYHOW_RE = re.compile(r"anyhow::")
_TEST_DIR_MARKERS = ("/tests/", "/benches/")
_TEST_FILE_NAMES = {"tests.rs"}
_TEST_FILE_SUFFIXES = ("_tests.rs", "_test.rs", "_bench.rs")
_ROW_RE = re.compile(r"^([A-Za-z0-9_-]+)\s*:\s*(\d+)\s*$")


def _is_test_path(rel: str) -> bool:
if any(marker in f"/{rel}" for marker in _TEST_DIR_MARKERS):
return True
name = Path(rel).name
return name in _TEST_FILE_NAMES or name.endswith(_TEST_FILE_SUFFIXES)


def _file_has_real_anyhow_usage(path: Path) -> bool:
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return False
for line in text.splitlines():
if line.strip().startswith("//"):
continue
if _ANYHOW_RE.search(line):
return True
return False


def count_anyhow_files_per_crate() -> dict[str, int]:
"""Map crate directory name -> count of non-test files using `anyhow::`.

Every crate directory directly under `crates/` is included, even at 0,
so the floor file has somewhere to record "still clean" as well as
"still migrating" -- a crate absent from this dict entirely means
`crates/<name>` doesn't exist (or has no `.rs` files at all).
"""
counts: dict[str, int] = {}
base = REPO_ROOT / _CRATES_DIR
if not base.exists():
return counts
for crate_dir in sorted(p for p in base.iterdir() if p.is_dir()):
crate = crate_dir.name
total = 0
for path in sorted(crate_dir.rglob("*.rs")):
rel = str(path.relative_to(REPO_ROOT))
if "target" in Path(rel).parts or _is_test_path(rel):
continue
if _file_has_real_anyhow_usage(path):
total += 1
counts[crate] = total
return counts


def _read_floor(floor_path: Path) -> dict[str, int]:
floor: dict[str, int] = {}
for raw_line in floor_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
m = _ROW_RE.match(line)
if not m:
raise ValueError(f"malformed row (want `crate-name: N`): {raw_line!r}")
floor[m.group(1)] = int(m.group(2))
return floor


def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--floor-file", type=Path, default=REPO_ROOT / ".konjo" / "error-taxonomy.txt"
)
args = parser.parse_args(argv)

try:
floor = _read_floor(args.floor_file)
except (OSError, ValueError) as exc:
print(f"::error::Cannot read error-taxonomy floor from {args.floor_file}: {exc}")
return 2

counts = count_anyhow_files_per_crate()

print("error_taxonomy: non-test `anyhow::` files per crate")
regressions: list[str] = []
improved: list[str] = []
unrecorded: list[str] = []
for crate in sorted(counts):
current = counts[crate]
if crate not in floor:
if current > 0:
unrecorded.append(crate)
print(f" {crate}: {current} (no locked floor -- add a row to {args.floor_file.name})")
continue
locked = floor[crate]
marker = ""
if current > locked:
marker = " <-- REGRESSION"
regressions.append(
f"{crate}: {current} `anyhow::` file(s), up from the locked floor of {locked}"
)
elif current < locked:
marker = " (dropped below floor)"
improved.append(f"{crate}: {locked} -> {current}")
print(f" {crate}: {current} (floor {locked}){marker}")

if unrecorded:
print(
"::error::Crate(s) with `anyhow::` usage but no floor row: "
+ ", ".join(unrecorded)
+ f". Add each to {args.floor_file} before this can be ratcheted."
)
return 1

if regressions:
print("::error::error-taxonomy regression(s):")
for msg in regressions:
print(f" {msg}")
print(
"Migrate the new/grown file(s) off `anyhow::` onto typed errors "
"(see crates/lopi-core/src/config.rs or crates/lopi-git/src/diff.rs "
"for the established `thiserror` pattern), or if this is a genuine "
"ratchet-down PR, say why in the commit message and lower the floor "
"-- never raise it silently."
)
return 1

if improved:
print("Count(s) dropped below the locked floor. Consider ratcheting down:")
for msg in improved:
print(f" {msg}")

print("error-taxonomy gate: OK")
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
104 changes: 104 additions & 0 deletions .konjo/scripts/test_error_taxonomy_killtest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Kill-test for error_taxonomy_check.py -- Track C, KT-C.1.
#
# Proves, using real fixture files planted under crates/lopi-core/src/ and
# crates/lopi-memory/src/ and cleaned up on exit (same technique as
# test_indexing_floor_killtest.sh and test_function_length_killtest.sh, since
# this script's REPO_ROOT is fixed to the real checkout):
#
# 1. REGRESSION: pushing an already-migrated crate's (lopi-core) count one
# file above its locked floor fails the gate, naming the crate.
# 2. NO-REGRESSION: a still-unmigrated crate (lopi-memory) that gains zero
# new `anyhow::` files -- i.e. stays exactly at its existing (nonzero)
# floor, the normal state of an untouched crate on every unrelated PR --
# passes the gate.
# 3. Both fixtures run in the same invocation, proving the checker tells
# "regression in a migrated crate" apart from "steady-state in an
# unmigrated crate" rather than collapsing both into one shared signal
# (the failure mode a single workspace-wide total would have).
# 4. Comment-only `anyhow::` mentions and `#[cfg(test)]`-suffixed test
# files/dirs are excluded from the count (same convention as the
# indexing/function-length ratchets).
#
# Usage: bash .konjo/scripts/test_error_taxonomy_killtest.sh

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CHECK="$REPO_ROOT/.konjo/scripts/error_taxonomy_check.py"
CORE_FIXTURE="$REPO_ROOT/crates/lopi-core/src/_error_taxonomy_killtest_fixture.rs"
TMP="$(mktemp -d)"
cleanup() { rm -rf "$TMP" "$CORE_FIXTURE"; }
trap cleanup EXIT

PASS=0
FAIL=0

report() {
local desc="$1" expected="$2" actual="$3" log="$4"
if [ "$actual" -eq "$expected" ]; then
echo "PASS: $desc (exit $actual)"
PASS=$((PASS + 1))
else
echo "FAIL: $desc (expected exit $expected, got $actual)"
cat "$log"
FAIL=$((FAIL + 1))
fi
}

echo "── Error-taxonomy kill-test (KT-C.1) ──"

# ── Case (b) first: NO fixture planted at all -- the real repo state, where
# lopi-memory (unmigrated, floor 30) has not changed and every other crate is
# exactly at its locked floor. This must ACCEPT.
set +e
python3 "$CHECK" >"$TMP/clean.log" 2>&1
CLEAN_EXIT=$?
set -e
report "(b) unmigrated crate (lopi-memory) unchanged at its existing floor: ACCEPT" 0 "$CLEAN_EXIT" "$TMP/clean.log"
if grep -q "lopi-memory: 30 (floor 30)$" "$TMP/clean.log"; then
echo "PASS: lopi-memory shown at its unchanged floor, not flagged"
PASS=$((PASS + 1))
else
echo "FAIL: expected lopi-memory: 30 (floor 30) with no regression marker"
cat "$TMP/clean.log"
FAIL=$((FAIL + 1))
fi

# ── Case (a): plant one new non-test file under lopi-core (already migrated,
# floor 1) that uses `anyhow::` in real code. This must REJECT, even though
# lopi-core is not the only, or even the least-migrated, crate in the repo.
cat > "$CORE_FIXTURE" <<'EOF'
// Kill-test fixture: simulates a regression in an already-migrated crate.
pub fn _killtest_regression() -> anyhow::Result<()> {
Ok(())
}
EOF

set +e
python3 "$CHECK" >"$TMP/regressed.log" 2>&1
REGRESSED_EXIT=$?
set -e
report "(a) regression in already-migrated crate (lopi-core): REJECT" 1 "$REGRESSED_EXIT" "$TMP/regressed.log"

if grep -q "lopi-core: 2 (floor 1) <-- REGRESSION" "$TMP/regressed.log"; then
echo "PASS: lopi-core named as the regressing crate with its real counts"
PASS=$((PASS + 1))
else
echo "FAIL: expected lopi-core named as a regression at 2 (floor 1)"
cat "$TMP/regressed.log"
FAIL=$((FAIL + 1))
fi

if grep -q "lopi-memory: 30 (floor 30)$" "$TMP/regressed.log"; then
echo "PASS: lopi-memory still shown as unregressed in the same run"
PASS=$((PASS + 1))
else
echo "FAIL: expected lopi-memory to remain unregressed alongside lopi-core's failure"
cat "$TMP/regressed.log"
FAIL=$((FAIL + 1))
fi

echo
echo "── Results: $PASS passed, $FAIL failed ──"
[ "$FAIL" -eq 0 ]
Loading
Loading