From 54d8679ae5e95c3c8efdd76ad60aef1596de7ded Mon Sep 17 00:00:00 2001 From: "Carlos D. Escobar-Valbuena" Date: Tue, 26 May 2026 20:50:21 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(0.21.8):=20bstack=20skills=20audit=20?= =?UTF-8?q?=E2=80=94=20skill=20registry=20audit=20(Phase=206c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `bstack skills audit` subcommand. Crystallizes the "Skill Registry Audit" pattern (3/3 in the bstack-engine ledger: Steipete skill-cleaner + 2026-05-25 manual inventory + P7 Freshness degenerate case). Adapts Steipete's algorithm for Claude Code + bstack. ## 5 reports budget (ceil(utf8/4) token cost vs ceiling) · duplicates (realpath-deduped) · registry coherence (companion-skills.yaml vs installed) · unused (Claude Code session-log trace, --months window) · roots (per-root count). --json + env overrides for hermetic tests. ## Real run (2026-05-26, 3 broomva roots) 362 skills / 331 unique; budget 223% of 2% ceiling (corroborates the 2026-05-25 269% skill-cleaner reading); 31 cross-root duplicates surfaced. ## Files - NEW scripts/skill-audit.py (~250 lines, pyyaml, env-overridable) - NEW tests/skill-audit.test.sh — 9 hermetic tests (fake roots/registry/logs; realpath-dedupe, dup detection, registry coherence, unused, budget). All pass. - CHANGED bin/bstack-skills — audit) dispatch + usage - VERSION 0.21.7 → 0.21.8 Completes skills-monorepo meta-tooling: graduate (migrate in) + audit (health). --- CHANGELOG.md | 33 +++++ VERSION | 2 +- bin/bstack-skills | 5 + scripts/skill-audit.py | 252 ++++++++++++++++++++++++++++++++++++++ tests/skill-audit.test.sh | 134 ++++++++++++++++++++ 5 files changed, 425 insertions(+), 1 deletion(-) create mode 100755 scripts/skill-audit.py create mode 100755 tests/skill-audit.test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 343e337..4b61d68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 0.21.8 — 2026-05-26 + +### `bstack skills audit` — skill registry audit (Phase 6c) + +New subcommand `bstack skills audit` — crystallizes the "Skill Registry Audit" pattern (bstack-engine candidate ledger, 3/3 instances: Steipete's skill-cleaner + the 2026-05-25 manual inventory + P7 Freshness as a degenerate single-dimension case). Adapts Steipete's skill-cleaner (steipete/agent-scripts) algorithm for Claude Code + bstack. + +Five reports: +1. **Budget** — total description token cost (`ceil(utf8_bytes / chars_per_token)`, identical to Steipete) vs a ceiling (default 20,000 = 2% of 1M). Flags over-budget. +2. **Duplicates** — same skill name across >1 distinct realpath (symlink-deduped, so the `.agents` ↔ workspace symlink case doesn't false-positive). +3. **Registry coherence** — `companion-skills.yaml` vs installed roots: registered-but-missing + installed-but-unregistered. +4. **Unused** — no invocation trace in recent Claude Code session logs (`~/.claude/projects/**/*.jsonl`, replacing Codex's `~/.codex/history.jsonl`); `--months` window; `--no-logs` to skip. +5. **Roots** — skill count per root. + +`--json` for machine output. Env-overridable (`BSTACK_AUDIT_ROOTS`, `BSTACK_DIR`, `BSTACK_AUDIT_LOG_GLOB`) for hermetic testing. + +### First real-run findings (2026-05-26, against the 3 broomva skill roots) + +- 362 skills, 331 unique names across `~/.claude/skills` + `~/.agents/skills` + `~/broomva/skills` +- Description budget at **223% of the 2% ceiling** (44,681 tokens / 20,000) — corroborates the 2026-05-25 skill-cleaner reading (269% over the broader Codex+plugin set) +- 31 cross-root duplicates (mostly snapshot-in-.claude + source-in-workspace — expected, but worth surfacing) + +### Files + +- **NEW** `scripts/skill-audit.py` (~250 lines) — the auditor; pyyaml-based; env-overridable for hermetic tests +- **NEW** `tests/skill-audit.test.sh` — 9 hermetic tests (fake roots + registry + logs; realpath-dedupe, duplicate detection, registry coherence, unused detection, budget flag). All pass. +- **CHANGED** `bin/bstack-skills` — `audit)` dispatch + usage +- **VERSION** `0.21.7` → `0.21.8` + +This completes the skills-monorepo meta-tooling: `graduate` (migrate skills in) + `audit` (keep the registry healthy). + +--- + + ## 0.21.7 — 2026-05-26 ### `bstack skills graduate` — crystallized Tier-2 migration (Phase 6b) diff --git a/VERSION b/VERSION index aa53fc8..4b9d187 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.7 +0.21.8 diff --git a/bin/bstack-skills b/bin/bstack-skills index 999bcbe..24bfd6c 100755 --- a/bin/bstack-skills +++ b/bin/bstack-skills @@ -49,6 +49,8 @@ Subcommands: graduate [options] Migrate a standalone broomva/ skill repo into the broomva/skills Tier-2 monorepo (≥ 0.21.7). Run `bstack skills graduate --help`. + audit [--json] [--no-logs] Registry audit: budget, duplicates, registry + coherence, unused, roots (≥ 0.21.8). help | --help This message Examples: @@ -56,6 +58,8 @@ Examples: bstack skills install --required-only bstack skills install --dry-run bstack skills status --json + bstack skills audit + bstack skills audit --no-logs --json bstack skills graduate handoff --category lifecycle --dry-run EOF } @@ -271,6 +275,7 @@ case "${1:-}" in status) shift; cmd_status "$@" ;; list) shift; cmd_list "$@" ;; graduate) shift; exec "$BSTACK_DIR/scripts/skill-graduate.sh" "$@" ;; + audit) shift; exec python3 "$BSTACK_DIR/scripts/skill-audit.py" "$@" ;; -h|--help|help|"") usage ;; *) echo "bstack-skills: unknown subcommand '$1'" >&2 diff --git a/scripts/skill-audit.py b/scripts/skill-audit.py new file mode 100755 index 0000000..fb0008d --- /dev/null +++ b/scripts/skill-audit.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""skill-audit.py — skill registry audit (bstack v0.21.8). + +Invoked as: `bstack skills audit [options]` + +Crystallizes the "Skill Registry Audit" pattern (bstack-engine candidate ledger, +3/3 instances: Steipete's skill-cleaner + the 2026-05-25 manual inventory + +P7 Freshness as a degenerate single-dimension case). Adapts Steipete's +skill-cleaner (steipete/agent-scripts) algorithm for Claude Code + bstack: + + - token math identical: ceil(utf8_bytes / chars_per_token) + - realpath-dedupe of symlinked roots (the .agents <-> workspace symlink case) + - usage-trace scanning of Claude Code logs (~/.claude/projects/**/*.jsonl) + rather than Codex's ~/.codex/history.jsonl + +Five reports: + 1. Budget — total description token cost vs ceiling (default 2% of 1M) + 2. Duplicates — same skill name across >1 distinct realpath + 3. Registry — coherence between companion-skills.yaml and installed roots + (registered-but-missing, installed-but-unregistered) + 4. Unused — no invocation trace in recent session logs (--months window) + 5. Roots — skill count per root + +Env overrides (test fixtures): + BSTACK_DIR bstack root (for default companion-skills.yaml) + BSTACK_AUDIT_ROOTS colon-separated skill roots (overrides defaults) + BSTACK_AUDIT_LOG_GLOB glob for session logs (default ~/.claude/projects/**/*.jsonl) +""" +from __future__ import annotations + +import argparse +import glob +import json +import math +import os +import re +import sys +from pathlib import Path + +try: + import yaml +except ImportError: + print("skill-audit: python3 yaml module required (pip install pyyaml)", file=sys.stderr) + sys.exit(2) + +HOME = Path.home() +DEFAULT_ROOTS = [ + HOME / ".claude" / "skills", + HOME / ".agents" / "skills", + Path(os.environ.get("BROOMVA_ROOT", HOME / "broomva")) / "skills", +] + + +def parse_frontmatter(skill_md: Path) -> dict: + """Extract YAML frontmatter (name, description) from a SKILL.md.""" + try: + text = skill_md.read_text(encoding="utf-8", errors="replace") + except OSError: + return {} + if not text.startswith("---"): + return {} + end = text.find("\n---", 3) + if end == -1: + return {} + block = text[3:end] + try: + data = yaml.safe_load(block) + return data if isinstance(data, dict) else {} + except yaml.YAMLError: + return {} + + +def token_cost(text: str, chars_per_token: int) -> int: + """Codex-identical: ceil(utf8_bytes / chars_per_token).""" + if not text: + return 0 + return math.ceil(len(text.encode("utf-8")) / chars_per_token) + + +def discover_skills(roots: list[Path]) -> list[dict]: + """Walk roots for */SKILL.md (one level deep + monorepo skills//). + realpath-dedupe so a symlinked root doesn't double-count. + """ + seen_realpaths: set[str] = set() + skills: list[dict] = [] + for root in roots: + if not root.is_dir(): + continue + # Each immediate child dir with a SKILL.md is a skill. + for child in sorted(root.iterdir()): + skill_md = child / "SKILL.md" + if not skill_md.is_file(): + continue + rp = os.path.realpath(skill_md) + if rp in seen_realpaths: + continue + seen_realpaths.add(rp) + fm = parse_frontmatter(skill_md) + name = fm.get("name", child.name) + desc = fm.get("description", "") or "" + if isinstance(desc, list): + desc = " ".join(str(d) for d in desc) + skills.append({ + "name": str(name), + "dir_name": child.name, + "root": str(root), + "path": str(skill_md), + "realpath": rp, + "desc_chars": len(str(desc)), + "description": str(desc), + }) + return skills + + +def load_registry(yaml_path: Path) -> list[dict]: + if not yaml_path.is_file(): + return [] + try: + data = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return [] + return data.get("skills", []) if isinstance(data, dict) else [] + + +def scan_usage(skill_names: list[str], log_glob: str, months: int) -> set[str]: + """Return the set of skill names with an invocation trace in recent logs. + Heuristic (matches Steipete): a name appears as `$`, `--skill `, + or `skills//SKILL.md` in a session JSONL within the window. + """ + import time + cutoff = time.time() - months * 31 * 24 * 3600 + used: set[str] = set() + # Build one combined regex of all names (word-boundary-ish). + if not skill_names: + return used + patterns = {n: re.compile( + r"(?:\$" + re.escape(n) + r"\b|--skill\s+" + re.escape(n) + r"\b|skills/" + re.escape(n) + r"/SKILL\.md)" + ) for n in skill_names} + for fpath in glob.glob(log_glob, recursive=True): + try: + if os.path.getmtime(fpath) < cutoff: + continue + with open(fpath, "r", encoding="utf-8", errors="replace") as fh: + blob = fh.read() + except OSError: + continue + for n, pat in patterns.items(): + if n in used: + continue + if pat.search(blob): + used.add(n) + return used + + +def main() -> int: + ap = argparse.ArgumentParser(prog="bstack skills audit", description="Skill registry audit.") + ap.add_argument("--roots", action="append", default=[], help="Additional skill root (repeatable).") + ap.add_argument("--budget-tokens", type=int, default=20000, help="Token budget ceiling (default 20000 = 2%% of 1M).") + ap.add_argument("--chars-per-token", type=int, default=4, help="Token-cost divisor (default 4).") + ap.add_argument("--months", type=int, default=3, help="Usage-trace window for unused detection (default 3).") + ap.add_argument("--no-logs", action="store_true", help="Skip usage-trace scanning.") + ap.add_argument("--json", action="store_true", help="Machine-readable output.") + args = ap.parse_args() + + # Resolve roots: env override > --roots > defaults. + if os.environ.get("BSTACK_AUDIT_ROOTS"): + roots = [Path(p) for p in os.environ["BSTACK_AUDIT_ROOTS"].split(":") if p] + else: + roots = list(DEFAULT_ROOTS) + roots += [Path(p) for p in args.roots] + + bstack_dir = Path(os.environ.get("BSTACK_DIR", Path(__file__).resolve().parent.parent)) + registry = load_registry(bstack_dir / "references" / "companion-skills.yaml") + + skills = discover_skills(roots) + names = sorted({s["name"] for s in skills}) + + # 1. Budget + total_tokens = sum(token_cost(s["description"], args.chars_per_token) for s in skills) + budget_used_ratio = (total_tokens / args.budget_tokens) if args.budget_tokens else 0.0 + + # 2. Duplicates — same name across >1 distinct realpath + by_name: dict[str, list[dict]] = {} + for s in skills: + by_name.setdefault(s["name"], []).append(s) + duplicates = {n: v for n, v in by_name.items() if len({x["realpath"] for x in v}) > 1} + + # 3. Registry coherence + reg_names = {r["name"] for r in registry if "name" in r} + installed_names = set(names) + registered_missing = sorted(reg_names - installed_names) + installed_unregistered = sorted(installed_names - reg_names) + + # 4. Unused + log_glob = os.environ.get("BSTACK_AUDIT_LOG_GLOB", str(HOME / ".claude" / "projects" / "**" / "*.jsonl")) + unused: list[str] = [] + if not args.no_logs: + used = scan_usage(names, log_glob, args.months) + unused = sorted(set(names) - used) + + # 5. Roots + root_counts: dict[str, int] = {} + for s in skills: + root_counts[s["root"]] = root_counts.get(s["root"], 0) + 1 + + if args.json: + print(json.dumps({ + "total_skills": len(skills), + "unique_names": len(names), + "budget": {"total_tokens": total_tokens, "ceiling": args.budget_tokens, "used_ratio": round(budget_used_ratio, 3)}, + "duplicates": {n: [x["path"] for x in v] for n, v in duplicates.items()}, + "registry": {"registered_missing": registered_missing, "installed_unregistered": installed_unregistered}, + "unused": unused, + "roots": root_counts, + }, indent=2)) + return 0 + + # Human report + print("# Skill Audit Report\n") + print(f"discovered: {len(skills)} skills ({len(names)} unique names) across {len([r for r in roots if r.is_dir()])} roots\n") + print("## Budget") + print(f" description tokens : {total_tokens:,} / {args.budget_tokens:,} ceiling ({budget_used_ratio*100:.1f}%)") + if budget_used_ratio > 1.0: + print(f" ⚠ OVER BUDGET by {(budget_used_ratio-1)*100:.1f}% — consider trimming descriptions or pruning unused skills") + print() + print(f"## Duplicates ({len(duplicates)})") + if duplicates: + for n, v in sorted(duplicates.items()): + print(f" {n}:") + for x in v: + print(f" - {x['path']}") + else: + print(" (none)") + print() + print("## Registry coherence") + print(f" registered but NOT installed ({len(registered_missing)}): {', '.join(registered_missing) or '(none)'}") + print(f" installed but NOT registered ({len(installed_unregistered)}): {', '.join(installed_unregistered) or '(none)'}") + print() + if args.no_logs: + print("## Unused\n (skipped — --no-logs)") + else: + print(f"## Unused (no trace in last {args.months}mo) [{len(unused)}]") + print(f" {', '.join(unused) or '(none — all skills show recent usage)'}") + print() + print("## Roots") + for r, c in sorted(root_counts.items()): + print(f" {c:3d} {r}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/skill-audit.test.sh b/tests/skill-audit.test.sh new file mode 100755 index 0000000..8724d3a --- /dev/null +++ b/tests/skill-audit.test.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# tests/skill-audit.test.sh — Smoke tests for `bstack skills audit`. +# +# Fully hermetic: builds fake skill roots + registry + session logs in a +# tmpdir, points the auditor at them via BSTACK_AUDIT_ROOTS / BSTACK_DIR / +# BSTACK_AUDIT_LOG_GLOB. No real filesystem roots or network touched. +# +# Run from the bstack repo root: +# bash tests/skill-audit.test.sh + +set -uo pipefail + +BSTACK_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SKILLS_BIN="$BSTACK_REPO/bin/bstack-skills" +AUDIT_PY="$BSTACK_REPO/scripts/skill-audit.py" + +PASS=0; FAIL=0; FAILED=() +ap() { PASS=$((PASS+1)); echo " [pass] $1"; } +af() { FAIL=$((FAIL+1)); FAILED+=("$1"); echo " [FAIL] $1"; [ -n "${2:-}" ] && echo " $2"; } + +echo "── skill-audit CLI smoke tests ────────────────────────────────────" + +# Build a hermetic fixture: 2 roots, 1 duplicate (symlink), 1 over-budget desc, +# a registry with one registered-but-missing + ignoring one installed skill, +# and a session log mentioning only one skill. +FX="$(mktemp -d)" +ROOT_A="$FX/rootA"; ROOT_B="$FX/rootB" +mkdir -p "$ROOT_A" "$ROOT_B" + +make_skill() { # + mkdir -p "$1/$2" + printf -- '---\nname: %s\ndescription: %s\n---\nbody\n' "$3" "$4" > "$1/$2/SKILL.md" +} +make_skill "$ROOT_A" alpha alpha "Short description for alpha." +make_skill "$ROOT_A" beta beta "Beta does beta things and triggers on beta." +make_skill "$ROOT_B" gamma gamma "Gamma skill in root B." +# Duplicate: 'alpha' also present in rootB at a DISTINCT path (not a symlink) → should flag as duplicate +make_skill "$ROOT_B" alpha alpha "Short description for alpha." +# Symlinked duplicate: rootB/delta -> rootA/beta (realpath-dedupe should NOT double count) +ln -s "$ROOT_A/beta" "$ROOT_B/delta" + +# Fake registry (BSTACK_DIR/references/companion-skills.yaml) +FAKE_BSTACK="$FX/bstack"; mkdir -p "$FAKE_BSTACK/references" +cat > "$FAKE_BSTACK/references/companion-skills.yaml" <<'YEOF' +schema_version: 1 +skills: + - name: alpha + repo: broomva/skills + category: meta + - name: beta + repo: broomva/skills + category: meta + - name: zeta + repo: broomva/zeta + category: meta +YEOF +# → 'gamma' is installed-but-unregistered; 'zeta' is registered-but-missing. + +# Fake session log mentioning only 'beta' (via --skill beta) +LOGDIR="$FX/logs"; mkdir -p "$LOGDIR" +echo '{"text":"run --skill beta now"}' > "$LOGDIR/session.jsonl" + +run_audit() { + BSTACK_AUDIT_ROOTS="$ROOT_A:$ROOT_B" \ + BSTACK_DIR="$FAKE_BSTACK" \ + BSTACK_AUDIT_LOG_GLOB="$LOGDIR/*.jsonl" \ + python3 "$AUDIT_PY" "$@" +} + +# T1: dispatch advertises audit +t="bstack-skills --help advertises audit" +if "$SKILLS_BIN" --help 2>&1 | grep -q 'audit \[--json\]'; then ap "$t"; else af "$t"; fi + +# T2: JSON output is valid + counts unique names (alpha, beta, gamma = 3; delta symlink-deduped) +t="audit --json valid + realpath-dedupe (delta symlink not double-counted)" +out=$(run_audit --json --no-logs 2>&1); rc=$? +if [ "$rc" -eq 0 ] && echo "$out" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['unique_names']==3, d['unique_names']" 2>/dev/null; then + ap "$t" +else + af "$t" "rc=$rc unique_names mismatch: $(echo "$out" | python3 -c 'import json,sys;print(json.load(sys.stdin).get(\"unique_names\"))' 2>/dev/null)" +fi + +# T3: duplicate detection (alpha in two distinct paths) +t="audit detects alpha duplicate (2 distinct realpaths)" +if run_audit --json --no-logs 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); assert 'alpha' in d['duplicates'], d['duplicates']" 2>/dev/null; then + ap "$t" +else + af "$t" +fi + +# T4: symlinked delta does NOT appear as a duplicate (realpath-dedupe worked) +t="symlinked delta is NOT flagged duplicate" +if run_audit --json --no-logs 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); assert 'delta' not in d['duplicates'] and 'beta' not in d['duplicates']" 2>/dev/null; then + ap "$t" +else + af "$t" +fi + +# T5: registry coherence — gamma unregistered, zeta missing +t="registry coherence (gamma unregistered, zeta missing)" +if run_audit --json --no-logs 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); r=d['registry']; assert 'gamma' in r['installed_unregistered'] and 'zeta' in r['registered_missing'], r" 2>/dev/null; then + ap "$t" +else + af "$t" +fi + +# T6: unused detection — only beta used (per log); alpha+gamma unused +t="unused detection (beta used via log, alpha+gamma unused)" +if run_audit --json --months 99 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); u=d['unused']; assert 'beta' not in u and 'alpha' in u and 'gamma' in u, u" 2>/dev/null; then + ap "$t" +else + af "$t" +fi + +# T7: token budget — tiny ceiling flags over-budget in human output +t="over-budget flag fires with tiny ceiling" +if run_audit --no-logs --budget-tokens 1 2>/dev/null | grep -q 'OVER BUDGET'; then ap "$t"; else af "$t"; fi + +# T8: --no-logs skips usage scan (human output) +t="--no-logs skips usage scan" +if run_audit --no-logs 2>/dev/null | grep -q 'skipped — --no-logs'; then ap "$t"; else af "$t"; fi + +# T9: human report has all 5 sections +t="human report has 5 sections" +out=$(run_audit --no-logs 2>/dev/null) +if echo "$out" | grep -q '## Budget' && echo "$out" | grep -q '## Duplicates' \ + && echo "$out" | grep -q '## Registry coherence' && echo "$out" | grep -q '## Unused' \ + && echo "$out" | grep -q '## Roots'; then ap "$t"; else af "$t"; fi + +rm -rf "$FX" +echo "" +echo "── results: $PASS passed, $FAIL failed ────────────────────────────" +if [ "$FAIL" -gt 0 ]; then printf ' failed: %s\n' "${FAILED[@]}"; exit 1; fi +exit 0 From 3711d541c30b9943c52b706dc1b7db49a99ed78f Mon Sep 17 00:00:00 2001 From: "Carlos D. Escobar-Valbuena" Date: Tue, 26 May 2026 20:53:34 -0500 Subject: [PATCH 2/2] fix(p20-round-1): clamp chars-per-token to >=1 (guard ZeroDivisionError) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P20 cross-review (Strata B, PASS 8/10) flagged --chars-per-token 0 as an unguarded ZeroDivisionError (edge-input nit, not in real corpus). Clamped to max(1, ...) + regression test T9b. The other nit (unquoted-colon description → silent empty parse) is genuinely benign — verified 0 occurrences across all 48 workspace SKILL.md files; YAML quoting/folding handles real descriptions. 10/10 tests pass. --- scripts/skill-audit.py | 5 +++-- tests/skill-audit.test.sh | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/skill-audit.py b/scripts/skill-audit.py index fb0008d..a486f58 100755 --- a/scripts/skill-audit.py +++ b/scripts/skill-audit.py @@ -175,8 +175,9 @@ def main() -> int: skills = discover_skills(roots) names = sorted({s["name"] for s in skills}) - # 1. Budget - total_tokens = sum(token_cost(s["description"], args.chars_per_token) for s in skills) + # 1. Budget. Clamp chars_per_token to >=1 so a bad flag can't ZeroDivision. + cpt = max(1, args.chars_per_token) + total_tokens = sum(token_cost(s["description"], cpt) for s in skills) budget_used_ratio = (total_tokens / args.budget_tokens) if args.budget_tokens else 0.0 # 2. Duplicates — same name across >1 distinct realpath diff --git a/tests/skill-audit.test.sh b/tests/skill-audit.test.sh index 8724d3a..e661c7d 100755 --- a/tests/skill-audit.test.sh +++ b/tests/skill-audit.test.sh @@ -120,6 +120,11 @@ if run_audit --no-logs --budget-tokens 1 2>/dev/null | grep -q 'OVER BUDGET'; th t="--no-logs skips usage scan" if run_audit --no-logs 2>/dev/null | grep -q 'skipped — --no-logs'; then ap "$t"; else af "$t"; fi +# T9b: --chars-per-token 0 does not crash (clamped to >=1) +t="--chars-per-token 0 clamped (no ZeroDivisionError)" +out=$(run_audit --no-logs --chars-per-token 0 2>&1); rc=$? +if [ "$rc" -eq 0 ] && ! echo "$out" | grep -q 'ZeroDivisionError'; then ap "$t"; else af "$t" "rc=$rc"; fi + # T9: human report has all 5 sections t="human report has 5 sections" out=$(run_audit --no-logs 2>/dev/null)