From 9285b29e3caf7c8f2ce8db306ea180c230691134 Mon Sep 17 00:00:00 2001 From: Defame1297 Date: Tue, 29 Sep 2026 08:00:17 +0000 Subject: [PATCH] fix(factory-audit): flag unbraced plugin-root tokens and close hook check gaps Why: PR #144 review round 4 reproduced hooks referencing $PLUGIN_ROOT or ${PLUGIN_ROOT} without a path separator passing the audit, although apm only rewrites ${TOKEN}/ and the deployed hook points nowhere. - FAIL unbraced or unseparated plugin-root tokens - check the exec bit for scripts run via an interpreter -c string - skip env NAME=value prefixes when locating bare relative paths - correct input: and empty-frontmatter messages, depth-walk applyTo braces - INFO on unrecognised targets; failing-case tests for untested checks - document tiers, blind spots and crash exit 2; drop rtk from portable flow - restore the after-a-hand-edit trigger; pin upstream apm source URL Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KkT7RSDwDbmrM9T34b6sTi --- .../.apm/skills/factory-audit/SKILL.md | 6 +- .../factory-audit/assets/vale/.vale.ini | 2 +- .../vale/styles/Kyberforge/PaddingPhrase.yml | 2 +- .../factory-audit/references/hook-flow.md | 32 ++- .../references/instruction-flow.md | 4 +- .../factory-audit/references/prompt-flow.md | 4 +- .../factory-audit/references/sources.md | 3 +- .../scripts/lib-checks-primitive.sh | 110 +++++++-- .../skills/factory-audit/scripts/validate.sh | 6 +- .../.apm/skills/factory-audit/tests/README.md | 10 +- .../tests/validate-primitive.bats | 227 ++++++++++++++++++ 11 files changed, 363 insertions(+), 43 deletions(-) diff --git a/plugins/kyberforge/.apm/skills/factory-audit/SKILL.md b/plugins/kyberforge/.apm/skills/factory-audit/SKILL.md index 5ffb2d8..21722e3 100644 --- a/plugins/kyberforge/.apm/skills/factory-audit/SKILL.md +++ b/plugins/kyberforge/.apm/skills/factory-audit/SKILL.md @@ -1,8 +1,8 @@ --- name: factory-audit description: > - Use when a skill, agent, or apm hook, instruction or prompt needs auditing, - or "is this ready to ship". Not fixing a skill -> skill-author. + Use when a skill, agent, apm hook, instruction or prompt needs auditing + ("ready to ship?"), even after a hand edit. Not fixing a skill -> skill-author. Not fixing an agent -> agent-author. Not fixing a hook, instruction or prompt -> primitive-author. allowed-tools: Bash Read @@ -42,7 +42,7 @@ Resolve the flow from the target path **before running anything**. The flows run | A file named `*.instructions.md` | instruction | `references/instruction-flow.md` | | A file named `*.prompt.md` | prompt | `references/prompt-flow.md` | | A `.md` file whose immediate parent directory is `agents/` (`.apm/agents`, `.claude/agents`, `.github/agents`, `.copilot/agents`) | agent | `references/agent-flow.md` | -| A `.json` file whose immediate parent directory is `hooks/` (`.apm/hooks`, or a package-root `hooks/`; any other `hooks/` is deployed output the hook flow FAILs) | hook | `references/hook-flow.md` | +| A `.json` file whose immediate parent directory is `hooks/` (`.apm/hooks`, or a package-root `hooks/`; the hook flow FAILs any other `hooks/`) | hook | `references/hook-flow.md` | | Anything else — a missing path, a directory without `SKILL.md`, any other file | none | — | Read only the file its row matched. Each carries Steps 1 to 3 — the deterministic checks, the read, and the qualitative audit — and is self-contained. Return here for Step 4. diff --git a/plugins/kyberforge/.apm/skills/factory-audit/assets/vale/.vale.ini b/plugins/kyberforge/.apm/skills/factory-audit/assets/vale/.vale.ini index 0b4767d..e37222c 100644 --- a/plugins/kyberforge/.apm/skills/factory-audit/assets/vale/.vale.ini +++ b/plugins/kyberforge/.apm/skills/factory-audit/assets/vale/.vale.ini @@ -12,7 +12,7 @@ BasedOnStyles = Kyberforge [**/*.prompt.md] BasedOnStyles = Kyberforge -# Stays the last section: tests/test-vale-wrap.sh case 31 appends a rule +# Stays the last section: the repo's `tests/test-vale-wrap.sh` case 31 appends a rule # override to the end of this file and relies on it landing here. [**/*.agent.md] BasedOnStyles = Kyberforge, KyberforgeCopilot diff --git a/plugins/kyberforge/.apm/skills/factory-audit/assets/vale/styles/Kyberforge/PaddingPhrase.yml b/plugins/kyberforge/.apm/skills/factory-audit/assets/vale/styles/Kyberforge/PaddingPhrase.yml index 4c5f5ae..1d721a9 100644 --- a/plugins/kyberforge/.apm/skills/factory-audit/assets/vale/styles/Kyberforge/PaddingPhrase.yml +++ b/plugins/kyberforge/.apm/skills/factory-audit/assets/vale/styles/Kyberforge/PaddingPhrase.yml @@ -1,5 +1,5 @@ extends: existence -message: "Generic reference pointer: '%s' — use the specific 'If X, read `references/file.md`' form instead" +message: "Generic reference pointer: '%s' — name the exact file and when to read it ('If X, read ``') instead" level: error scope: text ignorecase: true diff --git a/plugins/kyberforge/.apm/skills/factory-audit/references/hook-flow.md b/plugins/kyberforge/.apm/skills/factory-audit/references/hook-flow.md index 23d8e95..18d48b2 100644 --- a/plugins/kyberforge/.apm/skills/factory-audit/references/hook-flow.md +++ b/plugins/kyberforge/.apm/skills/factory-audit/references/hook-flow.md @@ -25,17 +25,41 @@ Resolve the path against this skill's own directory. Run exactly: bash scripts/validate.sh ``` -Its findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: JSON validity, the wrapped-or-naked shape, event lists and nested handler lists (the checks whose failure makes the Copilot install fail), a file contributing no entries (no events, only empty event lists, or an entry with no handler), event names that never fire, unfilled `FILL IN` or `FILL_IN_` template placeholders, a symlinked file or one under apm's deployed output rather than package source, referenced scripts that are missing, outside the package, not executable when run directly, or referenced by an absolute, bare relative, `../`, split-quoted, space-containing, or `$`/backtick-containing path apm will not bundle correctly, deprecated filename routing, and `${CLAUDE_PLUGIN_ROOT}` where `${PLUGIN_ROOT}` would do. Script references are read with apm 0.28.0's own patterns: `${PLUGIN_ROOT}/…` only when the path follows the token directly, up to the first whitespace or quote, and `./…` anywhere in the command. A `./` or `../` match is held to the script rules — a FAIL when missing — only in command position (the first token, or the first operand after `bash`, `sh`, `zsh`, `python`, `python3`, `node`, `pwsh`, `ruby` or `perl`, past its options such as `-e` or `-u`; after a `sh`-family `-c`, the first token of the command string; inline code such as `python3 -c` has none), when it ends in a script extension, or when it names a package entry that is not a file; any other match (`npx prettier --check ./src`, `printf '.\n'`) is at most a SUGGESTION, because apm only warns and it runs against the consumer's working directory as meant. Absolute and bare relative script paths are checked in the same command positions. Each event is judged per target the package root's `apm.yml` deploys to — no `target:`/`targets:`, `all`, or no `apm.yml` means every hook target — after apm's rename map for that target: it FAILs when a target with a published event list (Claude, Copilot) does not fire the renamed name, whatever its casing. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason. An INFO line is observational: report it under `### Structure` and count it in `· P info`. +Its findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned. + +Checks: + +- JSON validity and UTF-8 encoding; a top level that is not a JSON object; the wrapped-or-naked shape; event lists and nested handler lists (the checks whose failure makes the Copilot install fail). +- A file contributing no entries (no events, only empty event lists, or an entry with no handler), an empty event name, and event names that never fire. +- Unfilled `FILL IN` or `FILL_IN_` template placeholders. +- A symlinked file, or one under apm's deployed output or outside any package rather than package source (exit 1, a finding, not exit 2). +- Referenced scripts that are missing, outside the package, not executable when run directly, or referenced by an absolute, bare relative, `../`, split-quoted, space-containing, or `$`/backtick-containing path apm will not bundle correctly. +- A plugin-root token apm never rewrites: unbraced (`$PLUGIN_ROOT/x.sh`, `$CLAUDE_PLUGIN_ROOT`), or braced but not directly followed by `/` or `\` (`cd ${PLUGIN_ROOT} && …`). +- Deprecated filename routing, and `${CLAUDE_PLUGIN_ROOT}` where `${PLUGIN_ROOT}` would do. +- INFO: no `apm.yml` at an inferred `.apm/` package root, or a `targets:` naming no hook target apm recognises (`claude-code`), which leaves event names checked against no harness. + +Script reference rules: + +- Script references are read with apm 0.28.0's own patterns: `${PLUGIN_ROOT}/…` only when the path follows the token directly, up to the first whitespace or quote, and `./…` anywhere in the command. +- A `./` or `../` match is held to the script rules — a FAIL when missing — only in command position, when it ends in a script extension, or when it names a package entry that is not a file; any other match (`npx prettier --check ./src`, `printf '.\n'`) is at most a SUGGESTION, because apm only warns and it runs against the consumer's working directory as meant. +- Command position is the first token past any `NAME=value` assignments and `env` with its options, or the first operand after `bash`, `sh`, `zsh`, `python`, `python3`, `node`, `pwsh`, `ruby` or `perl`, past its options such as `-e` or `-u`; after a `sh`-family `-c`, the first token of the command string; inline code such as `python3 -c` has none. Absolute and bare relative script paths are checked in the same positions. +- The exec bit is required of a script that runs directly: the first token, or the first token of a `-c` command string (`bash -c "${PLUGIN_ROOT}/x.sh"`). Through an interpreter it is not. +- Each event is judged per target the package root's `apm.yml` deploys to — no `target:`/`targets:`, `all`, or no `apm.yml` means every hook target — after apm's rename map for that target: it FAILs when a target with a published event list (Claude, Copilot) does not fire the renamed name, whatever its casing. + +Exit codes: **0** with no FAIL, **1** on real findings, **2** when it never ran, including a crash inside the checks — report that as `### Structure` unverified, quoting the stderr reason. An INFO line is observational: report it under `### Structure` and count it in `· P info`. + +The command parser is a heuristic, not a shell. Known blind spots: a script after `&&`, `;` or a pipe inside one command, `env -S`, command substitution, and a quoted `-c` string that ends before the script are not in command position, so a bad reference there is at most a SUGGESTION or unseen. Read every command in Step 2 rather than taking a clean script run as proof. There is no provenance and no Vale step: a hook carries no `source_keys` and no prose. -Five tiers deliberately differ from `primitive-author`'s checklist or the research's. Do not re-tier them by judgment: +Six tiers deliberately differ from `primitive-author`'s checklist or the research's. Do not re-tier them by judgment: -- A hook file directly under a package-root `hooks/` — beside the package's `apm.yml`, or beside a `plugin.json` at any location apm's `find_plugin_json` reads (root, `.github/plugin/`, `.claude-plugin/`, `.cursor-plugin/`) — passes. apm discovers both `.apm/hooks/` and `hooks/`, installs a Claude plugin with no `apm.yml`, and this audit may target a third-party package; `primitive-author` authors only in `.apm/hooks/`. Any other `hooks/` directory (`.github/hooks/`, `.cursor/hooks/`, …) is apm's deployed output and FAILs. +- A hook file directly under a package-root `hooks/` — beside the package's `apm.yml`, or beside a `plugin.json` at any location apm's `find_plugin_json` reads (root, `.github/plugin/`, `.claude-plugin/`, `.cursor-plugin/`) — passes. apm discovers both `.apm/hooks/` and `hooks/`, installs a Claude plugin with no `apm.yml`, and this audit may target a third-party package; `primitive-author` authors only in `.apm/hooks/`. Any other `hooks/` directory (`.github/hooks/`, `.cursor/hooks/`, …) is apm's deployed output, or no package source at all, and FAILs at exit 1. - An event that every listed target fires, but that reaches a target with no published event list (Cursor, Kiro, Gemini, Codex, Antigravity, Windsurf) in a non-PascalCase form after apm's rename, is a SUGGESTION, not the FAIL Must 4 implies: the script cannot tell a harness's native spelling (Cursor's `stop`, Windsurf's `pre_run_command`) from a typo. A Cursor-only `stop` therefore exits 0. - Copilot counts every name apm's own Copilot map emits as fired (`userPromptSubmit`, although Copilot documents `userPromptSubmitted`): the author cannot route around apm's rename, so that is not a finding in the file. - Deprecated filename routing is a SUGGESTION, matching the author's Should: the research allows it when deprecated routing is intended. -- A non-executable script run as the command's first token is a FAIL, stricter than the research's Should, because it fails every time it fires. +- A non-executable script run directly (the first token, or first in a `-c` string) is a FAIL, stricter than the research's Should, because it fails every time it fires. +- `primitive-author` hook Must 5 bans an absolute or bare relative path "in any position"; this audit checks command positions only, because a later argument is data the script cannot tell from a path. Judge the rest by reading in Step 3. ## Step 2 — Read the hook and its scripts diff --git a/plugins/kyberforge/.apm/skills/factory-audit/references/instruction-flow.md b/plugins/kyberforge/.apm/skills/factory-audit/references/instruction-flow.md index a7fe060..e809b70 100644 --- a/plugins/kyberforge/.apm/skills/factory-audit/references/instruction-flow.md +++ b/plugins/kyberforge/.apm/skills/factory-audit/references/instruction-flow.md @@ -23,7 +23,7 @@ bash scripts/validate.sh bash scripts/vale-wrap.sh ``` -`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path, unfilled `FILL IN` or `FILL_IN_` template placeholders, frontmatter, `description`, body, an `applyTo` that is present but empty or has unbalanced braces or brackets, a missing or list-form `applyTo`, extra keys, and a stem duplicated at the package root. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason. +`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path, a file that is not valid UTF-8, unfilled `FILL IN` or `FILL_IN_` template placeholders, frontmatter, `description`, body, an `applyTo` that is neither a string nor a list, present but empty, or has unbalanced braces or brackets (a closer before its opener counts), a missing or list-form `applyTo`, extra keys, and a stem duplicated at the package root. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran, including a crash inside the checks — report that as `### Structure` unverified, quoting the stderr reason. A missing `applyTo` is a SUGGESTION, not the FAIL `primitive-author`'s instruction Must 4 implies: absence is legal after the author Gate's explicit yes, which the audit cannot see. The **scope** dimension's always-on FAILs below cover the misuse. Do not re-tier it by judgment. @@ -33,7 +33,7 @@ There is no provenance step: an instruction carries no `source_keys`. ## Step 2 — Read the instruction and its context -Read the file, the package's `apm.yml`, and the repo's root `AGENTS.md`. For a scoped file, list the tracked files its `applyTo` matches (`rtk git ls-files` filtered by the glob). List the instruction stems the installed dependencies ship (`apm_modules/**/.apm/instructions/*.instructions.md`) — the script checks only the package root for a duplicate. +Read the file, the package's `apm.yml`, and the repo's root `AGENTS.md`. For a scoped file, list the tracked files its `applyTo` matches (`git ls-files` filtered by the glob). List the instruction stems the installed dependencies ship (`apm_modules/**/.apm/instructions/*.instructions.md`) — the script checks only the package root for a duplicate. ## Step 3 — Qualitative audit diff --git a/plugins/kyberforge/.apm/skills/factory-audit/references/prompt-flow.md b/plugins/kyberforge/.apm/skills/factory-audit/references/prompt-flow.md index 589d632..54877b2 100644 --- a/plugins/kyberforge/.apm/skills/factory-audit/references/prompt-flow.md +++ b/plugins/kyberforge/.apm/skills/factory-audit/references/prompt-flow.md @@ -12,7 +12,7 @@ order, then return to `SKILL.md` Step 4 to report. ## Gotchas -- A prompt is judged against ADR-0029, not against apm's framing. apm calls a prompt "a callable program"; this repo holds it to a single-intent, user-triggered message that steers existing skills or agents by name and carries no procedure of its own. +- A prompt is judged against ADR-0029, not against apm's framing. apm's docs call a prompt "a program for an LLM" (`apm-docs-llms-full`, "What is APM?" › "Secure by default"); this repo holds it to a single-intent, user-triggered message that steers existing skills or agents by name and carries no procedure of its own. - A prompt's description is not a skill description. It is one plain user-facing sentence with no "Use when" trigger clause and no boundary clause — so never raise a missing trigger or boundary as a finding. A Vale `Kyberforge.DescriptionOpener` alert here means rewrite it as an imperative action ("Review the current PR with …"), not add a trigger. ## Step 1 — Deterministic checks @@ -24,7 +24,7 @@ bash scripts/validate.sh bash scripts/vale-wrap.sh ``` -`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path and name, unfilled `FILL IN` or `FILL_IN_` template placeholders, frontmatter, `description` presence, length, and trigger or `Not X -> Y` boundary clause, keys Claude drops, the camelCase spelling of `allowed-tools` or `argument-hint` and an `argument-hint` alongside `input:` (both SUGGESTION), `input:` names and the object form `- : ""` (`primitive-author` prompt Must 3 — a bare name, a string list or a plain map is a FAIL even though apm reads them), and `${input:x}` references against `input:`. Keys Claude drops are a SUGGESTION, per `primitive-author` prompt Should 5: a Copilot-only key is legitimate when its Claude drop is intended, and only the author can say which. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason. +`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path and name, a file that is not valid UTF-8, unfilled `FILL IN` or `FILL_IN_` template placeholders, frontmatter, `description` presence, length, and trigger or `Not X -> Y` boundary clause, keys Claude drops, the camelCase spelling of `allowed-tools` or `argument-hint` and an `argument-hint` alongside `input:` (both SUGGESTION), `input:` names and the object form `- : ""` (`primitive-author` prompt Must 3 — a bare name, a string list or a plain map is a FAIL even though apm reads them), and `${input:x}` references against `input:`. Keys Claude drops are a SUGGESTION, per `primitive-author` prompt Should 5: a Copilot-only key is legitimate when its Claude drop is intended, and only the author can say which. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran, including a crash inside the checks — report that as `### Structure` unverified, quoting the stderr reason. `vale-wrap.sh` applies the bundled `Kyberforge` style. Every alert is a FAIL under `### Prose`, cited by rule ID; do not re-derive it by judgment. That includes the rules scoped to the `description` (`Kyberforge.DescriptionOpener`, `VagueWording`, `CompositionNote`), a deliberate deviation from `primitive-author`, which holds description wording to at most a Should: the house prose rules apply to every model- or user-visible description, and a deterministic rule does not change tier by file kind. Do not re-tier them. `0 files` scanned means NOT RUN, not clean — say so and judge prose by reading. diff --git a/plugins/kyberforge/.apm/skills/factory-audit/references/sources.md b/plugins/kyberforge/.apm/skills/factory-audit/references/sources.md index f7dd5ac..0f77b3c 100644 --- a/plugins/kyberforge/.apm/skills/factory-audit/references/sources.md +++ b/plugins/kyberforge/.apm/skills/factory-audit/references/sources.md @@ -159,7 +159,8 @@ source_keys: ## apm-cli-installed-source -- **URL:** file:///root/.local/pipx/venvs/apm-cli/lib/python3.11/site-packages/apm_cli/ +- **URL:** https://github.com/microsoft/apm/tree/v0.28.0/src/apm_cli/ +- **Note:** read locally from the pipx install at `~/.local/pipx/venvs/apm-cli/lib/python3.11/site-packages/apm_cli/` (apm-cli 0.28.0, tag `v0.28.0`) - **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md - **Description:** Installed apm-cli 0.28.0 source — ground truth for what apm deploys from a hook, instruction or prompt file and what it silently skips, warns on, or fails the install for; every deterministic check in `scripts/lib-checks-primitive.sh` traces to it via the research docs' Authoring checklists - **Contributing files:** SKILL.md, references/hook-flow.md, references/instruction-flow.md, references/prompt-flow.md diff --git a/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-primitive.sh b/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-primitive.sh index c244c02..4c20bff 100755 --- a/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-primitive.sh +++ b/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-primitive.sh @@ -129,7 +129,9 @@ def package_root_for(subdir): return os.path.dirname(apm_dir) -FRONTMATTER_RE = re.compile(r'\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL) +# The inner group is lazy-optional so an empty block (`---` directly followed +# by `---`) matches as empty rather than as no block at all. +FRONTMATTER_RE = re.compile(r'\A---[ \t]*\r?\n(?:(.*?)\r?\n)??---[ \t]*(?:\r?\n|\Z)', re.DOTALL) def split_frontmatter(content): @@ -141,7 +143,7 @@ def split_frontmatter(content): fail(f"has no YAML frontmatter block (--- ... ---) — description and every other key live there — {fname}") return None, content, False try: - fm = yaml.safe_load(m.group(1)) + fm = yaml.safe_load(m.group(1) or '') except yaml.YAMLError as exc: mark = getattr(exc, 'problem_mark', None) where = f" at line {mark.line + 2}" if mark is not None else '' @@ -252,6 +254,12 @@ ROOT_TOKEN_RE = re.compile(r'\$\{(' + '|'.join(ROOT_TOKENS) + r')\}') # matches after an interpreter (`bash ./x.sh`) too. APM_ROOT_REF_RE = re.compile(r'\$\{(?:' + '|'.join(ROOT_TOKENS) + r')\}([\\/][^\s"\']+)') APM_REL_REF_RE = re.compile(r'(\.[\\/][^\s"\']+)') +# A plugin-root token apm never rewrites: unbraced, so its pattern cannot see it. +UNBRACED_ROOT_RE = re.compile(r'\$(?:CLAUDE_|CURSOR_|KIRO_)?PLUGIN_ROOT\b') +# A NAME=value shell assignment before the command, bare or after `env`. +ASSIGN_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=') +# env options that consume the next token as their value. +ENV_VALUE_OPTS = {'-u', '--unset', '-C', '--chdir'} # An interpreter whose first operand is the script it runs. A reference in @@ -276,16 +284,33 @@ def _prefix_tokens(prefix): return [t.strip('"\'') for t in prefix.split()] +def _command_start(tokens): + """Index of the token that actually runs: past leading NAME=value + assignments and an `env` with its options and assignments.""" + i = 0 + while i < len(tokens) and ASSIGN_RE.match(tokens[i]): + i += 1 + if i < len(tokens) and os.path.basename(tokens[i]) == 'env': + i += 1 + while i < len(tokens): + tok = tokens[i] + if tok in ENV_VALUE_OPTS: + i += 2 + elif tok.startswith('-') or ASSIGN_RE.match(tok): + i += 1 + else: + break + return i + + def _interp_arg_index(tokens): """(index, is_command_string) of the script operand after a known - interpreter (optionally behind `env`), or None when the command does not + interpreter (optionally behind assignments or `env`), or None when the command does not open with one. Option flags are skipped (`bash -e x.sh`, `python3 -u x.py`); for a sh-family `-c` the operand is the command string, whose own first token is the script (`sh -c 'scripts/x.sh'`). Inline code (`python3 -c`, `node -e`) has no script operand.""" - i = 0 - if tokens and os.path.basename(tokens[0]) == 'env': - i = 1 + i = _command_start(tokens) if not (len(tokens) > i and os.path.basename(tokens[i]) in INTERPRETERS): return None interp = os.path.basename(tokens[i]) @@ -308,12 +333,21 @@ def _interp_arg_index(tokens): def _position(prefix): - """(is_first_token, is_interpreter_arg) for a reference preceded by prefix.""" + """(is_direct, is_interpreter_arg) for a reference preceded by prefix. + Direct means the reference is what runs: the command's first token, or the + first token of a sh-family `-c` command string (`bash -c "./x.sh"`).""" toks = [t for t in _prefix_tokens(prefix) if t] - if not toks: - return True, False - slot = _interp_arg_index(toks) - return False, slot is not None and slot[0] == len(toks) + while True: + if _command_start(toks) == len(toks): + return True, False + slot = _interp_arg_index(toks) + if slot is None: + return False, False + idx, is_command_string = slot + if is_command_string and idx <= len(toks): + toks = toks[idx:] + continue + return False, idx == len(toks) def is_handler(h): @@ -327,7 +361,7 @@ def is_handler(h): def extract_script_refs(cmd, pkg_root, where): - """Return (kind, relpath, is_first_token, is_interpreter_arg) for each + """Return (kind, relpath, is_direct, is_interpreter_arg) for each package-relative reference apm would rewrite, reading the command exactly as apm does. kind is 'root' for a ${*_PLUGIN_ROOT} token, 'rel' for a ./path, 'up' for a ../path. A token apm reads wrongly — split-quoted, or a @@ -339,6 +373,10 @@ def extract_script_refs(cmd, pkg_root, where): start, end = m.start(), m.end() if end < len(cmd) and cmd[end] in '"\'' and cmd[end + 1:end + 2] in ('/', '\\'): fail(f"script path '{cmd[start:]}' splits the quote after ${{{m.group(1)}}} — apm rewrites only a path that follows the token directly, so this one deploys unrewritten and unbundled; quote the whole token: \"${{PLUGIN_ROOT}}/\" — {where}") + elif cmd[end:end + 1] not in ('/', '\\'): + fail(f"${{{m.group(1)}}} is not followed directly by / or \\ — apm rewrites the token only as the head of a path (${{PLUGIN_ROOT}}/), so here it deploys unrewritten and expands to nothing on most targets — {where}") + for m in UNBRACED_ROOT_RE.finditer(cmd): + fail(f"unbraced {m.group(0)} — apm rewrites only the braced ${{PLUGIN_ROOT}}/ form, so this deploys unrewritten and the script is not bundled; write ${{{m.group(0)[1:]}}}/ — {where}") for m in APM_ROOT_REF_RE.finditer(cmd): start, end = m.start(), m.end() opener = cmd[start - 1] if start > 0 and cmd[start - 1] in '"\'' else None @@ -397,7 +435,10 @@ def check_unanchored_script(cmd, pkg_root, where): toks = command_tokens(cmd) if not toks: return - slots = [0] + start = _command_start(toks) + if start >= len(toks): + return + slots = [start] arg = _interp_arg_index(toks) if arg is not None and arg[0] < len(toks): if arg[1]: @@ -414,7 +455,7 @@ def check_unanchored_script(cmd, pkg_root, where): # In the first slot an extension-less absolute path outside the # package (`/usr/bin/env`, `/bin/bash`) is the host's interpreter; # in the interpreter-argument slot it is the script being run. - if inside or SCRIPT_EXT_RE.search(tok) or idx > 0: + if inside or SCRIPT_EXT_RE.search(tok) or idx > start: fail(f"script '{tok}' is an absolute path — apm neither bundles nor rewrites it, so it breaks on every other machine; reference it as ${{PLUGIN_ROOT}}/ — {where}") continue if '/' in tok: @@ -424,7 +465,7 @@ def check_unanchored_script(cmd, pkg_root, where): break -def check_script(kind_, rel, first, interp_arg, pkg_root, where): +def check_script(kind_, rel, direct, interp_arg, pkg_root, where): if not rel: return # apm's ./ pattern also matches plain arguments — a cwd directory @@ -433,7 +474,7 @@ def check_script(kind_, rel, first, interp_arg, pkg_root, where): # meant, so a ./ or ../ match is held to the script rules only in command # position or when it names a script by extension (or a package entry that # is not a file). - strong = kind_ == 'root' or first or interp_arg or bool(SCRIPT_EXT_RE.search(rel)) + strong = kind_ == 'root' or direct or interp_arg or bool(SCRIPT_EXT_RE.search(rel)) if kind_ == 'up': in_pkg = os.path.exists(os.path.join(pkg_root, rel)) and not os.path.isfile(os.path.join(pkg_root, rel)) if strong or in_pkg: @@ -468,7 +509,7 @@ def check_script(kind_, rel, first, interp_arg, pkg_root, where): else: suggest(f"argument './{rel}' matches apm's ./ script pattern but names no package file — apm will warn 'Hook script not found' and leave it unrewritten; harmless if it is a path in the consumer's working directory — {where}") return - if first and not os.access(found, os.X_OK): + if direct and not os.access(found, os.X_OK): fail(f"script '{rel}' is run directly but is not executable — chmod +x it, or invoke it through an interpreter — {where}") @@ -510,7 +551,10 @@ def package_targets(pkg_root): tokens = {TARGET_ALIASES.get(t, t) for t in tokens if t} if not tokens or 'all' in tokens: return every - return tokens & HOOK_TARGETS + known = tokens & HOOK_TARGETS + if not known: + info(f"targets: in apm.yml names no hook target apm 0.28.0 recognises ({', '.join(sorted(tokens))}) — event names were checked against no harness; apm's hook targets are {', '.join(sorted(HOOK_TARGETS))} — {fname}") + return known def check_event(event, deploys_to): @@ -653,8 +697,8 @@ def audit_hook(): continue if '${CLAUDE_PLUGIN_ROOT}' in cmd: uses_claude_token = True - for kind_, rel, first, interp_arg in extract_script_refs(cmd, pkg_root, where): - check_script(kind_, rel, first, interp_arg, pkg_root, where) + for kind_, rel, direct, interp_arg in extract_script_refs(cmd, pkg_root, where): + check_script(kind_, rel, direct, interp_arg, pkg_root, where) check_unanchored_script(cmd, pkg_root, where) if uses_claude_token: @@ -692,6 +736,28 @@ def split_top_level(value): return [s.strip() for s in segs if s.strip()] +def _balanced(glob): + # A depth walk per bracket kind: a closer before its opener (`}{`) is as + # unbalanced as a missing one, which equal counts would not catch. + for opener, closer in ('{}', '[]'): + depth, i = 0, 0 + while i < len(glob): + c = glob[i] + if c == '\\': + i += 2 + continue + if c == opener: + depth += 1 + elif c == closer: + depth -= 1 + if depth < 0: + return False + i += 1 + if depth: + return False + return True + + def check_apply_to(apply_to): if isinstance(apply_to, list): entries = [e for e in apply_to if e is not None and str(e).strip()] @@ -706,7 +772,7 @@ def check_apply_to(apply_to): return False ok = True for g in globs: - if g.count('{') != g.count('}') or g.count('[') != g.count(']'): + if not _balanced(g): fail(f"applyTo glob '{g}' has unbalanced braces or brackets — it matches nothing, so the rule never fires — {fname}") ok = False return ok @@ -850,7 +916,7 @@ def audit_prompt(): for m in INPUT_REF_RE.finditer(body): if m.group(1) not in used: used.append(m.group(1)) - if used and not declared: + if used and fm.get('input') is None: fail(f"body uses {', '.join('${input:' + u + '}' for u in used)} but no input: is declared — apm rewrites references only when input: names them, so Claude receives the literal text — {fname}") else: for u in used: diff --git a/plugins/kyberforge/.apm/skills/factory-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/factory-audit/scripts/validate.sh index 5d584ec..0917313 100755 --- a/plugins/kyberforge/.apm/skills/factory-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/factory-audit/scripts/validate.sh @@ -133,7 +133,8 @@ the target: hook mode the target is a *.json file directly under a hooks/ directory (.apm/hooks, or a hooks/ at a package root: beside apm.yml or a plugin.json manifest; any other hooks/ - directory is apm's deployed output and FAILs). + directory is deployed output or no package at all, and + FAILs at exit 1). instruction mode the target is a *.instructions.md file. prompt mode the target is a *.prompt.md file. @@ -160,7 +161,8 @@ Exit codes: target does not exist, an unrecognized file extension, a missing references/agent-field-inventory.md, a missing or unreadable lib-*.sh beside this script, or, for a hook, instruction or prompt, a missing - python3 or PyYAML) + python3 or PyYAML, or a crash inside the hook, instruction or prompt + checks) EOF } diff --git a/plugins/kyberforge/.apm/skills/factory-audit/tests/README.md b/plugins/kyberforge/.apm/skills/factory-audit/tests/README.md index 80239e5..7df353d 100644 --- a/plugins/kyberforge/.apm/skills/factory-audit/tests/README.md +++ b/plugins/kyberforge/.apm/skills/factory-audit/tests/README.md @@ -58,7 +58,7 @@ suite must mean a differently named script. Each entry point decides for itself what it was handed. ADR-0025 states the skill and agent rule: a directory containing `SKILL.md` takes the skill flow; an `.agent.md` file, or a file under a directory named `agents/`, takes the agent -flow. `scripts/validate.sh` adds three primitive shapes: a `*.instructions.md` +flow. `scripts/validate.sh` adds the hook, instruction and prompt shapes: a `*.instructions.md` or `*.prompt.md` file takes the instruction or prompt flow wherever it sits, and a `.json` file directly under a `hooks/` directory takes the hook flow. Any shape outside the five is rejected rather than guessed at. Classification could @@ -66,17 +66,17 @@ not be wrong before the merge — each script was hard-wired to one artifact typ — so it is asserted from every side rather than in one place: - the skill-side suites pin the skill-directory classification and the - neither-shape rejection, + no-shape rejection, - the agent-side suites pin the two agent rules *separately* — `.agent.md` in a directory that is not `agents/`, and a plain `.md` under `.apm/agents/` — so that a detector implementing only one of them cannot pass both. Plus a control asserting an agent file never picks up a skill-only gate. - `validate-primitive.bats` pins the hook, instruction and prompt shapes, including the precedence that makes a `*.instructions.md` or `*.prompt.md` - under `agents/` take the primitive flow rather than the agent flow, a hook + under `agents/` take the instruction or prompt flow rather than the agent flow, a hook file under a package-root `hooks/`, and a `.json` outside `hooks/` matching - no shape. It also pins the primitive exit tiers: every negative case asserts - exit 1 (`assert_failure 1`), and a missing python3 or PyYAML asserts exit 2. + no shape. It also pins their exit tiers: every negative case asserts + exit 1 (`assert_failure 1`), and a missing python3 or PyYAML, or a crash inside the checks, asserts exit 2. Both skill-side suites additionally pin the `SKILL.md` **file** path, not just the directory: a pre-commit `files:` hook matches files, so every hook-driven diff --git a/plugins/kyberforge/.apm/skills/factory-audit/tests/validate-primitive.bats b/plugins/kyberforge/.apm/skills/factory-audit/tests/validate-primitive.bats index 3a4b5d3..6ce067f 100644 --- a/plugins/kyberforge/.apm/skills/factory-audit/tests/validate-primitive.bats +++ b/plugins/kyberforge/.apm/skills/factory-audit/tests/validate-primitive.bats @@ -535,6 +535,150 @@ teardown() { refute_output --partial "FAIL" } +@test "hook: an unbraced plugin-root token is a FAIL — apm rewrites only \${TOKEN}/" { + write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"\"$PLUGIN_ROOT/scripts/missing.sh\"","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "unbraced \$PLUGIN_ROOT" + + write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash $CLAUDE_PLUGIN_ROOT/scripts/x.sh","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "unbraced \$CLAUDE_PLUGIN_ROOT" +} + +@test "hook: a braced plugin-root token not followed directly by a path separator is a FAIL" { + write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"cd ${PLUGIN_ROOT} && node index.js","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "\${PLUGIN_ROOT} is not followed directly by / or \\" + + # The split-quote form keeps its own, more specific FAIL and is not double-reported. + write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"\"${PLUGIN_ROOT}\"/.apm/hooks/scripts/check.sh","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "splits the quote" + refute_output --partial "is not followed directly by" +} + +@test "hook: a script run as the first token of a -c command string must be executable" { + chmod -x "$PKG/.apm/hooks/scripts/check.sh" + write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash -c \"${PLUGIN_ROOT}/.apm/hooks/scripts/check.sh\"","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "is run directly but is not executable" + + write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"sh -xc ./scripts/check.sh","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "is run directly but is not executable" + + # Through an interpreter inside the command string, the exec bit is not needed. + write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash -c \"bash ${PLUGIN_ROOT}/.apm/hooks/scripts/check.sh\"","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_success + refute_output --partial "FAIL" +} + +@test "hook: env options and NAME=value assignments are skipped to reach the interpreter's script" { + local cmd + for cmd in 'env FOO=1 bash scripts/check.sh' 'env -i FOO=1 bash scripts/check.sh' 'env -u HOME bash scripts/check.sh' 'FOO=1 bash scripts/check.sh'; do + write_hook hooks.json "{\"hooks\":{\"Stop\":[{\"hooks\":[{\"type\":\"command\",\"command\":\"$cmd\",\"timeout\":5}]}]}}" + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "script 'scripts/check.sh' is a bare relative path" + done +} + +@test "hook: targets: naming no recognised hook target is an INFO, not a silent skip" { + printf 'name: test-package\nversion: 0.1.0\ntargets: [claude-code]\n' > "$PKG/apm.yml" + write_hook hooks.json '{"hooks":{"stop":[{"hooks":[{"type":"command","command":"true","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_success + assert_output --partial "INFO targets: in apm.yml names no hook target apm 0.28.0 recognises (claude-code)" +} + +@test "hook: malformed shapes are FAILs — top level, hooks value, entry and nested hooks" { + write_hook hooks.json '[]' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "top level is not a JSON object" + + write_hook hooks.json '{"hooks":[]}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "'hooks' is not an object" + + write_hook hooks.json '{"hooks":{"Stop":["true"]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "event 'Stop' entry 0 is not an object" + + write_hook hooks.json '{"hooks":{"Stop":[{"hooks":"true"}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "nested 'hooks' is not a list of objects" +} + +@test "hook: an empty event name is a FAIL" { + write_hook hooks.json '{"hooks":{"":[{"hooks":[{"type":"command","command":"true","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "empty event name" +} + +@test "hook: a script path containing \$ or a backtick is a FAIL" { + write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"${PLUGIN_ROOT}/scripts/$X.sh","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "contains '\$' or a backtick" + + write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"${PLUGIN_ROOT}/scripts/`x`.sh","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "contains '\$' or a backtick" +} + +@test "hook: a lowercase event reaching an unlisted harness is judged after apm's rename" { + printf 'name: test-package\nversion: 0.1.0\ntargets: [gemini]\n' > "$PKG/apm.yml" + # Gemini renames preToolUse to BeforeTool: PascalCase after the rename, no finding. + write_hook hooks.json '{"hooks":{"preToolUse":[{"matcher":"x","hooks":[{"type":"command","command":"true","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_success + refute_output --partial "preToolUse" + + # sessionStart is not in Gemini's map, so it reaches Gemini verbatim. + write_hook hooks.json '{"hooks":{"sessionStart":[{"matcher":"x","hooks":[{"type":"command","command":"true","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_success + assert_output --partial "SUGGESTION event 'sessionStart' reaches gemini verbatim" +} + +@test "hook: no apm.yml at the inferred .apm/ package root is an INFO" { + rm "$PKG/apm.yml" + write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"true","timeout":5}]}]}}' + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_success + assert_output --partial "INFO no apm.yml at the inferred package root" +} + +@test "hook, instruction, prompt: a file that is not valid UTF-8 is a FAIL" { + printf '{"hooks":{"Stop":[]}}\xff\n' > "$PKG/.apm/hooks/hooks.json" + run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" + assert_failure 1 + assert_output --partial "not valid UTF-8" + + printf -- '---\ndescription: x\n---\n\nbody \xff\n' > "$PKG/.apm/instructions/x.instructions.md" + run bash "$SCRIPT" "$PKG/.apm/instructions/x.instructions.md" + assert_failure 1 + assert_output --partial "not valid UTF-8" + + printf -- '---\ndescription: x\n---\n\nbody \xff\n' > "$PKG/.apm/prompts/x.prompt.md" + run bash "$SCRIPT" "$PKG/.apm/prompts/x.prompt.md" + assert_failure 1 + assert_output --partial "not valid UTF-8" +} + @test "hook: an unedited primitive-author hook template is an unfilled-placeholder FAIL" { cp "$REPO_ROOT/plugins/kyberforge/.apm/skills/primitive-author/assets/templates/hook.json.template" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" @@ -656,6 +800,45 @@ applyTo: "**/*.py"' 'body' assert_output --partial "unfilled template placeholder 'FILL IN'" } +@test "instruction: no frontmatter block is a FAIL" { + printf 'Just a body.\n' > "$PKG/.apm/instructions/x.instructions.md" + run bash "$SCRIPT" "$PKG/.apm/instructions/x.instructions.md" + assert_failure 1 + assert_output --partial "has no YAML frontmatter block" +} + +@test "instruction: an empty frontmatter block is read as empty, not as a missing block" { + printf -- '---\n---\n\nbody\n' > "$PKG/.apm/instructions/x.instructions.md" + run bash "$SCRIPT" "$PKG/.apm/instructions/x.instructions.md" + assert_failure 1 + refute_output --partial "has no YAML frontmatter block" + assert_output --partial "'description' is missing or empty" +} + +@test "instruction: frontmatter that is not a mapping is a FAIL" { + write_instruction x '- a +- b' 'body' + run bash "$SCRIPT" "$PKG/.apm/instructions/x.instructions.md" + assert_failure 1 + assert_output --partial "frontmatter is not a YAML mapping" +} + +@test "instruction: an applyTo that is neither a string nor a list is a FAIL" { + write_instruction x 'description: x +applyTo: 5' 'body' + run bash "$SCRIPT" "$PKG/.apm/instructions/x.instructions.md" + assert_failure 1 + assert_output --partial "applyTo is neither a string nor a list" +} + +@test "instruction: an applyTo glob closing a brace before opening it is unbalanced" { + write_instruction x 'description: x +applyTo: "src/}{.py"' 'body' + run bash "$SCRIPT" "$PKG/.apm/instructions/x.instructions.md" + assert_failure 1 + assert_output --partial "has unbalanced braces or brackets" +} + # --------------------------------------------------------------------------- # Prompts # --------------------------------------------------------------------------- @@ -684,6 +867,7 @@ input: description: The PR' 'Review ${input:pr_number}.' run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md" assert_failure 1 + assert_output --partial "is one map with several keys — apm reads every key as an argument name" assert_output --partial "yields arguments [name, description]" } @@ -809,6 +993,38 @@ $(printf 'line\n%.0s' $(seq 1 80)) assert_output --partial "unfilled template placeholder 'FILL IN'" } +@test "prompt: body references with input: declared but every name invalid do not claim input: is absent" { + write_prompt review-pr 'description: Review a PR. +input: + - 1pr: "The PR"' 'Review ${input:pr_number}.' + run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md" + assert_failure 1 + refute_output --partial "no input: is declared" + assert_output --partial "input: does not declare 'pr_number'" +} + +@test "prompt: a name that is not a safe path segment is a FAIL" { + printf -- '---\ndescription: x\n---\n\nbody\n' > "$PKG/.apm/prompts/..prompt.md" + run bash "$SCRIPT" "$PKG/.apm/prompts/..prompt.md" + assert_failure 1 + assert_output --partial "is not a safe path segment" +} + +@test "prompt: an input entry that is not a string name, and an input of no known shape, are FAILs" { + write_prompt review-pr 'description: Review a PR. +input: + - 5: "x"' 'Review.' + run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md" + assert_failure 1 + assert_output --partial "input entry 5 is not a string name" + + write_prompt review-pr 'description: Review a PR. +input: 5' 'Review.' + run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md" + assert_failure 1 + assert_output --partial "input is neither a name, a list nor a map" +} + # --------------------------------------------------------------------------- # Never ran (exit 2) # --------------------------------------------------------------------------- @@ -836,3 +1052,14 @@ $(printf 'line\n%.0s' $(seq 1 80)) assert_equal "$status" 2 assert_output --partial "PyYAML is required" } + +@test "exit 2: a crash inside the checks is the never-ran tier, not a verdict" { + write_prompt review-pr 'description: Review a PR.' 'Review the PR.' + local crashyaml="$TMPDIR/crashyaml" + mkdir -p "$crashyaml" + # Importable, so the preflight passes; safe_load raises what no check expects. + printf 'class YAMLError(Exception):\n pass\ndef safe_load(_):\n raise RuntimeError("boom")\n' > "$crashyaml/yaml.py" + PYTHONPATH="$crashyaml" run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md" + assert_equal "$status" 2 + assert_output --partial "the prompt checks crashed (RuntimeError: boom)" +}