Compare commits
4 Commits
3bfdf58960
...
c9fe2e8ab2
| Author | SHA1 | Date | |
|---|---|---|---|
| c9fe2e8ab2 | |||
| ae178a95a2 | |||
| b4f5881973 | |||
| 099cf5846c |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "holocron",
|
||||
"description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"owner": {
|
||||
"name": "Defame1297",
|
||||
"email": "defame1297@rkdr.net",
|
||||
@@ -18,7 +18,7 @@
|
||||
{
|
||||
"name": "bin",
|
||||
"description": "A place for things to be binned",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"category": "Utilities",
|
||||
"source": "./plugins/bin"
|
||||
},
|
||||
|
||||
4
.github/plugin/marketplace.json
vendored
4
.github/plugin/marketplace.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "holocron",
|
||||
"description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"owner": {
|
||||
"name": "Defame1297",
|
||||
"email": "defame1297@rkdr.net",
|
||||
@@ -18,7 +18,7 @@
|
||||
{
|
||||
"name": "bin",
|
||||
"description": "A place for things to be binned",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"category": "Utilities",
|
||||
"source": "./plugins/bin"
|
||||
},
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -44,3 +44,11 @@ apm_modules/
|
||||
# `apm pack` bundle output. The pre-push gate runs pack with --dry-run, so this
|
||||
# only appears after a bare `apm pack` during a release; it is not repo content.
|
||||
build/
|
||||
|
||||
# `apm pack`'s manifest for the *root* package. Emitted beside the marketplace
|
||||
# manifest by a bare `apm pack`, and never tracked on any branch — the repo's
|
||||
# own paths hide it, since sync-plugin-content.sh redirects `apm pack -o` to a
|
||||
# scratch tree and the apm-pack-check-clean pre-push hook runs --dry-run. Scoped
|
||||
# to the file, not the directory: the sibling .claude-plugin/marketplace.json is
|
||||
# compiled output that IS committed and must stay tracked.
|
||||
/.claude-plugin/plugin.json
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"mcpServers": {
|
||||
"obsidian": {
|
||||
"args": [
|
||||
"@bitbonsai/mcpvault@latest",
|
||||
"@bitbonsai/mcpvault@0.15.0",
|
||||
"docs/"
|
||||
],
|
||||
"command": "npx",
|
||||
|
||||
@@ -89,6 +89,15 @@ repos:
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
- id: check-executables-allow-sync
|
||||
name: Check executables allow key sync
|
||||
description: Verify root apm.yml's executables.allow key names kyberforge's actual version -- apm matches that key by exact "<package>#<version>" lookup, so a version bump on one side alone silently stops deploying kyberforge's hooks/ and bin/ and lets the apm install go stale (see ADR-0019)
|
||||
entry: bash scripts/check-executables-allow-sync.sh
|
||||
language: system
|
||||
stages: [pre-push]
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
- id: apm-marketplace-check
|
||||
name: apm marketplace check
|
||||
description: Validate every marketplace.packages[] entry resolves, including network reachability of remote refs -- catches stale/unreachable remote package references that check-manifests.sh deliberately skips (local-source checks only)
|
||||
|
||||
@@ -31,7 +31,7 @@ This repo dogfoods its own plugins. Before shelling out to git, gitea, or lint t
|
||||
- Vale prose linting → `vale-config` / `vale-run`
|
||||
- This repo's own AGENTS.md → `agentsmd-author` / `agentsmd-audit`
|
||||
|
||||
Names are **unnamespaced**. Under the old `claude plugin install` these were `git:git-commits`, `kyberforge:skill-audit`, and so on; `apm install` deploys each skill to `.claude/skills/<name>/` as a plain project skill, which has no plugin prefix to carry. The `<plugin>:` form no longer resolves here — it still does in any project that installs holocron natively, so a skill body written for both audiences should name the bare skill. Same for agents: `git-orchestrate`, not `git:git-orchestrate`.
|
||||
Use the bare, **unnamespaced** names above. Under the old `claude plugin install` these were `git:git-commits`, `kyberforge:skill-audit`, and so on; `apm install` deploys each skill to `.claude/skills/<name>/` as a plain project skill, which has no plugin prefix to carry. The `<plugin>:` form has not stopped resolving here, though — `~/.claude.json` still enables `core`, `git`, `gitea`, `kyberforge`, and `lint` at **user** scope, and ADR-0018 left those native installs in place on purpose, converting them being a separate decision with a blast radius beyond this repo. Every skill is therefore live under both names right now, and a working `gitea:gitea-prs` is the user-scope copy answering — not evidence that the apm install or this file is broken, and not something to "fix". Prefer the bare name anyway: apm deploys it, an external consumer installing holocron through apm gets it, and it is the form that survives those user-scope installs eventually being converted. The namespaced form also still resolves in any project that installs holocron natively, so a skill body written for both audiences should name the bare skill. Same for agents: `git-orchestrate`, not `git:git-orchestrate`.
|
||||
|
||||
Fall back to raw shell only when no skill covers it.
|
||||
|
||||
@@ -40,7 +40,7 @@ Fall back to raw shell only when no skill covers it.
|
||||
- Run `apm install` to deploy this repo's own skills and agents into `.claude/skills/` and `.claude/agents/`. Both are gitignored install output, not authoring source — `plugins/<name>/.apm/` remains the only place to edit. The six dependencies in root `apm.yml` resolve from the holocron **remote**, unpinned against the default branch, so a `.apm/` edit is not visible to the running session until it is pushed and `apm update` re-runs (`apm install` deploys from `apm.lock.yaml` and does not re-resolve refs). Needs the network, and needs `apm_modules/` (which it materializes) left gitignored. `apm install` also configures the `obsidian` MCP server into the repo's `.mcp.json`, carried over from `plugins/bin/.mcp.json`.
|
||||
- Do not add repo-owned keys to `.claude/settings.json`. apm treats that file as its own deployed artifact: `apm audit --ci` replays the install into a scratch tree and diffs, so anything apm would not have written there — an `enabledPlugins` block, a real `hooks` entry — is permanent drift that fails the `apm-audit-ci` pre-push hook. Its committed content is whatever apm last wrote — `{"hooks": {}}` until kyberforge's `SessionStart` hook lands there, after which the merged hook entry is apm's output and belongs in the commit (ADR-0019). What does not change is that nothing repo-authored goes in the file. A hook you want in this repo is authored in `plugins/<name>/.apm/hooks/` and deployed by apm, never hand-written here. Machine-specific settings go in the gitignored `.claude/settings.local.json`, which apm does not deploy and the replay does not compare; shared enforcement belongs in `.pre-commit-config.yaml`.
|
||||
- Keeping the install current is automatic but not free. Because the six dependencies are unpinned, deployed skills go stale whenever anyone merges. kyberforge ships a `SessionStart` hook that runs `apm outdated` at startup (~0.7s) and, when something is behind, runs `apm update --yes` and asks the host to re-scan skills (~10.4s). That rewrites `apm.lock.yaml`, so an unexplained modification to it after opening a session is expected, not a bug — commit or discard it deliberately. Note `apm install` alone will **not** pick up remote changes; it deploys from the lock. `apm update` is the command that re-resolves refs.
|
||||
- Install git hooks via `pc-run`, wiring all three stages — this repo's `.pre-commit-config.yaml` has no `default_install_hook_types`, so a plain install silently skips `commit-msg` (Conventional Commits) and `pre-push` (the 13-hook gate described below).
|
||||
- Install git hooks via `pc-run`, wiring all three stages — this repo's `.pre-commit-config.yaml` has no `default_install_hook_types`, so a plain install silently skips `commit-msg` (Conventional Commits) and `pre-push` (the 14-hook gate described below).
|
||||
- Install the `apm` CLI — four pre-push hooks shell out to it: `apm-marketplace-check`, `apm-audit-ci`, `apm-pack-check-clean`, and `check-plugin-content-sync` (via `scripts/sync-plugin-content.sh`, which wraps `apm pack`). `apm-marketplace-check` and `apm-pack-check-clean` are bare `apm …` hook entries and `apm-audit-ci` is a `bash -c` loop calling `apm` once per package, so without it the push dies with an unhelpful "command not found". Use `apm-install`, or `curl -sSL https://aka.ms/apm-unix | sh`; verify with `apm --version`.
|
||||
- Install `jq` — required by `scripts/check-manifests.sh` and `scripts/sync-plugin-content.sh`, both pre-push. These at least fail loudly (`Error: jq is required but not installed`).
|
||||
- Install the `vale` binary — required by the `vale-audit-prefilter-skill`/`-agent` pre-commit hooks. Their `files:` patterns are `.apm/`-scoped: `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$` and `^plugins/[^/]+/\.apm/agents/[^/]+\.agent\.md$`. Only the authoring source triggers them — a `SKILL.md` in the generated mirror matches neither pattern, so prose findings surface only when you edit the file you are supposed to be editing. Without the binary the hooks fail with a bare "command not found" and no install pointer. `brew install vale` (macOS), `snap install vale` (Linux), `choco install vale` (Windows), or see https://vale.sh/docs/vale-cli/installation/. No `vale sync` needed — the `Kyberforge` styles are committed under `plugins/kyberforge/.apm/skills/{skill-audit,agent-audit}/assets/vale/styles/`, not downloaded packages (see ADR-0014).
|
||||
@@ -48,10 +48,10 @@ Fall back to raw shell only when no skill covers it.
|
||||
- Run `bash tests/run-tests.sh` before considering any change done — it runs every `test-*.sh` script in the repo plus the bats suite (`--bats-only` for just bats). First run auto-initializes the bats submodules; no manual `git submodule update` needed.
|
||||
- A suite that exits 77 because a dependency is missing is reported as SKIPPED, and does **not** fail an ad-hoc run. The pre-push hook invokes the same script as `--strict` (`RUN_TESTS_STRICT=1` is equivalent), where a skip **does** fail the push: at pre-push a skip means one of the dependencies above is absent on this machine, so the gate would otherwise report success having run fewer suites than it appears to. Without vale, for instance, three suites skip (`test-check-vale-style-sync.sh`, `test-vale-hooks-consumer.sh`, `test-vale-wrap.sh`) and the strict failure names each one and what to install.
|
||||
- `tests/run-bats.sh` derives the set of `.bats` files it expects from `git ls-files`, so a `.bats` file deleted from the worktree but still tracked in the index fails the run rather than silently shrinking the suite. Remove one with `git rm` (or stage the deletion) when the removal is intentional; an untracked new `.bats` file is picked up and needs no ceremony. Both discovery walks (`tests/run-bats.sh` and `tests/run-tests.sh`) exclude `apm_modules/`: `apm install` materializes a full copy of every plugin there, and running a dependency's copy of a `.bats` file breaks its relative path to the bats helpers — 167 spurious failures before the exclusion landed.
|
||||
- Pushing runs 13 repo-defined pre-push hooks, not just the test suite — `run-tests` and `check-manifests`, plus generated-content drift gates (`check-plugin-content-sync`, `check-marketplace-mirror-sync`, `check-vale-style-sync`, `check-scope-walkup-sync`), artifact validators (`check-apm-agents-valid`, which runs agent-audit's `validate.sh` over every real `plugins/*/.apm/agents/*.agent.md`), apm's own gates (`apm-marketplace-check`, `apm-audit-ci`, `apm-pack-check-clean`), host validators (`validate-plugins`, `validate-marketplace`, both needing the `claude` CLI), and `check-release-needed`. Run `pre-commit run --hook-stage pre-push --all-files` locally — one command, the whole gate. That command reports **15**, not 13: pre-commit's own `meta` hooks, `check-hooks-apply` and `check-useless-excludes`, declare no `stages:` and so run at every stage including this one.
|
||||
- Pushing runs 14 repo-defined pre-push hooks, not just the test suite — `run-tests` and `check-manifests`, plus generated-content drift gates (`check-plugin-content-sync`, `check-marketplace-mirror-sync`, `check-vale-style-sync`, `check-scope-walkup-sync`, `check-executables-allow-sync`), artifact validators (`check-apm-agents-valid`, which runs agent-audit's `validate.sh` over every real `plugins/*/.apm/agents/*.agent.md`), apm's own gates (`apm-marketplace-check`, `apm-audit-ci`, `apm-pack-check-clean`), host validators (`validate-plugins`, `validate-marketplace`, both needing the `claude` CLI), and `check-release-needed`. `check-executables-allow-sync` is the odd one in that first group — it guards a silent failure rather than drift in generated text. apm gates a package's `hooks/` and `bin/` on an exact `<package>#<version>` lookup in root `apm.yml`'s `executables.allow`, with no wildcard and no version-less form, so bumping `plugins/kyberforge/apm.yml`'s `version:` without bumping the key errors nowhere: the entry simply stops matching, kyberforge's `SessionStart` hook stops deploying, and the install goes quietly stale — the failure ADR-0019 records as live. Run `pre-commit run --hook-stage pre-push --all-files` locally — one command, the whole gate. That command reports **16**, not 14: pre-commit's own `meta` hooks, `check-hooks-apply` and `check-useless-excludes`, declare no `stages:` and so run at every stage including this one.
|
||||
- `apm-audit-ci` runs `apm audit --ci` once per manifest — the root one and each of the six plugin packages — because the root-only invocation audits the marketplace manifest and **nothing else**, and `apm-pack-check-clean` does not parse plugin `dependencies:` blocks either (verified: a malformed one passes `apm pack --check-versions --check-clean --dry-run` and fails `apm audit --ci` in that package's directory). It verifies two things and claims no more: each `apm.yml` parses as a valid APM manifest, and any package declaring dependencies has a consistent `apm.lock.yaml`. It does **not** enforce an org policy — apm discovers one from the git remote and only understands github.com and Azure DevOps, so against this repo's self-hosted Gitea remote it prints `No org policy found at unknown; enforcement skipped`. Do **not** "fix" that with `policy.fetch_failure_default: block` in `apm.yml`: it was tested and rejected, because with no reachable policy source it makes the hook exit 1 on every push forever.
|
||||
- `check-apm-agents-valid` derives its expected agent-file set from `git ls-files` (same pattern as `tests/run-bats.sh`), so an agent file deleted from the worktree but still tracked fails the run, and discovering zero agent files is an error rather than a pass. An untracked new agent file is still validated — the derivation is one-directional on purpose, so uncommitted work is not blocked but also cannot bypass the gate.
|
||||
- **Two** pre-push hooks need the network, for one shared reason: root `apm.yml`'s `marketplace.packages[]` contains exactly one remote entry (`mattpocock-skills`, `source: mattpocock/skills`), and resolving it needs a `git ls-remote`. `apm-marketplace-check` resolves every entry and is `always_run`, so it fails with `No cached refs (offline)`. `apm-pack-check-clean` (`apm pack --check-versions --check-clean --dry-run`) re-resolves the same entry and fails with `Error: Git network timeout during ls-remote`. Pinning the entry to an exact version does **not** remove the call — an exact pin still ls-remotes. `--offline` rescues neither. To push without a network, skip both using pre-commit's own mechanism: `SKIP=apm-marketplace-check,apm-pack-check-clean git push`. Skip those two alone — verified under `unshare -rn`, the other eleven pre-push hooks pass offline because they are real local checks, and adding one of them to `SKIP` disarms it silently. `apm-audit-ci` calls `apm` too but stays local: its org-policy discovery resolves nothing on this remote before any network call, so it does not join the pair above.
|
||||
- **Two** pre-push hooks need the network, for one shared reason: root `apm.yml`'s `marketplace.packages[]` contains exactly one remote entry (`mattpocock-skills`, `source: mattpocock/skills`), and resolving it needs a `git ls-remote`. `apm-marketplace-check` resolves every entry and is `always_run`, so it fails with `No cached refs (offline)`. `apm-pack-check-clean` (`apm pack --check-versions --check-clean --dry-run`) re-resolves the same entry and fails with `Error: Git network timeout during ls-remote`. Pinning the entry to an exact version does **not** remove the call — an exact pin still ls-remotes. `--offline` rescues neither. To push without a network, skip both using pre-commit's own mechanism: `SKIP=apm-marketplace-check,apm-pack-check-clean git push`. Skip those two alone — verified under `unshare -rn`, the other twelve pre-push hooks pass offline because they are real local checks (`check-executables-allow-sync` landed after that run, but reads two local manifests and makes no network call), and adding one of them to `SKIP` disarms it silently. `apm-audit-ci` calls `apm` too but stays local: its org-policy discovery resolves nothing on this remote before any network call, so it does not join the pair above.
|
||||
- Author commits with `git-commits` — it validates Conventional Commits (enforced at `commit-msg`) for you.
|
||||
|
||||
## Key documents
|
||||
|
||||
@@ -33,10 +33,10 @@ The deployable unit in the plugin marketplace. A plugin bundles one or more skil
|
||||
A Git repository with a `marketplace.json` manifest listing installable plugins. No backend, registry, or SaaS required — the Git repo is the marketplace. This repo is the `holocron` marketplace. The manifest at `.claude-plugin/marketplace.json` (read by both Claude Code and Copilot CLI) is **compiled output** of `apm pack`, generated from the root `apm.yml`'s `marketplace:` block (owner, build/output config, versioning strategy, and the `packages:` list of installable plugins) — it is not hand-edited. See ADR-0015. `.github/plugin/marketplace.json` is Copilot CLI's legacy manifest path; apm has no output profile for it (only `claude` and `codex`, and `codex`'s is a differently-shaped file at `.agents/plugins/marketplace.json`), so `scripts/sync-marketplace-mirror.sh` keeps it byte-identical to `.claude-plugin/marketplace.json`, checked at pre-push. Each listed package's `source:` still points at that plugin's own `plugins/<name>/` root, not at an `apm pack` build artifact — which is why that root also carries the flat `agents/`/`skills/`/`commands/`/`hooks/hooks.json` content mirror described under "Plugin" (ADR-0017): without it, an install from this marketplace finds a valid manifest but no discoverable content.
|
||||
|
||||
### apm-consumed install
|
||||
How this repo installs its own plugins, as of 2026-08-14: not `claude plugin install <name>@holocron`, but six `dependencies.apm` entries in the root `apm.yml`, each a `git:`/`path:` object against the holocron remote, deployed by `apm install` into `.claude/skills/` and `.claude/agents/`. Project scope only — nothing is installed at user scope, so the switch is contained to this repo and any other repo opts in by declaring its own dependencies. The git+path object form is deliberate over the shorter `<name>@holocron` marketplace alias: an alias must first be registered with `apm marketplace add`, which writes to `~/.apm/marketplaces.json` (user scope, outside the repo), whereas the object form needs nothing beyond the committed manifest and so survives a fresh clone.
|
||||
How this repo installs its own plugins, as of 2026-08-14: not `claude plugin install <name>@holocron`, but six `dependencies.apm` entries in the root `apm.yml`, each a `git:`/`path:` object against the holocron remote, deployed by `apm install` into `.claude/skills/` and `.claude/agents/`. Project scope only — apm installs nothing at user scope, so the switch is contained to this repo and any other repo opts in by declaring its own dependencies. The git+path object form is deliberate over the shorter `<name>@holocron` marketplace alias: an alias must first be registered with `apm marketplace add`, which writes to `~/.apm/marketplaces.json` (user scope, outside the repo), whereas the object form needs nothing beyond the committed manifest and so survives a fresh clone.
|
||||
|
||||
Four consequences, each load-bearing:
|
||||
- **Skills lose their namespace.** apm deploys plain project skills, so `git:git-commits` is now `git-commits`. The `<plugin>:` form does not resolve in this repo. It still does wherever holocron is installed natively, so cross-audience skill bodies should use the bare name.
|
||||
- **Skills gain an unnamespaced name.** apm deploys plain project skills, so `git:git-commits` also answers to `git-commits`. The `<plugin>:` form has not stopped resolving here: `~/.claude.json` still enables `core`, `git`, `gitea`, `kyberforge`, and `lint` at user scope, which ADR-0018 left in place deliberately — converting them is a separate decision with a blast radius beyond this repo. Until it is taken, every skill is live under two names, which is the same "present twice under two names" outcome ADR-0018's own "Alternatives considered" rejected for *keeping both install paths* — reached here by leaving user scope alone rather than by adopting it as the install model. Write the bare name regardless: apm deploys it, and a repo consuming holocron through apm gets only that form. The namespaced form still resolves wherever holocron is installed natively, so cross-audience skill bodies should use the bare name.
|
||||
- **apm owns `.claude/settings.json`.** `apm audit --ci` (an `apm-audit-ci` pre-push hook) replays the install into a scratch tree and diffs it against the worktree, so any key apm would not have written is permanent drift. Committed content is exactly `{"hooks": {}}`; repo-owned settings have nowhere to live in that file.
|
||||
- **Install output is gitignored.** `.claude/skills/`, `.claude/agents/`, and `apm_modules/` are all regenerated by `apm install`. `apm.lock.yaml` and the generated `.mcp.json` are committed. Committing the deployed skills would add a third mirror of the same content to the two ADR-0017 already governs.
|
||||
- **Test discovery must skip `apm_modules/`.** It holds a full copy of every plugin, `.bats` files included; both `tests/run-bats.sh` and `tests/run-tests.sh` exclude it.
|
||||
|
||||
6
apm.yml
6
apm.yml
@@ -1,5 +1,5 @@
|
||||
name: holocron
|
||||
version: 0.4.0
|
||||
version: 0.4.1
|
||||
description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.
|
||||
license: MIT
|
||||
|
||||
@@ -52,7 +52,7 @@ marketplace:
|
||||
# top-level apm.yml description:/version: above are NOT inherited into the
|
||||
# compiled output despite being used elsewhere (e.g. by `apm audit`).
|
||||
description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.
|
||||
version: 0.4.0
|
||||
version: 0.4.1
|
||||
owner:
|
||||
name: Defame1297
|
||||
email: defame1297@rkdr.net
|
||||
@@ -85,7 +85,7 @@ marketplace:
|
||||
- name: bin
|
||||
description: A place for things to be binned
|
||||
source: ./plugins/bin
|
||||
version: 1.1.2
|
||||
version: 1.1.3
|
||||
category: Utilities
|
||||
|
||||
- name: git
|
||||
|
||||
@@ -54,12 +54,23 @@ Three sub-decisions inside that:
|
||||
|
||||
## Consequences
|
||||
|
||||
**Skills lose their namespace.** apm deploys plain project skills, so `git:git-commits` is now
|
||||
`git-commits` and `kyberforge:skill-audit` is `skill-audit`. This is not configurable — a project
|
||||
skill has no plugin to prefix. Every `<plugin>:<skill>` reference in the repo's own instructions
|
||||
was stale the moment the switch landed; `AGENTS.md` and `CONTEXT.md` are updated. The namespaced
|
||||
form still resolves for anyone installing holocron natively, so skill bodies written for both
|
||||
audiences should name the bare skill.
|
||||
**Skills gain an unnamespaced name.** apm deploys plain project skills, so `git:git-commits` also
|
||||
answers to `git-commits` and `kyberforge:skill-audit` to `skill-audit`. This is not configurable —
|
||||
a project skill has no plugin to prefix. `AGENTS.md` and `CONTEXT.md` are updated to name the bare
|
||||
form, which is what apm deploys and the only form a repo consuming holocron through apm gets.
|
||||
|
||||
**Correction (2026-08-14): the namespaced form did not stop resolving.** An earlier revision of
|
||||
this consequence said every `<plugin>:<skill>` reference "was stale the moment the switch landed",
|
||||
and `AGENTS.md`/`CONTEXT.md` were written to match. That contradicts the "User scope is untouched,
|
||||
deliberately" consequence below, and the contradiction resolves against it: `~/.claude.json` still
|
||||
enables `core`, `git`, `gitea`, `kyberforge` and `lint` at user scope, so both names are live at
|
||||
once and a working `gitea:gitea-prs` is the user-scope copy answering. That doubling is the same
|
||||
"present twice under two names" outcome the "Keeping both install paths" alternative was rejected
|
||||
for — reached by leaving user scope alone rather than by adopting it, which is why it is a
|
||||
consequence to record rather than a decision to revisit. Prefer the bare name regardless: it
|
||||
survives those user-scope installs eventually being converted, and the namespaced form still
|
||||
resolves for anyone installing holocron natively, so skill bodies written for both audiences
|
||||
should name the bare skill.
|
||||
|
||||
**apm owns `.claude/settings.json`.** (ADR-0019 supersedes the "exactly `{"hooks": {}}`" claim
|
||||
below — once a package ships a hook, apm merges it into that file and the merged entry is apm's own
|
||||
|
||||
@@ -70,8 +70,31 @@ this machine still has it open.
|
||||
immediately, since bumping kyberforge to 1.5.0 required editing the key in the same commit. A
|
||||
kyberforge version bump makes the entry stop matching, the
|
||||
gate blocks the hook, and the install silently stops refreshing — the exact failure this ADR exists
|
||||
to end, reintroduced through the mechanism meant to secure it. The `executables:` block carries a
|
||||
comment saying to check it first when skills go stale after a release.
|
||||
to end, reintroduced through the mechanism meant to secure it.
|
||||
|
||||
Matching is an exact dictionary lookup on the composed `name#version` string
|
||||
(`apm_cli/security/executables.py`, `is_package_approved`), so there is no wildcard or
|
||||
version-less key that would sidestep this — the key has to be edited on every bump, and the
|
||||
question is only what catches a missed edit. A comment in the `executables:` block is not enough:
|
||||
this repo gates generated-content drift, marketplace mirror drift and vale style drift
|
||||
deterministically, and a silent-staleness failure is strictly worse than any of them. So
|
||||
`scripts/check-executables-allow-sync.sh` runs at pre-push, parsing `version:` out of
|
||||
`plugins/kyberforge/apm.yml` and asserting root `apm.yml` carries the matching
|
||||
`kyberforge#<version>` key. The comment stays as the human-facing pointer; the hook is what
|
||||
actually holds. It parses with PyYAML where importable and falls back to a two-shape scan
|
||||
otherwise, so a missing pip package cannot become the thing that blocks every push.
|
||||
|
||||
**Trust is keyed on the version, not on the content.** `kyberforge#1.5.0` approves whatever
|
||||
`check-apm-current.sh` contains at the moment it is fetched, not the bytes that were reviewed when
|
||||
the key was written. Because the dependency is unpinned against the default branch and the hook
|
||||
runs `apm update --yes` unattended, an edit to that script landing on `main` deploys and executes
|
||||
on every contributor's machine at their next session start, with no second approval prompt and no
|
||||
diff shown. The trust gate constrains *which package* may ship an executable; it does not constrain
|
||||
what that executable does between version bumps. That is an accepted property of this design rather
|
||||
than an oversight — the remote is self-hosted, push access to `main` is already sufficient to
|
||||
change any skill body an agent will follow — but it is the reason the gate should not be read as a
|
||||
supply-chain control. Pinning each dependency to a `ref:` is what would make it one, and ADR-0018
|
||||
defers that until per-package release tags exist.
|
||||
|
||||
**A referenced hook script must be addressed at its `.apm/` path.** apm resolves
|
||||
`${CLAUDE_PLUGIN_ROOT}/...` against the installed package root, and `apm pack` keeps only `*.json`
|
||||
@@ -84,7 +107,30 @@ A test pins the reference.
|
||||
|
||||
**Session startup gets slower when the install is stale.** Measured: ~0.7 s for the `apm outdated`
|
||||
check when everything is current, ~10.4 s when six packages are behind and the refresh runs. The
|
||||
hook declares `timeout: 320` to cover a cold multi-package fetch.
|
||||
hook declares `timeout: 380` to cover a cold multi-package fetch. That number is not free-standing:
|
||||
the script imposes its own `timeout 60` on `apm outdated` and `timeout 300` on `apm update`, so the
|
||||
host-side timeout has to exceed their sum or the host kills the hook mid-update and leaves
|
||||
`.claude/skills/` half-deployed with no notice emitted. An earlier revision declared `320`, which
|
||||
was below the 360 s the script can legitimately take. A test asserts the invariant rather than the
|
||||
literal — it parses every `timeout N` out of the script, sums them, and requires the `hooks.json`
|
||||
value to be larger — so changing either side without the other fails the suite.
|
||||
|
||||
**Reading a human-readable CLI for a control decision cost a silent failure, again.** `apm outdated`
|
||||
has no `--json` or other machine-readable flag (confirmed against 0.28.0), so the hook must match
|
||||
its prose. The first attempt matched `outdated dependencies found` — plural only. apm emits
|
||||
`1 outdated dependency found` in the singular when exactly one package is behind
|
||||
(`apm_cli/commands/outdated.py`), so a single stale package was invisible: the hook exited 0
|
||||
silently and no refresh ran. With six packages merging independently, one-behind is the ordinary
|
||||
case rather than an edge, which means the mechanism failed most often in exactly the situation it
|
||||
exists for. The match is now `outdated dependenc(y|ies) found`.
|
||||
|
||||
The deeper lesson is the one `post-push` already taught and this repeated: every assertion about the
|
||||
hook mocked `apm`, so the suite was green while the hook could not detect the common case. Mocks
|
||||
verify the code against its author's belief about the interface, never the interface. The suite now
|
||||
carries one probe that stages a genuinely outdated dependency against a local git remote — offline,
|
||||
via `url.<path>.insteadOf`, so the twelve-hooks-pass-under-`unshare -rn` property survives — runs
|
||||
the real `apm outdated`, and replays its genuine output through the real hook. Reverting the grep
|
||||
to plural-only fails it.
|
||||
|
||||
**The hook cannot install itself.** Dependencies resolve from the remote, so the hook does not
|
||||
deploy until this change is merged and `apm update` has run once against the new default branch.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bin",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"description": "A place for things to be binned",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
@@ -20,7 +20,7 @@
|
||||
"mcpServers": {
|
||||
"obsidian": {
|
||||
"args": [
|
||||
"@bitbonsai/mcpvault@latest",
|
||||
"@bitbonsai/mcpvault@0.15.0",
|
||||
"docs/"
|
||||
],
|
||||
"command": "npx",
|
||||
|
||||
2
plugins/bin/.github/plugin/plugin.json
vendored
2
plugins/bin/.github/plugin/plugin.json
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bin",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"description": "A place for things to be binned",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"mcpServers": {
|
||||
"obsidian": {
|
||||
"args": [
|
||||
"@bitbonsai/mcpvault@latest",
|
||||
"@bitbonsai/mcpvault@0.15.0",
|
||||
"docs/"
|
||||
],
|
||||
"command": "npx",
|
||||
|
||||
@@ -33,7 +33,7 @@ copilot plugin install ./plugins/bin
|
||||
| Component | Path | Description |
|
||||
|---|---|---|
|
||||
| Skills | `.apm/skills/` → `skills/` | Slash commands available after install |
|
||||
| MCP servers | `.mcp.json` | The `obsidian` server (`npx @bitbonsai/mcpvault@latest docs/`), hand-authored at the plugin root |
|
||||
| MCP servers | `.mcp.json` | The `obsidian` server (`npx @bitbonsai/mcpvault@0.15.0 docs/`), hand-authored at the plugin root |
|
||||
|
||||
`.apm/` is the authoring source; `skills/` is the generated mirror plugin hosts scan (ADR-0017). This plugin ships no agents. It is the only plugin here with a non-empty `.mcp.json`, which is why its compiled manifests are the only ones carrying an `mcpServers` block.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: bin
|
||||
version: 1.1.2
|
||||
version: 1.1.3
|
||||
description: A place for things to be binned
|
||||
author:
|
||||
name: Defame1297
|
||||
|
||||
@@ -13,18 +13,36 @@
|
||||
# Inert in any project that does not consume packages through apm.
|
||||
set -uo pipefail
|
||||
|
||||
# Anchor on the project root, not the session's cwd. Claude Code exports
|
||||
# CLAUDE_PROJECT_DIR for SessionStart hooks; a session opened in a subdirectory
|
||||
# would otherwise miss the lockfile, no-op silently, and — worse — run the apm
|
||||
# calls below against that wrong directory. Fall back to the cwd when the
|
||||
# variable is absent, which keeps the hook inert-but-harmless under a host that
|
||||
# does not set it.
|
||||
project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
|
||||
# No lockfile means nothing was installed through apm here — e.g. a host that
|
||||
# installed this plugin natively. Say nothing and cost nothing.
|
||||
[[ -f apm.lock.yaml ]] || exit 0
|
||||
[[ -f "$project_dir/apm.lock.yaml" ]] || exit 0
|
||||
command -v apm > /dev/null 2>&1 || exit 0
|
||||
|
||||
# Every apm call below must see the same directory the guard just checked —
|
||||
# `apm outdated` and `apm update` both resolve the lockfile from the cwd.
|
||||
cd "$project_dir" || exit 0
|
||||
|
||||
# `apm outdated` exits 0 whether or not anything is stale, so the answer has to
|
||||
# come from its output. ~0.7s against six remote refs; a hung remote must not
|
||||
# hold the session open.
|
||||
#
|
||||
# There is no --json/machine-readable flag on `apm outdated` (verified against
|
||||
# apm 0.28.0), so the phrase match is forced rather than chosen. Note the
|
||||
# singular: apm prints "1 outdated dependency found" when exactly one package is
|
||||
# behind, so matching only "dependencies" would silently miss a one-package
|
||||
# drift. tests/test-apm-current-hook.sh pins both spellings against the real apm.
|
||||
outdated_output="$(timeout 60 apm outdated 2>&1)" || exit 0
|
||||
grep -q "outdated dependencies found" <<< "$outdated_output" || exit 0
|
||||
grep -qE 'outdated dependenc(y|ies) found' <<< "$outdated_output" || exit 0
|
||||
|
||||
stale_count="$(grep -oE '[0-9]+ outdated dependencies found' <<< "$outdated_output" | grep -oE '^[0-9]+' || true)"
|
||||
stale_count="$(grep -oE '[0-9]+ outdated dependenc(y|ies) found' <<< "$outdated_output" | grep -oE '^[0-9]+' || true)"
|
||||
[[ "$stale_count" =~ ^[0-9]+$ ]] || stale_count="some"
|
||||
|
||||
# Only ever emit fixed text plus a digit-checked count — never interpolate
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh",
|
||||
"timeout": 320,
|
||||
"timeout": 380,
|
||||
"type": "command"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -83,9 +83,33 @@ Note that apm's **executable-trust gate is off** unless the consuming project's
|
||||
|
||||
`check-apm-current.sh` keeps an apm-consumed install level with its remote: it runs `apm outdated`,
|
||||
and if anything is behind, runs `apm update --yes` and returns `reloadSkills: true` so the running
|
||||
session picks up the redeployed content. It exits silently when there is no `apm.lock.yaml` in the
|
||||
working directory, which makes it inert for any host that installed this plugin natively rather than
|
||||
through apm. Rationale, measurements, and the failure modes are in ADR-0019.
|
||||
session picks up the redeployed content. Rationale, measurements, and the failure modes are in
|
||||
ADR-0019.
|
||||
|
||||
**Where it looks for the lockfile.** The hook resolves a project directory as `${CLAUDE_PROJECT_DIR}`
|
||||
when the host exports it (Claude Code does, for SessionStart hooks) and the current directory
|
||||
otherwise, then exits silently unless that directory holds an `apm.lock.yaml` — which is what makes
|
||||
it inert for any host that installed this plugin natively rather than through apm. Both `apm`
|
||||
invocations run against the same resolved directory. The earlier spelling checked a bare
|
||||
`apm.lock.yaml` against the session's cwd, so a session opened in a subdirectory of an
|
||||
apm-consuming repo no-opped silently. Keep the cwd fallback: a host that sets no
|
||||
`CLAUDE_PROJECT_DIR` must still get inert-but-harmless behaviour, not an unset-variable error.
|
||||
|
||||
**The `timeout` in `hooks.json` must exceed the script's own budget.** The script spends at most
|
||||
`timeout 60 apm outdated` plus `timeout 300 apm update`; the hook entry declares `timeout: 380`, the
|
||||
sum plus a buffer. Set it lower and a slow remote gets the hook SIGKILLed mid-`apm update`, leaving a
|
||||
partially redeployed `.claude/skills/` and emitting no notice — precisely the silent failure the hook
|
||||
exists to prevent. `tests/test-apm-current-hook.sh` pins the relationship (host timeout > sum of the
|
||||
script's internal timeouts) rather than the literal, so raising either side alone fails the suite.
|
||||
|
||||
**Staleness is detected by matching apm's summary line, and both spellings count.** `apm outdated`
|
||||
has no `--json` or otherwise machine-readable output (verified against apm 0.28.0), so the hook
|
||||
greps its text. apm prints `1 outdated dependency found` in the singular when exactly one package is
|
||||
behind and `N outdated dependencies found` otherwise; matching only the plural silently misses a
|
||||
one-package drift. Because a mocked `apm` would keep a reworded release invisible, the test suite
|
||||
stages a genuinely outdated dependency against the **real** `apm` — a local git repo reached through
|
||||
`url.<path>.insteadOf` rewrites, so it needs no network — and replays that genuine output through the
|
||||
hook.
|
||||
|
||||
## GitHub Copilot CLI
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh",
|
||||
"timeout": 320,
|
||||
"timeout": 380,
|
||||
"type": "command"
|
||||
}
|
||||
],
|
||||
|
||||
231
scripts/check-executables-allow-sync.sh
Executable file
231
scripts/check-executables-allow-sync.sh
Executable file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Fails the push when root apm.yml's executables.allow key stops naming
|
||||
# kyberforge's actual version.
|
||||
#
|
||||
# apm gates a package's hooks/ and bin/ on an EXACT dict lookup of
|
||||
# "<package>#<version>" in executables.allow (apm_cli/security/executables.py,
|
||||
# `allow_executables.get(package_key)`) — there is no wildcard and no
|
||||
# version-less form. So bumping plugins/kyberforge/apm.yml's `version:` without
|
||||
# bumping the key in root apm.yml does not error anywhere: the entry simply
|
||||
# stops matching, kyberforge's SessionStart hook stops deploying, and the apm
|
||||
# install goes quietly stale — the exact failure ADR-0019 records as live and
|
||||
# mitigates only with a comment. Nothing else in the pre-push gate compares the
|
||||
# two files, which is why this exists.
|
||||
#
|
||||
# Run from repo root or pass REPO_ROOT as arg.
|
||||
|
||||
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
||||
# A nonexistent REPO_ROOT fails loudly rather than falling through to the
|
||||
# "no kyberforge plugin here" no-op below: that no-op is for a repo that
|
||||
# legitimately does not ship the plugin, not for a typo'd or stale path, and
|
||||
# exit 0 would read as "checked, in sync" when nothing was compared at all.
|
||||
if [[ ! -d "$REPO_ROOT" ]]; then
|
||||
echo "FAIL: executables-allow sync check: REPO_ROOT '$REPO_ROOT' is not a directory." >&2
|
||||
echo " Fix: run this from the repo root, or pass a real repo path as the first argument." >&2
|
||||
exit 1
|
||||
fi
|
||||
REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
|
||||
|
||||
PLUGIN_DIR="$REPO_ROOT/plugins/kyberforge"
|
||||
PLUGIN_MANIFEST="$PLUGIN_DIR/apm.yml"
|
||||
ROOT_MANIFEST="$REPO_ROOT/apm.yml"
|
||||
|
||||
# Only a repo with no kyberforge plugin at all is a legitimate no-op — there is
|
||||
# no version to pin and no hook to deploy.
|
||||
if [[ ! -d "$PLUGIN_DIR" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "$PLUGIN_MANIFEST" ]]; then
|
||||
echo "FAIL: plugins/kyberforge/ exists but has no apm.yml, so its version cannot be read." >&2
|
||||
echo " Why: this gate compares that version against root apm.yml's executables.allow key;" >&2
|
||||
echo " without the manifest it would exit 0 having compared nothing." >&2
|
||||
echo " Fix: restore plugins/kyberforge/apm.yml, or remove plugins/kyberforge/ entirely." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ROOT_MANIFEST" ]]; then
|
||||
echo "FAIL: root apm.yml is missing, so kyberforge's executables allow key cannot be verified." >&2
|
||||
echo " Why: apm reads executables.allow from the consuming package's manifest; with no root" >&2
|
||||
echo " manifest nothing grants kyberforge's hooks/ and bin/ permission to deploy." >&2
|
||||
echo " Fix: restore apm.yml at the repo root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest facts
|
||||
# ---------------------------------------------------------------------------
|
||||
# Both readers emit the same line protocol, so the rest of the script does not
|
||||
# care which ran:
|
||||
#
|
||||
# v<TAB><kyberforge version> exactly once
|
||||
# a<TAB>present|absent exactly once — is executables.allow a mapping?
|
||||
# k<TAB><allow key> zero or more
|
||||
#
|
||||
# python3 + PyYAML is preferred where importable, because it is a real parser.
|
||||
# It is deliberately NOT a hard requirement: no other hook in this repo's
|
||||
# pre-push gate needs PyYAML, and making a version-pin check the one thing that
|
||||
# can block every push on a missing pip package is a worse failure than reading
|
||||
# two known shapes by hand. The fallback below is not a YAML parser — it
|
||||
# recognises exactly the two shapes these manifests use and nothing else.
|
||||
|
||||
read_facts_python() {
|
||||
python3 - "$ROOT_MANIFEST" "$PLUGIN_MANIFEST" << 'PY'
|
||||
import sys
|
||||
import yaml
|
||||
|
||||
|
||||
def load(path):
|
||||
with open(path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
root, plugin = load(sys.argv[1]), load(sys.argv[2])
|
||||
|
||||
version = plugin.get("version")
|
||||
# A 2-component version parses as a YAML float and would render back as e.g.
|
||||
# "1.5" from 1.50 — a mismatch invented by the reader rather than found in the
|
||||
# files. Refuse instead of guessing; semver keys here are always strings.
|
||||
if version is not None and not isinstance(version, str):
|
||||
sys.stderr.write(
|
||||
"kyberforge's version: is not a string (%r) — quote it so the "
|
||||
"executables.allow key can be compared verbatim.\n" % (version,)
|
||||
)
|
||||
raise SystemExit(2)
|
||||
print("v\t%s" % ("" if version is None else version))
|
||||
|
||||
executables = root.get("executables")
|
||||
allow = executables.get("allow") if isinstance(executables, dict) else None
|
||||
if isinstance(allow, dict):
|
||||
print("a\tpresent")
|
||||
for key in allow:
|
||||
print("k\t%s" % key)
|
||||
else:
|
||||
print("a\tabsent")
|
||||
PY
|
||||
}
|
||||
|
||||
# Strips one layer of matching quotes plus surrounding whitespace from a scalar.
|
||||
unquote() {
|
||||
local value="$1"
|
||||
read -r value <<< "$value"
|
||||
case "$value" in
|
||||
\"*\") value="${value#\"}"; value="${value%\"}" ;;
|
||||
\'*\') value="${value#\'}"; value="${value%\'}" ;;
|
||||
esac
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
read_facts_bash() {
|
||||
local line version="" allow_state="absent" in_executables=0 in_allow=0 key
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
[[ "$line" == version:* ]] || continue
|
||||
version="$(unquote "${line#version:}")"
|
||||
break
|
||||
done < "$PLUGIN_MANIFEST"
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Blank and full-line comments carry no structure at any depth.
|
||||
case "$line" in
|
||||
'' | '#'*) continue ;;
|
||||
esac
|
||||
# A top-level key (column 0) closes whatever block was open. Checked before
|
||||
# anything else so `marketplace:` after `executables:` cannot leak keys in.
|
||||
if [[ "$line" != [[:space:]]* ]]; then
|
||||
if [[ "$line" == executables:* ]]; then
|
||||
in_executables=1
|
||||
else
|
||||
in_executables=0
|
||||
fi
|
||||
in_allow=0
|
||||
continue
|
||||
fi
|
||||
[[ $in_executables -eq 1 ]] || continue
|
||||
# 2-space indent: a key directly under executables:. `allow:` opens the
|
||||
# mapping this gate reads; any sibling key closes it.
|
||||
if [[ "$line" =~ ^\ \ [^[:space:]#] ]]; then
|
||||
if [[ "$line" =~ ^\ \ allow: ]]; then
|
||||
in_allow=1
|
||||
allow_state="present"
|
||||
else
|
||||
in_allow=0
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
# 4-space indent while inside allow: — an allow key. Keys contain '#' by
|
||||
# construction ("kyberforge#1.5.0"), so a trailing-comment strip would eat
|
||||
# them; there is none, and inline comments are not used on these lines.
|
||||
if [[ $in_allow -eq 1 && "$line" =~ ^\ \ \ \ ([^[:space:]#][^:]*): ]]; then
|
||||
key="$(unquote "${BASH_REMATCH[1]}")"
|
||||
printf 'k\t%s\n' "$key"
|
||||
fi
|
||||
done < "$ROOT_MANIFEST"
|
||||
|
||||
printf 'v\t%s\n' "$version"
|
||||
printf 'a\t%s\n' "$allow_state"
|
||||
}
|
||||
|
||||
if command -v python3 > /dev/null 2>&1 && python3 -c 'import yaml' > /dev/null 2>&1; then
|
||||
READER="python3 + PyYAML"
|
||||
if ! FACTS="$(read_facts_python)"; then
|
||||
echo "FAIL: could not read apm.yml / plugins/kyberforge/apm.yml (see the parser error above)." >&2
|
||||
echo " Why: this gate compares kyberforge's version against root apm.yml's executables.allow" >&2
|
||||
echo " key; an unreadable manifest means the comparison did not happen." >&2
|
||||
echo " Fix: make both manifests valid YAML, then re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
READER="shape-scan fallback (PyYAML unavailable)"
|
||||
FACTS="$(read_facts_bash)"
|
||||
fi
|
||||
|
||||
VERSION="$(printf '%s\n' "$FACTS" | sed -n 's/^v\t//p')"
|
||||
ALLOW_STATE="$(printf '%s\n' "$FACTS" | sed -n 's/^a\t//p')"
|
||||
ALLOW_KEYS="$(printf '%s\n' "$FACTS" | sed -n 's/^k\t//p')"
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "FAIL: plugins/kyberforge/apm.yml declares no version:, so no allow key can be checked against it." >&2
|
||||
echo " Why: apm's executables.allow lookup is keyed on '<package>#<version>' exactly; with no" >&2
|
||||
echo " version there is nothing for root apm.yml's key to stay in sync with." >&2
|
||||
echo " Fix: give plugins/kyberforge/apm.yml a top-level version: (read by $READER)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXPECTED_KEY="kyberforge#$VERSION"
|
||||
|
||||
if [[ "$ALLOW_STATE" != "present" ]]; then
|
||||
echo "FAIL: root apm.yml has no executables.allow mapping, but plugins/kyberforge is version $VERSION." >&2
|
||||
echo " Why: without an allow entry apm refuses to deploy kyberforge's hooks/ and bin/, so the" >&2
|
||||
echo " SessionStart hook that keeps this install level with the remote never runs and the" >&2
|
||||
echo " deployed skills go stale silently (ADR-0019)." >&2
|
||||
echo " Fix: add to root apm.yml:" >&2
|
||||
echo " executables:" >&2
|
||||
echo " allow:" >&2
|
||||
echo " $EXPECTED_KEY:" >&2
|
||||
echo " hooks: true" >&2
|
||||
echo " bin: true" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if printf '%s\n' "$ALLOW_KEYS" | grep -qxF "$EXPECTED_KEY"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
STALE_KEYS="$(printf '%s\n' "$ALLOW_KEYS" | grep '^kyberforge#' || true)"
|
||||
|
||||
echo "FAIL: root apm.yml's executables.allow has no '$EXPECTED_KEY' key, but that is kyberforge's version." >&2
|
||||
if [[ -n "$STALE_KEYS" ]]; then
|
||||
echo " Found instead:" >&2
|
||||
printf '%s\n' "$STALE_KEYS" | sed 's/^/ /' >&2
|
||||
fi
|
||||
echo " Why: apm matches this key by exact dict lookup — there is no wildcard and no version-less" >&2
|
||||
echo " form — so a key naming any other version silently stops granting kyberforge's hooks/" >&2
|
||||
echo " and bin/. The SessionStart hook then stops deploying and the apm install goes stale" >&2
|
||||
echo " with no error anywhere (ADR-0019, 'The allow key is version-pinned')." >&2
|
||||
echo " Fix: bump the key in root apm.yml to '$EXPECTED_KEY:' — the version bump in" >&2
|
||||
echo " plugins/kyberforge/apm.yml is not complete without it." >&2
|
||||
exit 1
|
||||
@@ -23,11 +23,14 @@ WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$FAKE_BIN" "$WORK"' EXIT
|
||||
|
||||
# Mock `apm`. $1 chooses what `apm outdated` reports; $2 the exit code of
|
||||
# `apm update`. A sentinel file records whether update was actually invoked.
|
||||
# `apm update`. Sentinel files record whether update was actually invoked and
|
||||
# which directory the calls ran in — the hook must run them against the same
|
||||
# directory its lockfile guard checked, not the session's cwd.
|
||||
make_apm() {
|
||||
local outdated_line="$1" update_exit="$2"
|
||||
cat > "$FAKE_BIN/apm" << EOF
|
||||
#!/usr/bin/env bash
|
||||
pwd > "$WORK/apm-cwd"
|
||||
case "\$1" in
|
||||
outdated) echo "$outdated_line"; exit 0 ;;
|
||||
update) touch "$WORK/update-was-called"; exit $update_exit ;;
|
||||
@@ -37,7 +40,22 @@ EOF
|
||||
chmod +x "$FAKE_BIN/apm"
|
||||
}
|
||||
|
||||
run_hook() { (cd "$WORK" && PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null); }
|
||||
# CLAUDE_PROJECT_DIR is cleared rather than merely left alone: a session in this
|
||||
# repo exports it, and an inherited value would point every case at the real
|
||||
# repo root (which has a real apm.lock.yaml) instead of the fixture. The
|
||||
# project-directory cases below set it deliberately.
|
||||
run_hook() { (cd "$WORK" && env -u CLAUDE_PROJECT_DIR PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null); }
|
||||
|
||||
# Same, with an explicit cwd and CLAUDE_PROJECT_DIR. $1 is the cwd; $2 the value
|
||||
# for CLAUDE_PROJECT_DIR, or the literal `-` to leave it unset.
|
||||
run_hook_in() {
|
||||
local cwd="$1" project_dir="$2"
|
||||
if [[ "$project_dir" == "-" ]]; then
|
||||
(cd "$cwd" && env -u CLAUDE_PROJECT_DIR PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null)
|
||||
else
|
||||
(cd "$cwd" && env CLAUDE_PROJECT_DIR="$project_dir" PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null)
|
||||
fi
|
||||
}
|
||||
|
||||
json_field() { python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"][sys.argv[1]])' "$1"; }
|
||||
|
||||
@@ -134,6 +152,54 @@ out="$(run_hook)"
|
||||
echo "$out" | python3 -m json.tool > /dev/null 2>&1 \
|
||||
&& pass "still emits valid JSON when the count cannot be parsed" || fail "JSON broke on an unparseable count"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- anchors on the project root, not the session cwd ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# `[[ -f apm.lock.yaml ]]` resolves against the cwd, and a session started in a
|
||||
# subdirectory of an apm-consuming repo therefore no-opped silently — and would
|
||||
# have run `apm outdated`/`apm update` against that wrong directory had the
|
||||
# guard passed. Claude Code exports CLAUDE_PROJECT_DIR for SessionStart hooks,
|
||||
# so that is the anchor; the cwd is only the fallback.
|
||||
ELSEWHERE="$WORK/elsewhere"
|
||||
mkdir -p "$ELSEWHERE"
|
||||
rm -f "$ELSEWHERE/apm.lock.yaml"
|
||||
|
||||
make_apm "[!] 6 outdated dependencies found" 0
|
||||
rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
|
||||
out="$(run_hook_in "$ELSEWHERE" "$WORK")"
|
||||
[[ -f "$WORK/update-was-called" ]] \
|
||||
&& pass "finds the lockfile via CLAUDE_PROJECT_DIR when the cwd is elsewhere" \
|
||||
|| fail "a session started in a subdirectory must still see the project's lockfile"
|
||||
[[ "$(cat "$WORK/apm-cwd" 2>/dev/null)" == "$WORK" ]] \
|
||||
&& pass "runs apm in the directory the guard checked, not the cwd" \
|
||||
|| fail "apm ran in '$(cat "$WORK/apm-cwd" 2>/dev/null)' — must run in the resolved project directory"
|
||||
echo "$out" | json_field additionalContext | grep -q "6 package" \
|
||||
&& pass "reports the count found via CLAUDE_PROJECT_DIR" || fail "should report the count"
|
||||
|
||||
# The fallback is not cosmetic: a host that installed this plugin natively sets
|
||||
# no CLAUDE_PROJECT_DIR, and the hook must stay inert-but-harmless there rather
|
||||
# than erroring on an unset variable (the script runs under `set -u`).
|
||||
rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
|
||||
out="$(run_hook_in "$WORK" "-")"
|
||||
[[ -f "$WORK/update-was-called" ]] \
|
||||
&& pass "falls back to the cwd when CLAUDE_PROJECT_DIR is unset" \
|
||||
|| fail "must still work with no CLAUDE_PROJECT_DIR in the environment"
|
||||
[[ "$(cat "$WORK/apm-cwd" 2>/dev/null)" == "$WORK" ]] \
|
||||
&& pass "runs apm in the cwd under the fallback" \
|
||||
|| fail "apm ran in '$(cat "$WORK/apm-cwd" 2>/dev/null)' — should be the cwd"
|
||||
|
||||
rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
|
||||
out="$(run_hook_in "$ELSEWHERE" "$ELSEWHERE")"; rc=$?
|
||||
[[ $rc -eq 0 ]] && pass "exits 0 when neither the project dir nor the cwd has a lockfile" \
|
||||
|| fail "should exit 0 when there is no lockfile anywhere"
|
||||
[[ -z "$out" ]] && pass "stays silent when neither the project dir nor the cwd has a lockfile" \
|
||||
|| fail "should stay silent when there is no lockfile anywhere"
|
||||
[[ ! -f "$WORK/update-was-called" ]] \
|
||||
&& pass "does not run apm update when there is no lockfile anywhere" \
|
||||
|| fail "must not touch a project that does not use apm"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- hooks.json wiring ---"
|
||||
@@ -153,7 +219,150 @@ matcher="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d[
|
||||
[[ "$matcher" == "startup" ]] && pass "fires on startup only" \
|
||||
|| fail "matcher is '$matcher' — resume/clear/compact would re-run this every compaction"
|
||||
|
||||
# The host's timeout must strictly exceed everything the script can spend, or
|
||||
# the host SIGKILLs the hook mid-`apm update` and leaves a half-redeployed
|
||||
# .claude/skills/ with no notice emitted — the silent failure this hook exists
|
||||
# to prevent. Asserted as an invariant over both files rather than against a
|
||||
# literal, so raising either internal `timeout` without raising the host budget
|
||||
# fails here instead of reintroducing the gap quietly.
|
||||
#
|
||||
# Every `timeout N` in the script counts, comments included: a stray "timeout
|
||||
# 300" in prose only makes this stricter, which is the safe direction.
|
||||
script_budget=0
|
||||
timeout_count=0
|
||||
while read -r n; do
|
||||
[[ -n "$n" ]] || continue
|
||||
script_budget=$((script_budget + n))
|
||||
timeout_count=$((timeout_count + 1))
|
||||
done < <(grep -oE '\btimeout [0-9]+\b' "$HOOK" | grep -oE '[0-9]+')
|
||||
|
||||
hook_timeout="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["hooks"][0]["timeout"])' "$HOOKS_JSON")"
|
||||
|
||||
if [[ $timeout_count -eq 0 ]]; then
|
||||
fail "found no 'timeout N' in $HOOK — the budget assertion below would be vacuous"
|
||||
else
|
||||
pass "parsed $timeout_count internal timeout(s) totalling ${script_budget}s from the hook script"
|
||||
[[ $hook_timeout -gt $script_budget ]] \
|
||||
&& pass "hooks.json timeout (${hook_timeout}s) exceeds the script's own budget (${script_budget}s)" \
|
||||
|| fail "hooks.json timeout is ${hook_timeout}s but the script can spend ${script_budget}s — the host would SIGKILL it mid-update"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- the greped phrase, against the real apm ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Everything above mocks `apm`, so an apm release that reworded its summary line
|
||||
# would revert this hook to its pre-fix behaviour with a fully green suite.
|
||||
# `apm outdated` has no --json/machine-readable flag (verified against 0.28.0),
|
||||
# so the phrase match cannot be replaced — it can only be pinned.
|
||||
#
|
||||
# The probe stages a genuinely outdated dependency with no network: a local git
|
||||
# repo stands in for the upstream, reached through `url.<path>.insteadOf`
|
||||
# rewrites of every URL spelling apm may build (it picks ssh or https depending
|
||||
# on ambient auth config, so all three are mapped). apm appends `.git` to the
|
||||
# repo URL, which is why the local repo is named `upstream.git` and the rewrite
|
||||
# target omits the suffix. HOME is redirected so no user-level apm cache or
|
||||
# credential state can influence the result.
|
||||
#
|
||||
# The genuine output is then replayed into the real hook through the mock, so
|
||||
# what is asserted is the hook's own matching logic against real apm text —
|
||||
# no pattern is duplicated here to drift out of sync.
|
||||
SKIP_REASON=""
|
||||
if ! command -v apm > /dev/null 2>&1 || ! command -v git > /dev/null 2>&1; then
|
||||
SKIP_REASON="SKIP: apm and git are both required to verify the hook's phrase match against real \`apm outdated\` output — everything above ran, this axis did not"
|
||||
echo " $SKIP_REASON"
|
||||
else
|
||||
PROBE="$(mktemp -d)"
|
||||
trap 'rm -rf "$FAKE_BIN" "$WORK" "$PROBE"' EXIT
|
||||
|
||||
UPSTREAM="$PROBE/upstream.git"
|
||||
git init -q "$UPSTREAM"
|
||||
git -C "$UPSTREAM" -c user.email=probe@example.invalid -c user.name=probe \
|
||||
commit -q --allow-empty -m one
|
||||
LOCKED_SHA="$(git -C "$UPSTREAM" rev-parse HEAD)"
|
||||
git -C "$UPSTREAM" -c user.email=probe@example.invalid -c user.name=probe \
|
||||
commit -q --allow-empty -m two
|
||||
BRANCH="$(git -C "$UPSTREAM" symbolic-ref --short HEAD)"
|
||||
|
||||
{
|
||||
for repo in alpha beta; do
|
||||
printf '[url "%s/upstream"]\n' "$PROBE"
|
||||
printf '\tinsteadOf = git@apm-probe.invalid:probe/%s\n' "$repo"
|
||||
printf '\tinsteadOf = https://apm-probe.invalid/probe/%s\n' "$repo"
|
||||
printf '\tinsteadOf = ssh://git@apm-probe.invalid/probe/%s\n' "$repo"
|
||||
done
|
||||
} > "$PROBE/gitconfig"
|
||||
|
||||
mkdir -p "$PROBE/consumer" "$PROBE/home"
|
||||
|
||||
# $1 = how many stale dependencies to stage. Writes a lockfile and echoes what
|
||||
# the real `apm outdated` printed for it.
|
||||
real_apm_outdated() {
|
||||
local want="$1" repo
|
||||
{
|
||||
echo "lockfile_version: '1'"
|
||||
echo "generated_at: '2026-01-01T00:00:00+00:00'"
|
||||
echo "apm_version: 0.0.0"
|
||||
echo "dependencies:"
|
||||
for repo in $( [[ "$want" == 1 ]] && echo alpha || echo alpha beta ); do
|
||||
echo "- host: apm-probe.invalid"
|
||||
echo " name: probe-$repo"
|
||||
echo " package_type: apm_package"
|
||||
echo " repo_url: probe/$repo"
|
||||
echo " resolved_ref: $BRANCH"
|
||||
echo " resolved_commit: $LOCKED_SHA"
|
||||
echo " version: 1.0.0"
|
||||
done
|
||||
echo "deployments: []"
|
||||
} > "$PROBE/consumer/apm.lock.yaml"
|
||||
(
|
||||
cd "$PROBE/consumer" &&
|
||||
env HOME="$PROBE/home" \
|
||||
GIT_CONFIG_GLOBAL="$PROBE/gitconfig" \
|
||||
GIT_CONFIG_NOSYSTEM=1 \
|
||||
GIT_TERMINAL_PROMPT=0 \
|
||||
apm outdated 2>&1
|
||||
)
|
||||
}
|
||||
|
||||
# Replay genuine output through the hook. A harness that stages nothing would
|
||||
# make every assertion below vacuously true, so the staged row is checked
|
||||
# first and a failure to stage is a FAIL, not a quiet pass.
|
||||
for want in 1 2; do
|
||||
genuine="$(real_apm_outdated "$want" || true)"
|
||||
if ! grep -q "outdated" <<< "$genuine"; then
|
||||
fail "probe staged no outdated dependency against the real apm (harness broken, not the hook): $(tr '\n' ' ' <<< "$genuine" | cut -c1-160)"
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$genuine" > "$PROBE/genuine-$want.txt"
|
||||
cat > "$FAKE_BIN/apm" << EOF
|
||||
#!/usr/bin/env bash
|
||||
pwd > "$WORK/apm-cwd"
|
||||
case "\$1" in
|
||||
outdated) cat "$PROBE/genuine-$want.txt"; exit 0 ;;
|
||||
update) touch "$WORK/update-was-called"; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$FAKE_BIN/apm"
|
||||
rm -f "$WORK/update-was-called"
|
||||
out="$(run_hook)"
|
||||
if [[ -n "$out" ]] && echo "$out" | json_field additionalContext 2>/dev/null | grep -q "$want package"; then
|
||||
pass "detects staleness in real \`apm outdated\` output and counts $want package(s)"
|
||||
else
|
||||
fail "real apm reported $want outdated dependency/dependencies but the hook did not act on it — apm reworded its summary line. Real output: $(tr '\n' ' ' < "$PROBE/genuine-$want.txt" | tail -c 120)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
# A skip only after everything runnable has run and passed: the mocked axis is
|
||||
# still worth executing on a machine without apm, but the suite must not read
|
||||
# as green when the real-apm axis was not verified. run-tests.sh reports 77 as
|
||||
# SKIPPED and, at pre-push (--strict), as a setup error naming this reason.
|
||||
[[ -z "$SKIP_REASON" ]] || exit 77
|
||||
exit 0
|
||||
|
||||
243
tests/test-check-executables-allow-sync.sh
Normal file
243
tests/test-check-executables-allow-sync.sh
Normal file
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for scripts/check-executables-allow-sync.sh — the pre-push gate that
|
||||
# keeps root apm.yml's executables.allow key level with kyberforge's version.
|
||||
#
|
||||
# Fixtures are two-file skeletons (root apm.yml + plugins/kyberforge/apm.yml)
|
||||
# rather than copies of the real repo: the gate reads exactly those two files,
|
||||
# and a hand-built fixture is the only way to construct the drift it exists to
|
||||
# catch without editing the real manifests.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SCRIPT="$REPO_ROOT/scripts/check-executables-allow-sync.sh"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# Same `exit 77` (automake convention; run-tests.sh renders it as SKIPPED) guard
|
||||
# the vale suites use. The script itself runs fine without python3 — it falls
|
||||
# back to a shape scan — but this suite asserts BOTH readers agree, and the
|
||||
# PyYAML path cannot be exercised at all on a machine without it. Reporting
|
||||
# those cases as failures would say "a regression landed" when the truth is
|
||||
# "this machine is missing a dev dependency".
|
||||
command -v python3 > /dev/null 2>&1 || { echo "SKIP: python3 is required to exercise the PyYAML reader"; exit 77; }
|
||||
python3 -c 'import yaml' > /dev/null 2>&1 || { echo "SKIP: PyYAML is required to exercise the PyYAML reader"; exit 77; }
|
||||
|
||||
FIXTURES=()
|
||||
cleanup() { [[ ${#FIXTURES[@]} -eq 0 ]] || rm -rf "${FIXTURES[@]}"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# make_fixture <plugin-version-line> <root-executables-block>
|
||||
# The executables block is passed verbatim (may be empty) so a fixture can omit
|
||||
# it entirely, which is one of the failure modes under test.
|
||||
make_fixture() {
|
||||
local version_line="$1" executables_block="$2" dir
|
||||
dir="$(mktemp -d)"
|
||||
FIXTURES+=("$dir")
|
||||
mkdir -p "$dir/plugins/kyberforge"
|
||||
|
||||
{
|
||||
echo "name: kyberforge"
|
||||
echo "$version_line"
|
||||
echo "description: fixture"
|
||||
} > "$dir/plugins/kyberforge/apm.yml"
|
||||
|
||||
{
|
||||
echo "name: ai-development"
|
||||
echo "version: 0.0.1"
|
||||
echo "dependencies:"
|
||||
echo " apm:"
|
||||
echo " - name: kyberforge"
|
||||
[[ -n "$executables_block" ]] && printf '%s\n' "$executables_block"
|
||||
# A top-level key after the block: the fallback reader must stop collecting
|
||||
# allow keys here rather than reading on into the next section.
|
||||
echo "marketplace:"
|
||||
echo " owner:"
|
||||
echo " name: fixture"
|
||||
} > "$dir/apm.yml"
|
||||
|
||||
printf '%s\n' "$dir"
|
||||
}
|
||||
|
||||
MATCHING_BLOCK='executables:
|
||||
allow:
|
||||
kyberforge#1.5.0:
|
||||
hooks: true
|
||||
bin: true'
|
||||
|
||||
STALE_BLOCK='executables:
|
||||
allow:
|
||||
kyberforge#1.4.0:
|
||||
hooks: true
|
||||
bin: true'
|
||||
|
||||
OTHER_PACKAGE_BLOCK='executables:
|
||||
allow:
|
||||
git#1.0.0:
|
||||
hooks: true'
|
||||
|
||||
# run_gate <fixture> — echoes combined output, sets GATE_RC.
|
||||
GATE_RC=0
|
||||
run_gate() {
|
||||
GATE_RC=0
|
||||
bash "$SCRIPT" "$1" > /dev/null 2>&1 || GATE_RC=$?
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "--- the real repo passes ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The gate's whole value is that it is green on a correct tree and red on drift;
|
||||
# a version bump landing in only one of the two real manifests must show up here.
|
||||
run_gate "$REPO_ROOT"
|
||||
[[ $GATE_RC -eq 0 ]] && pass "current repo state passes" \
|
||||
|| fail "current repo state should pass — the gate said: $(bash "$SCRIPT" "$REPO_ROOT" 2>&1 | head -3)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- matching version ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
F="$(make_fixture "version: 1.5.0" "$MATCHING_BLOCK")"
|
||||
run_gate "$F"
|
||||
[[ $GATE_RC -eq 0 ]] && pass "exits 0 when the allow key names the plugin's version" \
|
||||
|| fail "should pass when key and version agree, got rc=$GATE_RC"
|
||||
|
||||
F="$(make_fixture 'version: "1.5.0"' "$MATCHING_BLOCK")"
|
||||
run_gate "$F"
|
||||
[[ $GATE_RC -eq 0 ]] && pass "exits 0 when the version is quoted" \
|
||||
|| fail "a quoted version must compare the same as an unquoted one, got rc=$GATE_RC"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- mismatched version ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
F="$(make_fixture "version: 1.5.0" "$STALE_BLOCK")"
|
||||
run_gate "$F"
|
||||
[[ $GATE_RC -ne 0 ]] && pass "fails when the allow key names a different version" \
|
||||
|| fail "a stale allow key must fail the push"
|
||||
OUT="$(bash "$SCRIPT" "$F" 2>&1 || true)"
|
||||
grep -q "kyberforge#1.5.0" <<< "$OUT" && pass "names the key that should be there" \
|
||||
|| fail "the failure must state the expected key"
|
||||
grep -q "kyberforge#1.4.0" <<< "$OUT" && pass "names the stale key it found instead" \
|
||||
|| fail "the failure must quote back the stale key"
|
||||
grep -q "Why:" <<< "$OUT" && grep -q "Fix:" <<< "$OUT" && pass "uses the FAIL/Why/Fix message block" \
|
||||
|| fail "message must carry Why: and Fix: lines"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- missing executables.allow ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
F="$(make_fixture "version: 1.5.0" "")"
|
||||
run_gate "$F"
|
||||
[[ $GATE_RC -ne 0 ]] && pass "fails when there is no executables block at all" \
|
||||
|| fail "a missing executables.allow must fail — apm deploys no hooks without it"
|
||||
OUT="$(bash "$SCRIPT" "$F" 2>&1 || true)"
|
||||
grep -q "executables:" <<< "$OUT" && grep -q "kyberforge#1.5.0" <<< "$OUT" \
|
||||
&& pass "shows the block to add" || fail "the failure must show the block to add"
|
||||
|
||||
# An `executables:` key that is not a mapping is the same hole as no key at all.
|
||||
F="$(make_fixture "version: 1.5.0" "executables:")"
|
||||
run_gate "$F"
|
||||
[[ $GATE_RC -ne 0 ]] && pass "fails when executables: exists but allow: does not" \
|
||||
|| fail "an empty executables: block grants nothing and must fail"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- allow present, kyberforge key missing ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
F="$(make_fixture "version: 1.5.0" "$OTHER_PACKAGE_BLOCK")"
|
||||
run_gate "$F"
|
||||
[[ $GATE_RC -ne 0 ]] && pass "fails when allow: exists but names no kyberforge key" \
|
||||
|| fail "an allow block covering only other packages must still fail"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- degenerate inputs fail loudly rather than passing silently ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
run_gate "$REPO_ROOT/definitely-not-a-directory"
|
||||
[[ $GATE_RC -ne 0 ]] && pass "fails on a nonexistent REPO_ROOT" \
|
||||
|| fail "a bad path must not exit 0 — that reads as 'checked, in sync'"
|
||||
|
||||
# No kyberforge plugin at all is the one legitimate no-op: nothing to pin.
|
||||
NO_PLUGIN="$(mktemp -d)"; FIXTURES+=("$NO_PLUGIN")
|
||||
echo "name: someone-else" > "$NO_PLUGIN/apm.yml"
|
||||
run_gate "$NO_PLUGIN"
|
||||
[[ $GATE_RC -eq 0 ]] && pass "no-ops in a repo with no kyberforge plugin" \
|
||||
|| fail "a repo without plugins/kyberforge/ has nothing to check"
|
||||
|
||||
# ...but a kyberforge directory with no manifest is drift, not a no-op.
|
||||
mkdir -p "$NO_PLUGIN/plugins/kyberforge"
|
||||
run_gate "$NO_PLUGIN"
|
||||
[[ $GATE_RC -ne 0 ]] && pass "fails when plugins/kyberforge/ has no apm.yml" \
|
||||
|| fail "a plugin dir with no manifest must not silently pass"
|
||||
|
||||
F="$(make_fixture "description: no version here" "$MATCHING_BLOCK")"
|
||||
run_gate "$F"
|
||||
[[ $GATE_RC -ne 0 ]] && pass "fails when the plugin manifest declares no version" \
|
||||
|| fail "no version means nothing to compare — must fail, not pass"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- the fallback reader agrees with the PyYAML reader ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# PyYAML is deliberately not a hard dependency of this gate (no other pre-push
|
||||
# hook needs it), so the shape-scan fallback carries the same verdicts. Masking
|
||||
# is done with a python3 stub whose `import yaml` fails, which is the exact
|
||||
# condition on a machine that has python3 without PyYAML.
|
||||
NO_YAML_BIN="$(mktemp -d)"; FIXTURES+=("$NO_YAML_BIN")
|
||||
printf '#!/usr/bin/env bash\nexit 1\n' > "$NO_YAML_BIN/python3"
|
||||
chmod +x "$NO_YAML_BIN/python3"
|
||||
|
||||
run_fallback() {
|
||||
GATE_RC=0
|
||||
PATH="$NO_YAML_BIN:$PATH" bash "$SCRIPT" "$1" > /dev/null 2>&1 || GATE_RC=$?
|
||||
}
|
||||
|
||||
F="$(make_fixture "version: 1.5.0" "$MATCHING_BLOCK")"
|
||||
run_fallback "$F"
|
||||
[[ $GATE_RC -eq 0 ]] && pass "fallback passes a matching fixture" || fail "fallback should pass when in sync"
|
||||
|
||||
F="$(make_fixture 'version: "1.5.0"' "$MATCHING_BLOCK")"
|
||||
run_fallback "$F"
|
||||
[[ $GATE_RC -eq 0 ]] && pass "fallback strips quotes from the version" || fail "fallback mishandled a quoted version"
|
||||
|
||||
F="$(make_fixture "version: 1.5.0" "$STALE_BLOCK")"
|
||||
run_fallback "$F"
|
||||
[[ $GATE_RC -ne 0 ]] && pass "fallback fails a stale key" || fail "fallback missed a stale key"
|
||||
|
||||
F="$(make_fixture "version: 1.5.0" "")"
|
||||
run_fallback "$F"
|
||||
[[ $GATE_RC -ne 0 ]] && pass "fallback fails a missing executables block" || fail "fallback missed a missing block"
|
||||
|
||||
F="$(make_fixture "version: 1.5.0" "$OTHER_PACKAGE_BLOCK")"
|
||||
run_fallback "$F"
|
||||
[[ $GATE_RC -ne 0 ]] && pass "fallback fails when no kyberforge key is present" || fail "fallback missed an absent key"
|
||||
|
||||
run_fallback "$REPO_ROOT"
|
||||
[[ $GATE_RC -eq 0 ]] && pass "fallback passes the real repo" || fail "fallback disagrees with PyYAML on the real repo"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- wired into the pre-push gate ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A gate nobody runs is not a gate; this is the only assertion that the script
|
||||
# is actually reachable from `git push`.
|
||||
CONFIG="$REPO_ROOT/.pre-commit-config.yaml"
|
||||
grep -q "id: check-executables-allow-sync" "$CONFIG" \
|
||||
&& pass ".pre-commit-config.yaml declares the hook" || fail "hook is not declared in .pre-commit-config.yaml"
|
||||
grep -q "scripts/check-executables-allow-sync.sh" "$CONFIG" \
|
||||
&& pass ".pre-commit-config.yaml points at the script" || fail "hook does not reference the script path"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
Reference in New Issue
Block a user