diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml index ee33b30..1e0674b 100644 --- a/.woodpecker/test.yaml +++ b/.woodpecker/test.yaml @@ -20,6 +20,9 @@ steps: - name: shellcheck image: koalaman/shellcheck-alpine:stable commands: - # bin/ is empty until Phase 2 — degrade gracefully rather than failing - # on "no files specified". - - sh -c 'files=$(find bin tests -type f \( -name "*.sh" -o -perm -u+x \) 2>/dev/null); if [ -n "$files" ]; then shellcheck $files; else echo "no scripts yet"; fi' + # bin/ mixes python and bash (plugin-inventory is python) — filter by + # actual shebang, not just the executable bit or a *.sh glob, or + # shellcheck chokes on the python entries (SC1071). + - >- + sh -c 'files=$(grep -lE "^#!.*(bash|/bin/sh)" bin/* tests/*.sh 2>/dev/null); + if [ -n "$files" ]; then shellcheck $files; else echo "no shell scripts yet"; fi' diff --git a/bin/plugin-inventory b/bin/plugin-inventory new file mode 100755 index 0000000..bdb62f2 --- /dev/null +++ b/bin/plugin-inventory @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +"""Static pass over a Claude Code plugin's definitions -> one JSON document. + +Usage: plugin-inventory [--json] [--catalog PATH] + +Reads a plugin's skills/, agents/, hooks/, and bin/, and reports the static +signals defined in design/spec.md §5.1 (verb ratios, procedural density, +duplicated command blocks, rule tables, script coverage, tool-allowlist +shape). Stdlib only, offline, no PyYAML — this file itself is the plugin's +own thesis (FR-8): reading a plugin's structure is a pure-script step by +every one of the five determinism tests in references/boundary-rubric.md. + +No --json: pretty-printed (indent=2), for humans. +--json: compact single line, for piping into bin/offload-scan or tests. +""" +import argparse +import glob +import json +import os +import re +import sys + +PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_CATALOG = os.path.join(PLUGIN_ROOT, "references", "signals-catalog.md") +CACHE_GLOB = os.path.expanduser("~/.claude/plugins/cache/*/{name}/*/") + + +# -------------------------------------------------------------------------- +# signals-catalog.md loader +# -------------------------------------------------------------------------- + +def load_catalog(path): + sections = {} + current = None + with open(path, encoding="utf-8") as f: + for raw in f: + stripped = raw.strip() + if not stripped: + continue + # Section headers look like comments (start with #) but must be + # checked before generic comment-stripping discards them. + m = re.match(r"^#{0,2}\s*\[([a-z-]+)\]$", stripped) + if m: + current = m.group(1) + sections[current] = {} if current == "thresholds" else set() + continue + if stripped.startswith("#"): + continue + line = raw.split("#", 1)[0].strip() + if not line or current is None: + continue + if current == "thresholds": + if ":" in line: + k, v = line.split(":", 1) + sections[current][k.strip()] = float(v.strip()) + else: + for item in line.split(","): + item = item.strip() + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", item): + sections[current].add(item) + return sections + + +# -------------------------------------------------------------------------- +# target resolution +# -------------------------------------------------------------------------- + +def semver_key(dirname): + m = re.match(r"^(\d+)\.(\d+)\.(\d+)", dirname) + return tuple(int(g) for g in m.groups()) if m else (0, 0, 0) + + +def resolve_target(target): + if os.sep in target or os.path.isdir(target): + path = os.path.abspath(target) + if not os.path.isdir(path): + sys.exit(f"error: no such directory: {path}") + return path, "path" + + candidates = [] + for entry in glob.glob(CACHE_GLOB.format(name=re.escape(target)).replace(r"\*", "*")): + if os.path.exists(os.path.join(entry, ".orphaned_at")): + continue + candidates.append(entry.rstrip("/")) + if not candidates: + sys.exit(f"error: no cached plugin found for name {target!r} " + f"(and not a directory)") + candidates.sort(key=lambda p: semver_key(os.path.basename(p))) + return candidates[-1], "oleks-local" + + +# -------------------------------------------------------------------------- +# frontmatter (no PyYAML — this environment's SKILL.md/agent.md frontmatter +# is a narrow subset: scalar `key: value` lines and `key: |` block scalars) +# -------------------------------------------------------------------------- + +def parse_frontmatter(text): + if not text.startswith("---"): + return {}, text + lines = text.split("\n") + if lines[0].strip() != "---": + return {}, text + end = None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + end = i + break + if end is None: + return {}, text + meta = {} + i = 1 + while i < end: + line = lines[i] + if not line.strip() or line.lstrip().startswith("#"): + i += 1 + continue + m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line) + if not m: + i += 1 + continue + key, val = m.group(1), m.group(2).strip() + if val in ("|", "|-", ">", ">-"): + block = [] + i += 1 + while i < end and (lines[i].startswith(" ") or lines[i].startswith("\t") or not lines[i].strip()): + block.append(lines[i].strip()) + i += 1 + meta[key] = " ".join(b for b in block if b) + continue + meta[key] = val.strip('"\'') + i += 1 + body = "\n".join(lines[end + 1:]) + return meta, body + + +def split_list(value): + if not value: + return [] + return [v.strip() for v in value.split(",") if v.strip()] + + +# -------------------------------------------------------------------------- +# signal computation +# -------------------------------------------------------------------------- + +def verb_counts(body, verbs): + total = 0 + for verb in verbs: + total += len(re.findall(r"\b" + re.escape(verb) + r"\b", body, re.IGNORECASE)) + return total + + +def procedural_density(body): + list_item_re = re.compile(r"^\s*(?:\d+[.)]|[-*])\s+.+$", re.MULTILINE) + items = list_item_re.findall(body) + if not items: + return 0.0 + with_command = sum(1 for item in items if "`" in item) + return round(with_command / len(items), 3) + + +def extract_command_blocks(body): + blocks = re.findall(r"```(?:bash|sh|shell)?\n(.*?)```", body, re.DOTALL) + sigs = {} + for block in blocks: + first_line = next((ln.strip() for ln in block.splitlines() if ln.strip()), None) + if not first_line: + continue + sig = re.sub(r"\s+", " ", first_line) + sigs[sig] = sigs.get(sig, 0) + 1 + return [{"sig": sig, "count": count} for sig, count in sorted(sigs.items())] + + +def count_rule_tables(body): + return len(re.findall(r"^\|.+\|\s*\n\|[\s:|-]+\|\s*$", body, re.MULTILINE)) + + +def find_references(body, references_dir): + if not os.path.isdir(references_dir): + return [] + known = set(os.listdir(references_dir)) + found = set() + for m in re.finditer(r"([A-Za-z0-9_-]+\.md)", body): + if m.group(1) in known: + found.add(m.group(1)) + return sorted(found) + + +def wc(text): + return len(text.split()) + + +# -------------------------------------------------------------------------- +# per-plugin walk +# -------------------------------------------------------------------------- + +def walk_skills(plugin_path, catalog): + """Returns (skills, bodies) — bodies maps skill name -> body text, kept + out of the JSON output but needed by build_aggregate for drift detection.""" + skills = [] + bodies = {} + skills_dir = os.path.join(plugin_path, "skills") + if not os.path.isdir(skills_dir): + return skills, bodies + for name in sorted(os.listdir(skills_dir)): + skill_md = os.path.join(skills_dir, name, "SKILL.md") + if not os.path.isfile(skill_md): + continue + with open(skill_md, encoding="utf-8") as f: + text = f.read() + meta, body = parse_frontmatter(text) + script_v = verb_counts(body, catalog["script-verbs"]) + judgment_v = verb_counts(body, catalog["judgment-verbs"]) + ratio = round(script_v / (script_v + judgment_v), 3) if (script_v + judgment_v) else 0.0 + skill_name = meta.get("name", name) + bodies[skill_name] = body + skills.append({ + "name": skill_name, + "description_words": wc(meta.get("description", "")), + "body_words": wc(body), + "allowed_tools": split_list(meta.get("allowed-tools", meta.get("tools", ""))), + "procedural_density": procedural_density(body), + "script_verbs": script_v, + "judgment_verbs": judgment_v, + "verb_ratio": ratio, + "command_blocks": extract_command_blocks(body), + "rule_tables": count_rule_tables(body), + "references": find_references(body, os.path.join(skills_dir, name, "references")), + }) + return skills, bodies + + +def walk_agents(plugin_path, catalog): + agents = [] + agents_dir = os.path.join(plugin_path, "agents") + if not os.path.isdir(agents_dir): + return agents + mechanical = catalog["mechanical-tools"] + for fname in sorted(os.listdir(agents_dir)): + if not fname.endswith(".md"): + continue + with open(os.path.join(agents_dir, fname), encoding="utf-8") as f: + text = f.read() + meta, body = parse_frontmatter(text) + tools = split_list(meta.get("tools", "")) + mech = sum(1 for t in tools if t in mechanical) + agents.append({ + "name": meta.get("name", fname[:-3]), + "model": meta.get("model", ""), + "tools": tools, + "body_words": wc(body), + "mechanical_tool_share": round(mech / len(tools), 3) if tools else 0.0, + }) + return agents + + +def walk_hooks(plugin_path): + """Returns (hooks, texts) — texts maps hook filename -> file content, kept + out of the JSON output but needed by build_aggregate for drift detection.""" + hooks = [] + texts = {} + hooks_dir = os.path.join(plugin_path, "hooks") + if not os.path.isdir(hooks_dir): + return hooks, texts + hooks_json = os.path.join(plugin_path, ".claude-plugin", "hooks.json") + events_by_file = {} + if os.path.isfile(hooks_json): + try: + with open(hooks_json, encoding="utf-8") as f: + data = json.load(f) + for event, entries in (data.get("hooks") or {}).items(): + for entry in entries: + for hook in entry.get("hooks", []): + cmd = hook.get("command", "") + m = re.search(r"([\w.-]+\.sh)\b", cmd) + if m: + events_by_file[m.group(1)] = event + except (json.JSONDecodeError, OSError): + pass + for fname in sorted(os.listdir(hooks_dir)): + fpath = os.path.join(hooks_dir, fname) + if not os.path.isfile(fpath): + continue + with open(fpath, encoding="utf-8", errors="ignore") as f: + content = f.read() + texts[fname] = content + hooks.append({"file": fname, "event": events_by_file.get(fname, "unknown"), + "loc": content.count("\n") + 1}) + return hooks, texts + + +def detect_lang(fpath): + try: + with open(fpath, encoding="utf-8", errors="ignore") as f: + first = f.readline() + except OSError: + return "unknown" + if first.startswith("#!"): + if "python" in first: + return "python" + if "bash" in first: + return "bash" + if "sh" in first: + return "sh" + if fpath.endswith(".py"): + return "python" + return "unknown" + + +def walk_scripts(plugin_path): + scripts = [] + bin_dir = os.path.join(plugin_path, "bin") + if not os.path.isdir(bin_dir): + return scripts + for fname in sorted(os.listdir(bin_dir)): + fpath = os.path.join(bin_dir, fname) + if not os.path.isfile(fpath): + continue + with open(fpath, encoding="utf-8", errors="ignore") as f: + loc = sum(1 for _ in f) + scripts.append({"file": f"bin/{fname}", "loc": loc, "lang": detect_lang(fpath)}) + return scripts + + +def distinctive_tokens(text): + """Identifier-shaped tokens only (snake_case, SCREAMING_CASE, or + file/tool names with an underscore or a dot) — deliberately NOT plain + English words, which overlap constantly between topically-related prose + and produce false positives (e.g. every worktree-discipline skill shares + "already"/"session"/"branch" with every worktree-discipline hook, + regardless of whether any actual logic is restated). Real drift shows up + as the same *specific* identifier — an env var, a function name, a + literal filename — named in both places.""" + snake = re.findall(r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+){1,}\b", text) + screaming = re.findall(r"\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+){1,}\b", text) + dotted = re.findall(r"\b[a-zA-Z][a-zA-Z0-9_-]{3,}\.(?:sh|py|json|md)\b", text) + return {t.lower() for t in snake + screaming + dotted} + + +def build_aggregate(skills, agents, hooks, scripts, catalog, bodies=None, hook_texts=None): + skill_body_words = sum(s["body_words"] for s in skills) + agent_body_words = sum(a["body_words"] for a in agents) + script_loc = sum(s["loc"] for s in scripts) + sum(h["loc"] for h in hooks) + total_words = skill_body_words + agent_body_words + coverage = round(script_loc / total_words, 3) if total_words else 0.0 + + sig_skills = {} + for skill in skills: + for block in skill["command_blocks"]: + sig_skills.setdefault(block["sig"], set()).add(skill["name"]) + min_skills = int(catalog["thresholds"].get("duplicate_block_min_skills", 2)) + duplicated = [ + {"sig": sig, "skills": sorted(names)} + for sig, names in sorted(sig_skills.items()) + if len(names) >= min_skills + ] + + drift = [] + bodies = bodies or {} + hook_texts = hook_texts or {} + min_shared = int(catalog["thresholds"].get("hook_prose_drift_min_shared_tokens", 3)) + for hook in hooks: + hook_tokens = distinctive_tokens(hook_texts.get(hook["file"], "")) + if not hook_tokens: + continue + for skill_name, body in bodies.items(): + shared = hook_tokens & distinctive_tokens(body) + if len(shared) >= min_shared: + drift.append({ + "hook": hook["file"], + "skill": skill_name, + "shared_tokens": sorted(shared)[:10], + }) + + return { + "skill_body_words": skill_body_words, + "agent_body_words": agent_body_words, + "script_loc": script_loc, + "script_coverage": coverage, + "duplicated_command_blocks": duplicated, + "hook_prose_drift": drift, + } + + +def main(): + parser = argparse.ArgumentParser(prog="plugin-inventory") + parser.add_argument("target", help="plugin directory path, or a bare name to resolve from the plugin cache") + parser.add_argument("--json", action="store_true", help="compact single-line JSON instead of pretty-printed") + parser.add_argument("--catalog", default=DEFAULT_CATALOG, help="path to signals-catalog.md") + args = parser.parse_args() + + catalog = load_catalog(args.catalog) + plugin_path, source = resolve_target(args.target) + + manifest_path = os.path.join(plugin_path, ".claude-plugin", "plugin.json") + manifest = {} + if os.path.isfile(manifest_path): + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + + skills, bodies = walk_skills(plugin_path, catalog) + agents = walk_agents(plugin_path, catalog) + hooks, hook_texts = walk_hooks(plugin_path) + scripts = walk_scripts(plugin_path) + aggregate = build_aggregate(skills, agents, hooks, scripts, catalog, bodies, hook_texts) + + doc = { + "plugin": { + "name": manifest.get("name", os.path.basename(plugin_path)), + "version": manifest.get("version", "unknown"), + "source": source, + "path": plugin_path, + }, + "skills": skills, + "agents": agents, + "hooks": hooks, + "scripts": scripts, + "aggregate": aggregate, + } + + if args.json: + print(json.dumps(doc)) + else: + print(json.dumps(doc, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/references/signals-catalog.md b/references/signals-catalog.md new file mode 100644 index 0000000..1425cc6 --- /dev/null +++ b/references/signals-catalog.md @@ -0,0 +1,107 @@ +# signals-catalog.md — static-pass lexicons and thresholds + +Loaded at runtime by `bin/plugin-inventory`. Kept out of the script body so a +lexicon or threshold change is a reviewable diff, not a code change — per +`design/plan.md` §3. + +Format: one `key: value` per line, grouped under `[section]` headers. Lines +starting with `#` are comments. Whitespace-insensitive; commas separate list +values. `plugin-inventory` parses this file itself — a genuine `pure-script` +read (T1–T5 all pass: closed format, one right parse, deterministic, no +missing context, bounded grammar), so it is not a candidate for anything more +than what it already is. + +## [script-verbs] + +Occurrences count toward the script-verb signal. Matched case-insensitively, +whole-word, against skill/agent body prose (frontmatter excluded). + +count, sum, rank, sort, diff, parse, validate, enumerate, dedupe, dedup, +deduplicate, format, compare, aggregate, glob, grep, filter, normalize, +normalise, tabulate, checksum, hash, lint, tally, group, join, merge, split, +truncate, paginate, timestamp, serialize, serialise, deserialize, encode, +decode + +## [judgment-verbs] + +Occurrences count toward the judgment-verb signal. Same matching rule. + +decide, judge, explain, prioritize, prioritise, name, write, weigh, +interpret, recommend, assess, evaluate, justify, persuade, negotiate, +empathize, empathise, phrase, rephrase, summarize, summarise, synthesize, +synthesise, disambiguate, reconcile, adjudicate, arbitrate, narrate + +# Note: summarize/synthesize sit in judgment, not script — compressing text + +# into a good summary is not a total function of its inputs (T4), even though + +# "summarize" sounds mechanical. If a step turns out to be a fixed-format + +# extraction (e.g. "summarize" really means "pull these five fields"), that is + +# a digest problem — flag it as low/medium, not high, until a human confirms + +# the extraction is exhaustive + +## [mechanical-tools] + +Tools whose presence in an agent's `tools:` list leans that agent toward +"built to fetch." Used for `mechanical_tool_share`. + +Bash, Read, Grep, Glob, WebFetch, WebSearch, LS, NotebookRead, +ListMcpResourcesTool, ReadMcpResourceTool, ReadMcpResourceDirTool, TaskGet, +TaskList, TaskOutput, Monitor + +# Deliberately excluded (judgment-implying, even when mechanical-sounding) + +# Edit, Write, NotebookEdit (produce durable state — a mistake here is + +# consequential, T5/P4), AskUserQuestion (is itself an escalation), Agent + +# (spawns judgment, doesn't replace it) + +## [thresholds] + +verb_ratio_high: 0.6 + +# verb_ratio (script / (script+judgment)) above this with zero bin/ script + +# coverage is the loudest static smell per spec.md §5.1 + +script_coverage_low: 0.15 + +# script LOC / (skill+agent body words) below this, combined with + +# verb_ratio_high, is the target profile for a real candidate + +procedural_density_high: 0.35 + +# fraction of a skill body that is numbered/imperative steps containing a + +# literal command, above which the skill reads as a recipe being re-derived + +# every invocation rather than prose reasoning + +mechanical_tool_share_high: 0.75 + +# fraction of an agent's declared tools that are in [mechanical-tools] + +# above which the agent's own tool allowlist argues it was built to fetch + +duplicate_block_min_skills: 2 + +# a fenced command block (after normalization — see FR-3.5 masking rules + +# applied identically here) appearing in at least this many distinct skills + +# counts as "a shared script that was never written." + +hook_prose_drift_min_shared_tokens: 2 + +# a hook script and a skill body sharing at least this many identifier-shaped + +# tokens (snake_case, SCREAMING_CASE, or a literal filename — NOT plain + +# English words, which overlap constantly between topically-related prose) + +# counts as the hook's logic being restated in prose — two sources of truth diff --git a/tests/fixtures/sample-plugin/.claude-plugin/plugin.json b/tests/fixtures/sample-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..d863e92 --- /dev/null +++ b/tests/fixtures/sample-plugin/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "sample-plugin", + "version": "0.1.0", + "description": "Fixture plugin for tests/inventory.test.sh — not a real plugin." +} diff --git a/tests/fixtures/sample-plugin/agents/fetcher.md b/tests/fixtures/sample-plugin/agents/fetcher.md new file mode 100644 index 0000000..3d78fa1 --- /dev/null +++ b/tests/fixtures/sample-plugin/agents/fetcher.md @@ -0,0 +1,8 @@ +--- +name: fetcher +description: Fixture agent — mostly mechanical tools. +model: sonnet +tools: Bash, Read, Grep, Glob, Edit +--- + +Fetches and greps things. diff --git a/tests/fixtures/sample-plugin/bin/noop b/tests/fixtures/sample-plugin/bin/noop new file mode 100755 index 0000000..95ac61c --- /dev/null +++ b/tests/fixtures/sample-plugin/bin/noop @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Fixture script — three lines of LOC for tests/inventory.test.sh. +echo "noop" diff --git a/tests/fixtures/sample-plugin/hooks/guard.sh b/tests/fixtures/sample-plugin/hooks/guard.sh new file mode 100644 index 0000000..313cef2 --- /dev/null +++ b/tests/fixtures/sample-plugin/hooks/guard.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Fixture hook for tests/inventory.test.sh — checks FIXTURE_ENV_VAR via fixture_check. +set -euo pipefail +fixture_check() { + [ -n "${FIXTURE_ENV_VAR:-}" ] +} +fixture_check diff --git a/tests/fixtures/sample-plugin/skills/dupe-a/SKILL.md b/tests/fixtures/sample-plugin/skills/dupe-a/SKILL.md new file mode 100644 index 0000000..ba3d022 --- /dev/null +++ b/tests/fixtures/sample-plugin/skills/dupe-a/SKILL.md @@ -0,0 +1,21 @@ +--- +name: dupe-a +description: | + Fixture skill for tests/inventory.test.sh. Trigger on "fixture-a". +allowed-tools: Bash, Read +--- + +# dupe-a + +1. Count and sort the input. +2. Then judge which result matters and explain why. +3. Set `FIXTURE_ENV_VAR` before running — `fixture_check` restates this hook's + guard, which is exactly the drift this fixture exists to catch. + +```bash +git status --short +``` + +| Finding | Action | +|---|---| +| foo | bar | diff --git a/tests/fixtures/sample-plugin/skills/dupe-b/SKILL.md b/tests/fixtures/sample-plugin/skills/dupe-b/SKILL.md new file mode 100644 index 0000000..e4a8c68 --- /dev/null +++ b/tests/fixtures/sample-plugin/skills/dupe-b/SKILL.md @@ -0,0 +1,14 @@ +--- +name: dupe-b +description: | + Fixture skill for tests/inventory.test.sh. Trigger on "fixture-b". +allowed-tools: Bash +--- + +# dupe-b + +Parse and validate the output, then decide what to recommend. + +```bash +git status --short +``` diff --git a/tests/inventory.test.sh b/tests/inventory.test.sh new file mode 100755 index 0000000..285e4e8 --- /dev/null +++ b/tests/inventory.test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Asserts bin/plugin-inventory against tests/fixtures/sample-plugin, whose +# every signal value is known by construction (see the fixture files +# themselves for what each one is designed to trigger). +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +OUT=$(python3 bin/plugin-inventory tests/fixtures/sample-plugin --json) +fail=0 + +check() { + local desc="$1" expr="$2" + if ! python3 -c " +import json, sys +d = json.loads('''$OUT''') +assert ($expr), 'FAILED: $desc' +"; then + echo "FAIL: $desc" >&2 + fail=1 + else + echo "ok: $desc" + fi +} + +check "plugin name resolved from manifest" "d['plugin']['name'] == 'sample-plugin'" +check "two skills found" "len(d['skills']) == 2" +check "dupe-a verb_ratio is 0.5 (2 script, 2 judgment)" "d['skills'][0]['verb_ratio'] == 0.5" +check "dupe-a rule_tables counted" "d['skills'][0]['rule_tables'] == 1" +check "dupe-b has no rule table" "d['skills'][1]['rule_tables'] == 0" +check "duplicated command block found across dupe-a and dupe-b" " + any(b['sig'] == 'git status --short' and sorted(b['skills']) == ['dupe-a', 'dupe-b'] + for b in d['aggregate']['duplicated_command_blocks']) +" +check "one agent found" "len(d['agents']) == 1" +check "fetcher mechanical_tool_share is 0.8 (4 of 5 tools)" "d['agents'][0]['mechanical_tool_share'] == 0.8" +check "one hook found" "len(d['hooks']) == 1 and d['hooks'][0]['file'] == 'guard.sh'" +check "hook LOC matches file" "d['hooks'][0]['loc'] == 8" +check "one bin script found, bash detected" "d['scripts'][0]['lang'] == 'bash' and d['scripts'][0]['loc'] == 3" +check "hook_prose_drift catches FIXTURE_ENV_VAR/fixture_check restated in dupe-a" " + any(p['hook'] == 'guard.sh' and p['skill'] == 'dupe-a' + and {'fixture_env_var', 'fixture_check'} <= set(p['shared_tokens']) + for p in d['aggregate']['hook_prose_drift']) +" +check "hook_prose_drift does NOT fire for dupe-b (no shared identifiers)" " + not any(p['skill'] == 'dupe-b' for p in d['aggregate']['hook_prose_drift']) +" + +# Target resolution: bare name against a nonexistent cache entry fails loudly. +if python3 bin/plugin-inventory this-plugin-does-not-exist >/dev/null 2>&1; then + echo "FAIL: unresolvable target should exit non-zero" >&2 + fail=1 +else + echo "ok: unresolvable target exits non-zero" +fi + +exit "$fail"