Compare commits
5 Commits
e79497b3cf
...
013b913bd4
| Author | SHA1 | Date | |
|---|---|---|---|
| 013b913bd4 | |||
| bb9158da22 | |||
| 413a750819 | |||
| d4fa4b7153 | |||
| f6cf83c841 |
@@ -1,95 +1,95 @@
|
||||
{
|
||||
"name": "holocron",
|
||||
"interface": {
|
||||
"displayName": "holocron"
|
||||
},
|
||||
"name": "holocron",
|
||||
"plugins": [
|
||||
{
|
||||
"category": "Developer Tools",
|
||||
"name": "kyberforge",
|
||||
"policy": {
|
||||
"authentication": "ON_INSTALL",
|
||||
"installation": "AVAILABLE"
|
||||
},
|
||||
"source": {
|
||||
"path": "./plugins/kyberforge",
|
||||
"source": "local"
|
||||
}
|
||||
"source": "local",
|
||||
"path": "./plugins/kyberforge"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Developer Tools"
|
||||
},
|
||||
{
|
||||
"category": "Utilities",
|
||||
"name": "bin",
|
||||
"policy": {
|
||||
"authentication": "ON_INSTALL",
|
||||
"installation": "AVAILABLE"
|
||||
},
|
||||
"source": {
|
||||
"path": "./plugins/bin",
|
||||
"source": "local"
|
||||
}
|
||||
"source": "local",
|
||||
"path": "./plugins/bin"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Utilities"
|
||||
},
|
||||
{
|
||||
"category": "Version Control",
|
||||
"name": "git",
|
||||
"policy": {
|
||||
"authentication": "ON_INSTALL",
|
||||
"installation": "AVAILABLE"
|
||||
},
|
||||
"source": {
|
||||
"path": "./plugins/git",
|
||||
"source": "local"
|
||||
}
|
||||
"source": "local",
|
||||
"path": "./plugins/git"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Version Control"
|
||||
},
|
||||
{
|
||||
"category": "Version Control",
|
||||
"name": "gitea",
|
||||
"policy": {
|
||||
"authentication": "ON_INSTALL",
|
||||
"installation": "AVAILABLE"
|
||||
},
|
||||
"source": {
|
||||
"path": "./plugins/gitea",
|
||||
"source": "local"
|
||||
}
|
||||
"source": "local",
|
||||
"path": "./plugins/gitea"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Version Control"
|
||||
},
|
||||
{
|
||||
"category": "Productivity",
|
||||
"name": "core",
|
||||
"policy": {
|
||||
"authentication": "ON_INSTALL",
|
||||
"installation": "AVAILABLE"
|
||||
},
|
||||
"source": {
|
||||
"path": "./plugins/core",
|
||||
"source": "local"
|
||||
}
|
||||
"source": "local",
|
||||
"path": "./plugins/core"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
},
|
||||
{
|
||||
"category": "Productivity",
|
||||
"name": "mattpocock-skills",
|
||||
"policy": {
|
||||
"authentication": "ON_INSTALL",
|
||||
"installation": "AVAILABLE"
|
||||
},
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "mattpocock/skills",
|
||||
"ref": "v1.2.3",
|
||||
"sha": "835450ef244ab7335f75d95b83e7d979eae22a6d",
|
||||
"source": "url",
|
||||
"tag_pattern": "v{version}",
|
||||
"url": "mattpocock/skills"
|
||||
}
|
||||
"tag_pattern": "v{version}"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
},
|
||||
{
|
||||
"category": "Developer Tools",
|
||||
"name": "lint",
|
||||
"policy": {
|
||||
"authentication": "ON_INSTALL",
|
||||
"installation": "AVAILABLE"
|
||||
},
|
||||
"source": {
|
||||
"path": "./plugins/lint",
|
||||
"source": "local"
|
||||
}
|
||||
"source": "local",
|
||||
"path": "./plugins/lint"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Developer Tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
{
|
||||
"description": "AI development skills for Claude Code and GitHub Copilot CLI \u2014 factory, design, implement, review, and cross-cutting workflows.",
|
||||
"name": "holocron",
|
||||
"description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.",
|
||||
"version": "0.3.3",
|
||||
"owner": {
|
||||
"name": "Defame1297",
|
||||
"url": "https://git.dev.rkdr.net/Defame1297/"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"category": "Developer Tools",
|
||||
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
||||
"name": "kyberforge",
|
||||
"source": "./plugins/kyberforge",
|
||||
"version": "1.4.0"
|
||||
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
||||
"version": "1.4.1",
|
||||
"category": "Developer Tools",
|
||||
"source": "./plugins/kyberforge"
|
||||
},
|
||||
{
|
||||
"category": "Utilities",
|
||||
"description": "A place for things to be binned",
|
||||
"name": "bin",
|
||||
"source": "./plugins/bin",
|
||||
"version": "1.1.1"
|
||||
"description": "A place for things to be binned",
|
||||
"version": "1.1.1",
|
||||
"category": "Utilities",
|
||||
"source": "./plugins/bin"
|
||||
},
|
||||
{
|
||||
"category": "Version Control",
|
||||
"description": "Skills for working with Git \u2014 conventional commits, branch management, pull requests, and feature flow.",
|
||||
"name": "git",
|
||||
"source": "./plugins/git",
|
||||
"version": "1.3.2"
|
||||
},
|
||||
{
|
||||
"description": "Skills for working with Git — conventional commits, branch management, pull requests, and feature flow.",
|
||||
"version": "1.3.2",
|
||||
"category": "Version Control",
|
||||
"description": "Skills for managing Gitea repositories \u2014 issues, pull requests, milestones, releases, and wikis.",
|
||||
"source": "./plugins/git"
|
||||
},
|
||||
{
|
||||
"name": "gitea",
|
||||
"source": "./plugins/gitea",
|
||||
"version": "1.3.3"
|
||||
"description": "Skills for managing Gitea repositories — issues, pull requests, milestones, releases, and wikis.",
|
||||
"version": "1.3.3",
|
||||
"category": "Version Control",
|
||||
"source": "./plugins/gitea"
|
||||
},
|
||||
{
|
||||
"category": "Productivity",
|
||||
"description": "Cross-cutting utility skills for everyday AI-assisted coding \u2014 triage, diagnosis, architecture review, and session navigation.",
|
||||
"name": "core",
|
||||
"source": "./plugins/core",
|
||||
"version": "1.1.0"
|
||||
"description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.",
|
||||
"version": "1.1.1",
|
||||
"category": "Productivity",
|
||||
"source": "./plugins/core"
|
||||
},
|
||||
{
|
||||
"category": "Productivity",
|
||||
"description": "Skills for Real Engineers \u2014 planning, TDD, architecture, and debugging workflows from Matt Pocock's .claude directory.",
|
||||
"name": "mattpocock-skills",
|
||||
"description": "Skills for Real Engineers — planning, TDD, architecture, and debugging workflows from Matt Pocock's .claude directory.",
|
||||
"category": "Productivity",
|
||||
"source": {
|
||||
"ref": "v1.2.3",
|
||||
"repo": "mattpocock/skills",
|
||||
"sha": "835450ef244ab7335f75d95b83e7d979eae22a6d",
|
||||
"source": "github",
|
||||
"repo": "mattpocock/skills",
|
||||
"ref": "v1.2.3",
|
||||
"sha": "835450ef244ab7335f75d95b83e7d979eae22a6d",
|
||||
"tag_pattern": "v{version}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"category": "Developer Tools",
|
||||
"description": "Skills and agents for configuring and running linters.",
|
||||
"name": "lint",
|
||||
"source": "./plugins/lint",
|
||||
"version": "1.1.5"
|
||||
"description": "Skills and agents for configuring and running linters.",
|
||||
"version": "1.1.5",
|
||||
"category": "Developer Tools",
|
||||
"source": "./plugins/lint"
|
||||
}
|
||||
],
|
||||
"version": "0.3.2"
|
||||
]
|
||||
}
|
||||
|
||||
66
.github/plugin/marketplace.json
vendored
66
.github/plugin/marketplace.json
vendored
@@ -1,65 +1,65 @@
|
||||
{
|
||||
"description": "AI development skills for Claude Code and GitHub Copilot CLI \u2014 factory, design, implement, review, and cross-cutting workflows.",
|
||||
"name": "holocron",
|
||||
"description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.",
|
||||
"version": "0.3.3",
|
||||
"owner": {
|
||||
"name": "Defame1297",
|
||||
"url": "https://git.dev.rkdr.net/Defame1297/"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"category": "Developer Tools",
|
||||
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
||||
"name": "kyberforge",
|
||||
"source": "./plugins/kyberforge",
|
||||
"version": "1.4.0"
|
||||
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
||||
"version": "1.4.1",
|
||||
"category": "Developer Tools",
|
||||
"source": "./plugins/kyberforge"
|
||||
},
|
||||
{
|
||||
"category": "Utilities",
|
||||
"description": "A place for things to be binned",
|
||||
"name": "bin",
|
||||
"source": "./plugins/bin",
|
||||
"version": "1.1.1"
|
||||
"description": "A place for things to be binned",
|
||||
"version": "1.1.1",
|
||||
"category": "Utilities",
|
||||
"source": "./plugins/bin"
|
||||
},
|
||||
{
|
||||
"category": "Version Control",
|
||||
"description": "Skills for working with Git \u2014 conventional commits, branch management, pull requests, and feature flow.",
|
||||
"name": "git",
|
||||
"source": "./plugins/git",
|
||||
"version": "1.3.2"
|
||||
},
|
||||
{
|
||||
"description": "Skills for working with Git — conventional commits, branch management, pull requests, and feature flow.",
|
||||
"version": "1.3.2",
|
||||
"category": "Version Control",
|
||||
"description": "Skills for managing Gitea repositories \u2014 issues, pull requests, milestones, releases, and wikis.",
|
||||
"source": "./plugins/git"
|
||||
},
|
||||
{
|
||||
"name": "gitea",
|
||||
"source": "./plugins/gitea",
|
||||
"version": "1.3.3"
|
||||
"description": "Skills for managing Gitea repositories — issues, pull requests, milestones, releases, and wikis.",
|
||||
"version": "1.3.3",
|
||||
"category": "Version Control",
|
||||
"source": "./plugins/gitea"
|
||||
},
|
||||
{
|
||||
"category": "Productivity",
|
||||
"description": "Cross-cutting utility skills for everyday AI-assisted coding \u2014 triage, diagnosis, architecture review, and session navigation.",
|
||||
"name": "core",
|
||||
"source": "./plugins/core",
|
||||
"version": "1.1.0"
|
||||
"description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.",
|
||||
"version": "1.1.1",
|
||||
"category": "Productivity",
|
||||
"source": "./plugins/core"
|
||||
},
|
||||
{
|
||||
"category": "Productivity",
|
||||
"description": "Skills for Real Engineers \u2014 planning, TDD, architecture, and debugging workflows from Matt Pocock's .claude directory.",
|
||||
"name": "mattpocock-skills",
|
||||
"description": "Skills for Real Engineers — planning, TDD, architecture, and debugging workflows from Matt Pocock's .claude directory.",
|
||||
"category": "Productivity",
|
||||
"source": {
|
||||
"ref": "v1.2.3",
|
||||
"repo": "mattpocock/skills",
|
||||
"sha": "835450ef244ab7335f75d95b83e7d979eae22a6d",
|
||||
"source": "github",
|
||||
"repo": "mattpocock/skills",
|
||||
"ref": "v1.2.3",
|
||||
"sha": "835450ef244ab7335f75d95b83e7d979eae22a6d",
|
||||
"tag_pattern": "v{version}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"category": "Developer Tools",
|
||||
"description": "Skills and agents for configuring and running linters.",
|
||||
"name": "lint",
|
||||
"source": "./plugins/lint",
|
||||
"version": "1.1.5"
|
||||
"description": "Skills and agents for configuring and running linters.",
|
||||
"version": "1.1.5",
|
||||
"category": "Developer Tools",
|
||||
"source": "./plugins/lint"
|
||||
}
|
||||
],
|
||||
"version": "0.3.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ repos:
|
||||
- id: pretty-format-json
|
||||
stages: ['pre-commit']
|
||||
args: [--autofix]
|
||||
exclude: '(^|/)(\.claude-plugin/plugin\.json|\.github/plugin/plugin\.json|\.claude-plugin/marketplace\.json)$|^\.agents/plugins/marketplace\.json$'
|
||||
exclude: '(^|/)(\.claude-plugin/plugin\.json|\.github/plugin/plugin\.json|\.claude-plugin/marketplace\.json|\.github/plugin/marketplace\.json)$|^\.agents/plugins/marketplace\.json$'
|
||||
- id: check-yaml
|
||||
stages: ['pre-commit']
|
||||
- id: trailing-whitespace
|
||||
@@ -64,12 +64,12 @@ repos:
|
||||
|
||||
- id: check-plugin-content-sync
|
||||
name: Check plugin content sync
|
||||
description: Verify each plugin's flat skills/agents/commands/hooks.json mirror is in sync with .apm/ -- Claude Code has no .apm/ awareness so this compiled mirror must stay current (see issue #90)
|
||||
description: Verify each plugin's flat skills/agents/commands/hooks/hooks.json mirror is in sync with .apm/ -- Claude Code has no .apm/ awareness so this compiled mirror must stay current (see issue #90)
|
||||
entry: bash scripts/sync-plugin-content.sh --check --all
|
||||
language: system
|
||||
stages: [pre-push]
|
||||
pass_filenames: false
|
||||
files: '^(scripts/sync-plugin-content\.sh|\.claude-plugin/marketplace\.json|plugins/[^/]+/(apm\.yml|\.mcp\.json|hooks\.json|\.apm/|agents/|skills/|commands/|instructions/|extensions/))'
|
||||
always_run: true
|
||||
|
||||
- id: check-marketplace-mirror-sync
|
||||
name: Check marketplace mirror sync
|
||||
|
||||
21
AGENTS.md
21
AGENTS.md
@@ -4,9 +4,19 @@ This repo is the global AI development configuration repository — the authorit
|
||||
|
||||
## Structure
|
||||
|
||||
- `plugins/` — installable plugin units; each is self-contained (skills, agents, hooks, MCP servers, bundled assets); install separately via `claude plugin install <name>@holocron`
|
||||
- `plugins/` — installable plugin units; each is an apm package (`apm.yml` + `.apm/`) carrying skills, agents, hooks, MCP servers, and bundled assets; install separately via `claude plugin install <name>@holocron`
|
||||
- `providers/claude-code/` — Claude Code adapter (deployed to `~/.claude/` via `install.sh`)
|
||||
|
||||
## Edit `.apm/`, never the flat mirror
|
||||
|
||||
Inside a plugin, `plugins/<name>/.apm/` is the **only** hand-edited content source. Everything else in a plugin root is generated:
|
||||
|
||||
- `scripts/sync-plugin-content.sh` generates the flat `plugins/<name>/{skills,agents,commands,instructions,extensions}/` directories and the merged `plugins/<name>/hooks/hooks.json` (ADR-0017)
|
||||
- `apm pack` generates both per-plugin manifests — `plugins/<name>/.claude-plugin/plugin.json` and `plugins/<name>/.github/plugin/plugin.json` — and **two of the three** root marketplace manifests: `.claude-plugin/marketplace.json` (apm's `claude` output profile) and `.agents/plugins/marketplace.json` (its `codex` profile, a differently-shaped file) (ADR-0015)
|
||||
- `scripts/sync-marketplace-mirror.sh` generates the third, `.github/plugin/marketplace.json` — Copilot CLI's legacy manifest path. **No apm output profile targets it**: apm ships exactly two marketplace output profiles, `claude` and `codex` (documented in `plugins/kyberforge/.apm/skills/apm-workflow/references/marketplace.md`). The mirror is a byte-identical copy of `.claude-plugin/marketplace.json`, gated by the `check-marketplace-mirror-sync` pre-push hook. Do not expect `apm pack` to refresh it — that assumption is exactly the drift this pair exists to prevent
|
||||
|
||||
Nothing labels a generated file as generated — `plugins/kyberforge/skills/forge/SKILL.md` is byte-identical to its `.apm/` original, with no marker in either. Check the path before you edit. An edit to the mirror is discarded by the next sync and is reported as drift by the `check-plugin-content-sync` pre-push hook, which is the earliest anyone finds out. Details in `docs/spec/architecture.md`.
|
||||
|
||||
## Prefer plugin skills over raw shell
|
||||
|
||||
This repo dogfoods its own plugins. Before shelling out to git, gitea, or lint tooling directly, check whether an installed skill already owns the operation — it usually does:
|
||||
@@ -21,10 +31,13 @@ Fall back to raw shell only when no skill covers it.
|
||||
|
||||
## Setup and testing
|
||||
|
||||
- Install git hooks via `git: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` (tests, manifest check).
|
||||
- Install the `vale` binary — required by the `vale-audit-prefilter-skill`/`-agent` pre-commit hooks, which run on every commit touching a `SKILL.md` or agent `.md` file. Without it 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).
|
||||
- Install git hooks via `git: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 12-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`). The first three are bare `apm …` hook entries, so without it the push dies with an unhelpful "command not found". Use `kyberforge: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).
|
||||
- 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.
|
||||
- Pushing re-runs the full suite plus `scripts/check-manifests.sh` via the pre-push hook — same commands, so run them locally first.
|
||||
- Pushing runs 12 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`), 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.
|
||||
- `apm-marketplace-check` needs the network. It resolves every `marketplace.packages[]` entry including the remote `mattpocock-skills` ref, and it is `always_run`, so an unreachable network hard-fails the push. `--offline` is not an escape hatch — it still exits 1 on that entry (`No cached refs (offline)`). To push without a network, skip that one hook using pre-commit's own mechanism: `SKIP=apm-marketplace-check git push`. Skip that hook alone — it is the only one whose failure mode is "no network". Every other pre-push hook is a real local check, and adding it to `SKIP` disarms it silently.
|
||||
- Author commits with `git:git-commits` — it validates Conventional Commits (enforced at `commit-msg`) for you.
|
||||
|
||||
## Key documents
|
||||
|
||||
@@ -27,10 +27,10 @@ A separate product (separate repo) for browsing, editing, and configuring AI dev
|
||||
Reusable slash commands for AI coding tools, defined as `SKILL.md` files following the [Agent Skills open standard](https://agentskills.io). Deployed via plugin — `plugins/<plugin-name>/.apm/skills/<skill-name>/SKILL.md`, available after the plugin is installed (`claude plugin install <name>@<marketplace>`). Skills are self-contained — they cannot reference files outside the plugin directory after install-time caching.
|
||||
|
||||
### Plugin
|
||||
The deployable unit in the plugin marketplace. A plugin bundles one or more skills, agents, hooks, prompts, MCP servers, and optionally a `bin/` directory into a single installable directory. In this repo, plugins live under `plugins/<name>/`, each with its own `apm.yml` + `.apm/{skills,agents,hooks,...}` — this is the authoring source of truth for the plugin's content (ADR-0015). Two categories of tracked output are compiled from that source, never hand-edited: `.claude-plugin/plugin.json` (Claude Code) and `.github/plugin/plugin.json` (Copilot CLI) via `apm pack`/`apm compile`; and, alongside them, a flat `agents/`, `skills/`, `commands/`, `instructions/`, `extensions/` directory mirror plus a merged `hooks.json` at the plugin root, generated by `scripts/sync-plugin-content.sh` — Claude Code's and Copilot's installers convention-scan only these flat root-level paths and have no awareness of `.apm/` nesting at all, so this mirror is what actually makes `.apm/` content discoverable at install time (ADR-0017). Plugins are copied to a cache on install — they cannot reference files outside their own directory. Install a plugin with `claude plugin install <name>@<marketplace>`.
|
||||
The deployable unit in the plugin marketplace. A plugin bundles one or more skills, agents, hooks, prompts, MCP servers, and optionally a `bin/` directory into a single installable directory. In this repo, plugins live under `plugins/<name>/`, each with its own `apm.yml` + `.apm/{skills,agents,hooks,...}` — this is the authoring source of truth for the plugin's content (ADR-0015). Two categories of tracked output are compiled from that source, never hand-edited: `.claude-plugin/plugin.json` (Claude Code) and `.github/plugin/plugin.json` (Copilot CLI) via `apm pack`/`apm compile`; and, alongside them, a flat `agents/`, `skills/`, `commands/`, `instructions/`, `extensions/` directory mirror at the plugin root plus a merged hooks file at `hooks/hooks.json`, generated by `scripts/sync-plugin-content.sh` — Claude Code's and Copilot's installers convention-scan only these flat paths (`hooks/hooks.json` is the convention path for hooks specifically; a root-level `hooks.json` is scanned by nothing and is deleted as stale by a sync — see ADR-0017's 2026-08-14 amendment) and have no awareness of `.apm/` nesting at all, so this mirror is what actually makes `.apm/` content discoverable at install time (ADR-0017). Plugins are copied to a cache on install — they cannot reference files outside their own directory. Install a plugin with `claude plugin install <name>@<marketplace>`.
|
||||
|
||||
### Plugin marketplace
|
||||
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.json` content mirror described under "Plugin" (ADR-0017): without it, an install from this marketplace finds a valid manifest but no discoverable content.
|
||||
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.
|
||||
|
||||
### HITL (human-in-the-loop)
|
||||
Agent pauses before a consequential action; human approves before execution. Required for irreversible or high-stakes actions (architecture changes, production deployments, security configuration). The agent drafts the change plan and waits — it does not proceed autonomously. Contrast with HOTL.
|
||||
@@ -67,7 +67,7 @@ A skill pair in the `core` plugin for writing, updating, and reviewing a repo's
|
||||
A companion skill (`core` plugin) that detects a target repo's provider-specific instruction file (`CLAUDE.md`, `.cursor/rules/*.mdc`, `copilot-instructions.md`, etc.) and, where it duplicates content AGENTS.md should own, converts it into a thin adapter that imports AGENTS.md — mirroring this repo's own ADR-0002/ADR-0003 two-tier adapter pattern. Self-validates via its own bundled deterministic script (`scripts/validate-adapter.sh`: checks for an import reference, no duplicated headings, size threshold) rather than a separate paired audit skill — the check is mechanical, so a script suffices per governance.md's "prefer deterministic code for repeatable tasks." `agentsmd-author` calls this skill via skill composition when it detects an existing provider file with overlapping content.
|
||||
|
||||
### lint plugin
|
||||
A standalone, repo-agnostic plugin (`plugins/lint/`) for configuring and running linters — not scoped to kyberforge's own meta-tooling. First linter is Vale (prose style linting), split into two skills per the git/gitea per-concern pattern: `vale-config` (setup — `.vale.ini`, `StylesPath`, styles) and `vale-run` (invoke Vale, interpret/report findings). A `lint-runner` agent composes these for isolated-context lint sweeps; it is report-only (no `Edit` tool) — it flags findings, it does not rewrite prose. Vale's research docs (`docs/research/docs/vale/`) moved from `plugins/kyberforge/` to `plugins/lint/` to keep the provenance chain same-plugin.
|
||||
A standalone, repo-agnostic plugin (`plugins/lint/`) for configuring and running linters — not scoped to kyberforge's own meta-tooling. First linter is Vale (prose style linting), split into two skills per the git/gitea per-concern pattern: `vale-config` (setup — `.vale.ini`, `StylesPath`, styles) and `vale-run` (invoke Vale, interpret/report findings). A `lint-runner` agent composes these for isolated-context lint sweeps; it is report-only **by instruction, not by capability** — its body states "You never edit files" and "Do not edit, fix, or rewrite any flagged content", but nothing enforces that. It previously carried `tools: Bash, Read, Grep, Glob`, which withheld `Edit` outright; plugin-scope APM agents cannot express a `tools:` field at all (ADR-0016 — `apm compile` copies frontmatter verbatim to both Claude Code and Copilot, whose `tools:` vocabularies are incompatible, so a value correct for one harness is wrong for the other), so `plugins/lint/.apm/agents/lint-runner.agent.md` now declares only `name`/`description`/`source_keys` and inherits every tool, `Edit` included. ADR-0016 accepted this loss of enforcement knowingly; the restriction survives as prose the agent is expected to follow. Vale's research docs (`docs/research/docs/vale/`) moved from `plugins/kyberforge/` to `plugins/lint/` to keep the provenance chain same-plugin.
|
||||
|
||||
### Vale audit prefilter (skill-audit / agent-audit)
|
||||
Wiring Vale as a deterministic prefilter for `skill-audit`/`agent-audit`'s Description dimension (ADR motivation: issue #84) is repo-specific, not part of the generic `lint` plugin, so it doesn't live in `plugins/lint/` — but per ADR-0014 it also doesn't live at the repo root anymore. Two copies live inside `plugins/kyberforge/`, one per skill, since a plugin's cache-install only copies each skill's own files (no cross-skill sharing): `plugins/kyberforge/.apm/skills/agent-audit/assets/vale/` is canonical (`.vale.ini` plus a custom `Kyberforge` style covering description-opener banning ("This skill/agent..."), vague-capability wording ("helps with", "utilize", ...), and generic "see references/ for details" padding — and a `KyberforgeCopilot` style scoped only to `.agent.md` files for the Copilot-only "Use proactively has no effect" check), and `plugins/kyberforge/.apm/skills/skill-audit/assets/vale/` is a smaller duplicate (`Kyberforge` only, scoped to `SKILL.md`) kept in sync by `scripts/check-vale-style-sync.sh` (pre-push). A root-level `.pre-commit-hooks.yaml` exposes both copies (plus `skill-size-check`) so any external repo can enforce the same rules via `repo: <this-repo-url>, rev: <tag>` in its own `.pre-commit-config.yaml` — pre-commit clones the pinned rev into its own cache, independent of whether Claude Code or the `kyberforge` plugin is installed at all, and the same mechanism covers CI (`pre-commit run --all-files`). This repo's own `vale-audit-prefilter-skill`/`-agent` pre-commit hooks consume the identical plugin-bundled copies via `repo: local` (not a third root copy, and not a pinned self-reference — a pinned self-reference would lint working-tree edits against the last tagged release rather than the change being made). Every rule is `level: error` and every alert is a FAIL — no ignorable tier, same as shellcheck, the test suite, and conventional-pre-commit. Graded severities do not work here: Vale's exit code keys on `error` alerts alone, so `warning`/`suggestion` rules exit 0 and pre-commit swallows the output of a passing hook, leaving them invisible and blocking nothing. `MinAlertLevel` and `--minAlertLevel` are correspondingly absent from `.vale.ini` and the hook, being no-ops under this model. Vale covers the pattern-matchable sub-checks named in issue #84 (imperative opener, vague filler, `Use proactively`, generic reference-pointer padding) plus, per ADR-0013, one body-wide prose-pattern check ("There is/are" sentence openers) — everything else about body discipline (defaults-vs-menus, why-rationale, non-pattern-matchable judgment calls), near-miss exclusion strength, and control calibration stays LLM judgment.
|
||||
|
||||
10
apm.yml
10
apm.yml
@@ -1,5 +1,5 @@
|
||||
name: holocron
|
||||
version: 0.3.2
|
||||
version: 0.3.3
|
||||
description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.
|
||||
license: MIT
|
||||
marketplace:
|
||||
@@ -8,7 +8,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.3.2
|
||||
version: 0.3.3
|
||||
owner:
|
||||
name: Defame1297
|
||||
url: https://git.dev.rkdr.net/Defame1297/
|
||||
@@ -34,7 +34,7 @@ marketplace:
|
||||
- name: kyberforge
|
||||
description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.
|
||||
source: ./plugins/kyberforge
|
||||
version: 1.4.0
|
||||
version: 1.4.1
|
||||
category: Developer Tools
|
||||
|
||||
- name: bin
|
||||
@@ -56,9 +56,9 @@ marketplace:
|
||||
category: Version Control
|
||||
|
||||
- name: core
|
||||
description: Cross-cutting utility skills for everyday AI-assisted coding — triage, diagnosis, architecture review, and session navigation.
|
||||
description: Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.
|
||||
source: ./plugins/core
|
||||
version: 1.1.0
|
||||
version: 1.1.1
|
||||
category: Productivity
|
||||
|
||||
- name: mattpocock-skills
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
**Supersedes:** ADR-0011 (provider-agnostic issue tracker with file-based default — archived during refactoring)
|
||||
|
||||
> **Note on the ADR-0011 number.** Every "ADR-0011" on this page means the *archived* provider-agnostic issue tracker ADR, which no longer exists in `docs/adr/` — it was removed when it was superseded, and the number 0011 was later reused for an unrelated decision, `docs/adr/0011-gitea-skill-deep-modules.md` (the gitea skill's split into deep modules). That file is not the ADR referenced below. The number is not renumbered here: these ADRs are a published record and renumbering would break every citation that already points at either one. The archived text is recoverable from git history.
|
||||
|
||||
ADR-0011 established a provider-agnostic model with `docs/issues/NNNN-<slug>.md` as the file-based default, switching to Gitea MCP at runtime when available. The interim model was justified because Gitea would not be configured until after Chunk 3, and the repo needed to work before then.
|
||||
|
||||
Gitea is now configured and in active use. The condition in ADR-0011 has been met. This ADR supersedes it.
|
||||
|
||||
@@ -65,8 +65,13 @@ cannot be expressed as a `--config` argument at all — the config path has to b
|
||||
the process, from the script's own location. `vale-wrap.sh` accordingly defaults to its sibling
|
||||
`assets/vale/.vale.ini`, resolved from `${BASH_SOURCE[0]}`, whenever no `--config` is supplied;
|
||||
an explicit `--config` from any other caller still wins and still resolves against the caller's
|
||||
cwd, so both audit skills' Step 1 (`--config assets/vale/.vale.ini`) is unaffected. Both
|
||||
manifests now carry the identical argument-free `entry:`. Keeping them identical is part of the
|
||||
cwd. Both audit skills' Step 1 passes no `--config` either, for the same reason and one more: a
|
||||
relative `--config assets/vale/.vale.ini` resolves against the cwd, not against the skill
|
||||
directory the wrapper path was resolved from, so it yields `E100 Runtime error … does not exist`
|
||||
and exit 2 — which both skills' fallback misreads as "vale unavailable" and silently downgrades
|
||||
to full LLM judgment, the exact failure the self-location exists to prevent. Both `SKILL.md` Step
|
||||
1 sections say so explicitly ("Pass no `--config`"), and both manifests now carry the identical
|
||||
argument-free `entry:`. Keeping them identical is part of the
|
||||
decision: the local `repo: local` hook resolved its `--config` correctly only because the
|
||||
consuming repo *was* this repo, and that one difference is why three review rounds exercised a
|
||||
code path no external consumer ever takes.
|
||||
|
||||
@@ -52,7 +52,8 @@ new hand-maintained manifest format.
|
||||
`apm-orchestrate` (issue #88, already landed on this branch) fully replace what these two skills
|
||||
did. `plugin-author`/`marketplace-author` were deleted in issue #90's execution.
|
||||
- Translating the existing plugins into `apm.yml` + `.apm/` and running the real conversion was
|
||||
executed in issue #90 (https://git.dev.rkdr.net/Defame1297/holocron/issues/90, closed).
|
||||
executed under issue #90 (https://git.dev.rkdr.net/Defame1297/holocron/issues/90), which tracks
|
||||
that work through to merge.
|
||||
- `CONTEXT.md`'s "Plugin"/"Plugin marketplace" glossary entries were rewritten in issue #90 to
|
||||
describe the compiled-output model directly, rather than carrying a forward-pointer to this ADR.
|
||||
|
||||
@@ -119,9 +120,34 @@ correction) sorted what they document into three buckets:
|
||||
issue #90 had to re-author plugin-scope agents: `.apm/agents/*.agent.md` compiles verbatim to
|
||||
both Claude and Copilot, so those files carry only `name`/`description`/`model`/`source_keys` —
|
||||
existing dual-file `<name>.md`+`<name>.agent.md` pairs could not be raw-moved, only re-authored.
|
||||
- Two follow-up issues tracked the remaining work, both done: #89 (`skill-author`/`agent-author`
|
||||
routing adaptation, merged in #93) and #90 (the actual repo conversion, which also deleted
|
||||
`plugin-author`/`marketplace-author`).
|
||||
- Two follow-up issues tracked the remaining work: #89 (`skill-author`/`agent-author` routing
|
||||
adaptation — closed, merged in #93) and #90 (the actual repo conversion, which also deleted
|
||||
`plugin-author`/`marketplace-author` — tracked through to merge; treat #90's own state as the
|
||||
authority on whether it has landed, not this line).
|
||||
- **`displayName` is gone from all six compiled `plugin.json` files, and `owner.email` from the
|
||||
marketplace manifest — accepted, not overlooked.** `apm.yml` has no key that compiles to either,
|
||||
so the conversion dropped both: every `plugins/<name>/.claude-plugin/plugin.json` now carries
|
||||
`author`/`description`/`homepage`/`keywords`/`license`/`name`/`repository`/`version` (plus
|
||||
`mcpServers` for `bin`) and no `displayName`, and `.claude-plugin/marketplace.json`'s `owner`
|
||||
block is `{name, url}` only. Both fields are optional —
|
||||
`plugins/kyberforge/docs/research/docs/claude-code-plugins/api-reference.md:14` lists
|
||||
`displayName` as `Required: No`, "Human-readable name shown in plugin manager" — which is why
|
||||
`claude plugin validate --strict` still passes on all six. The visible cost is that the plugin
|
||||
manager falls back to the bare `name` as each plugin's label. Accepted as the price of `apm.yml`
|
||||
being the single authoring source: re-injecting `displayName` post-compile would mean a second
|
||||
`reinject_*` workaround of the kind ADR-0017's amendment reserves for fields apm strips on a
|
||||
factually wrong premise, and apm's premise here is simply that the key does not exist in its
|
||||
schema.
|
||||
- **`mattpocock-skills` is now version-pinned, and the pin is maintained by hand.** Pre-conversion
|
||||
the entry was `{"repo": "mattpocock/skills", "source": "github"}` — an unpinned reference that
|
||||
tracked the upstream default branch, so consumers got whatever was on it at install time. Root
|
||||
`apm.yml` now declares `version: "^1.2.0"` for it, which `apm pack` resolves and freezes into
|
||||
`.claude-plugin/marketplace.json` as `ref: v1.2.3` + an explicit `sha`. Consumers get a
|
||||
reproducible version instead of a moving target, which is the improvement; the cost is that
|
||||
nothing advances it. apm has no version-bump automation (established under "Versioning" in issue
|
||||
#90's plan), so picking up a new upstream release means a human editing the `version:` range in
|
||||
root `apm.yml` and re-running `apm pack`. Left un-bumped, the marketplace pins an ageing release
|
||||
indefinitely and silently.
|
||||
- **Caveat on "Status: executed" above:** issue #90's own execution comment flagged, before merge,
|
||||
that Claude Code's ability to actually load content out of `.apm/` was unverified — that caveat
|
||||
turned out to be a real defect, not a formality: the native installer has zero awareness of
|
||||
|
||||
@@ -9,7 +9,9 @@ could not.
|
||||
|
||||
**Status: executed (2026-08-13, issue #90).** `scripts/sync-plugin-content.sh` has been run
|
||||
against all 6 plugins; flat `agents/`, `skills/`, `commands/` (etc., wherever `.apm/` populates
|
||||
them), and a merged `hooks.json` now exist at each plugin root as tracked, generated files.
|
||||
them), and a merged hooks file now exist at each plugin root as tracked, generated files. The
|
||||
merged hooks file lands at `hooks/hooks.json`, not at the plugin root itself — see the second
|
||||
amendment below, which corrects the path this ADR originally recorded.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -57,8 +59,9 @@ plugins from.
|
||||
|
||||
Each plugin root gains a second, generated content category, produced by
|
||||
`scripts/sync-plugin-content.sh` (wraps `apm pack --format plugin`, copies the resulting bundle's
|
||||
`agents/`, `skills/`, `commands/`, `instructions/`, `extensions/`, and merged `hooks.json` back to
|
||||
the plugin root) — same governance status as `.claude-plugin/plugin.json`/`marketplace.json`:
|
||||
`agents/`, `skills/`, `commands/`, `instructions/`, `extensions/`, and merged hooks file back to
|
||||
the plugin root — the hooks file to `hooks/hooks.json`, per the second amendment below) — same
|
||||
governance status as `.claude-plugin/plugin.json`/`marketplace.json`:
|
||||
**compiled output of `.apm/`, never hand-edited.**
|
||||
|
||||
- `.apm/` remains the sole hand-edited authoring source, unchanged from ADR-0015.
|
||||
@@ -69,10 +72,21 @@ the plugin root) — same governance status as `.claude-plugin/plugin.json`/`mar
|
||||
`plugin.json` is already correctly generated by a separate, already-verified apm code path
|
||||
(`build_plugin_manifest`, run in the same `apm pack` invocation); `.mcp.json` is hand-authored
|
||||
at the plugin root per ADR-0015 and is not an `.apm/` primitive.
|
||||
- Drift is enforced by a pre-push gate (`scripts/sync-plugin-content.sh --check`, wired into
|
||||
- Dev-fixture `tests/` directories are excluded too — they are dev-time fixtures no plugin host
|
||||
ever needs to discover, and several reference their own repo root through a hardcoded relative
|
||||
walk-up sized for `.apm/`-nested depth, so a copy one directory level shallower breaks the
|
||||
duplicate and double-runs the original under repo-wide bats discovery. The exclusion is
|
||||
**depth-scoped to `<category>/<name>/tests`**, deliberately: a skill may legitimately ship a
|
||||
directory literally named `tests` as a template asset it scaffolds *from*
|
||||
(`skills/skill-author/assets/templates/tests`, at depth 4). A depth-agnostic `-name tests`
|
||||
matched that too and stripped it, making the mirrored `new-skill.sh` die mid-run on
|
||||
`sed: can't read .../tests/README.md` — the scaffolder seds its way through the template tree
|
||||
file by file. Scaffolding assets survive; fixtures do not.
|
||||
- Drift is enforced by a pre-push gate (`scripts/sync-plugin-content.sh --check --all`, wired into
|
||||
`.pre-commit-config.yaml` as hook id `check-plugin-content-sync` by a parallel workstream on
|
||||
issue #90) — the same enforcement model `check-manifests.sh` already applies to the other
|
||||
compiled-output category.
|
||||
compiled-output category. `--check` alone is not the gate: the script requires either `--all` or
|
||||
an explicit list of plugin directories, and run bare it prints usage and exits 1.
|
||||
- Verified two ways before landing: `claude plugin validate --strict` passes on all 6 real
|
||||
(non-scratch) plugin directories, and a live behavioral test
|
||||
(`claude --plugin-dir plugins/kyberforge -p "list your skills and agents"`) against the real
|
||||
@@ -125,24 +139,61 @@ explicit `mcpServers` declaration redundant, the way there is for skills/agents/
|
||||
the same "don't fight a stable, intentional apm code path" reasoning here would mean shipping a
|
||||
plugin manifest known to be missing a field Copilot actually reads.
|
||||
|
||||
Given that, `scripts/sync-plugin-content.sh`'s `reinject_mcp_servers()` (line 190, called from
|
||||
`sync_one()` at line 269, real syncs only) narrowly re-injects `mcpServers` into
|
||||
`.github/plugin/plugin.json` after a real sync, sourced from the plugin's own `.mcp.json`, and
|
||||
only when it declares at least one server — matching apm's own Claude-ecosystem builder, which
|
||||
omits the field entirely rather than emitting `mcpServers: {}`. This is scoped to one field found
|
||||
to be incorrectly stripped, not a reversal of the broader position above: the rejection of
|
||||
patching `skills`/`agents`/`commands`/`hooks` pointers still holds, since apm's premise for
|
||||
stripping those remains accurate.
|
||||
Given that, `scripts/sync-plugin-content.sh`'s `reinject_mcp_servers()`, called from `sync_one()`,
|
||||
narrowly re-injects `mcpServers` into `.github/plugin/plugin.json` after `apm pack` runs, sourced
|
||||
from the plugin's own `.mcp.json`, and only when it declares at least one server — matching apm's
|
||||
own Claude-ecosystem builder, which omits the field entirely rather than emitting
|
||||
`mcpServers: {}`. **Both modes re-inject**, not just real syncs: real mode writes into the plugin
|
||||
root directly, `--check` into its throwaway copy first, so the manifest diff compares against the
|
||||
same content a real sync would actually produce (see the script's own header). A check-mode
|
||||
re-injection is what keeps `--check` from reporting permanent phantom drift on every plugin that
|
||||
ships an `.mcp.json`. This is scoped to one field found to be incorrectly stripped, not a
|
||||
reversal of the broader position above: the rejection of patching
|
||||
`skills`/`agents`/`commands`/`hooks` pointers still holds, since apm's premise for stripping those
|
||||
remains accurate.
|
||||
|
||||
Consequence: if a future apm release corrects the Copilot `mcpServers` omission, `reinject_mcp_servers()`
|
||||
and its call site become dead code and should be deleted — nothing else in this ADR depends on the
|
||||
reinjection existing beyond working around this specific upstream gap.
|
||||
|
||||
Line numbers are deliberately omitted above. An earlier revision of this amendment cited
|
||||
`reinject_mcp_servers()` at line 190 and its call site at line 269; both had already moved by the
|
||||
next review round of the same PR, and moved again with the edits recorded in the amendment below.
|
||||
A function name is stable enough to grep for; a line number in an ADR is stale by the next commit.
|
||||
|
||||
## Amendment (2026-08-14): the merged hooks file lands at `hooks/hooks.json`, not the plugin root
|
||||
|
||||
As originally executed, `sync-plugin-content.sh` wrote the merged hooks file to
|
||||
`plugins/<name>/hooks.json`. That path is scanned by nothing. Claude Code convention-scans
|
||||
`hooks/hooks.json`, and the "Plugin Directory Layout" table this ADR's own root-cause analysis
|
||||
quotes above says so on the same line it says "All content directories must be at the plugin root,
|
||||
not inside `.claude-plugin/`"
|
||||
(`plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md:100`). The
|
||||
implementation read "at the plugin root" and dropped the file there; the row it was reading names
|
||||
`hooks/hooks.json`. So this ADR shipped with the contract quoted correctly in its diagnosis and
|
||||
violated in its output — the flat mirror bridged skills and agents into discovery and left hooks
|
||||
exactly as undiscoverable as before the fix.
|
||||
|
||||
The merged file therefore moves to `plugins/<name>/hooks/hooks.json`. A root-level `hooks.json`
|
||||
left over from a prior sync is stale output: a real sync deletes it, `--check` reports it as
|
||||
drift. The real sync produced exactly these working-tree changes — `plugins/kyberforge/hooks.json`
|
||||
and `plugins/lint/hooks.json` deleted, `plugins/kyberforge/hooks/hooks.json` and
|
||||
`plugins/lint/hooks/hooks.json` created. Only those two plugins have an `.apm/hooks/` tree, so
|
||||
only those two grow a mirrored hooks file at all.
|
||||
|
||||
This does **not** reopen the "patch `plugin.json` pointer fields" option rejected above. The move
|
||||
needs no `hooks` pointer in `plugin.json`: `hooks/hooks.json` *is* the convention path, so the
|
||||
host finds it by auto-discovery, exactly as it finds `skills/` and `agents/`. The rejection stands
|
||||
for the reason it was made — apm's `build_plugin_manifest` strips pointer fields unconditionally
|
||||
and is right to, because convention directories need no pointer. Writing to the convention path is
|
||||
what makes that premise true here rather than something to fight.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Git now tracks real, visible duplication: `.apm/skills/<name>/SKILL.md` and
|
||||
`skills/<name>/SKILL.md` both exist and must match, likewise `.apm/agents/*.agent.md` vs.
|
||||
`agents/*.agent.md`, and `.apm/hooks/*.json` vs. the merged `hooks.json`. This is an accepted
|
||||
`agents/*.agent.md`, and `.apm/hooks/*.json` vs. the merged `hooks/hooks.json` (see the
|
||||
2026-08-14 amendment above for that path). This is an accepted
|
||||
tradeoff of bridging a gap apm itself doesn't close, not a bug — `.apm/` stays the single
|
||||
hand-edited source, and the drift gate (`check-plugin-content-sync`) is what keeps the mirror
|
||||
honest rather than trusting authors to remember to regenerate it by hand.
|
||||
|
||||
@@ -25,7 +25,14 @@ Skills are **not** deployed by `install.sh`. They are distributed as plugins and
|
||||
|
||||
## Plugin model
|
||||
|
||||
Skills, agents, MCP servers, and hooks are distributed as self-contained plugin units under `plugins/`. Each plugin has a `plugin.json` manifest and is installed independently via `claude plugin install`.
|
||||
Skills, agents, MCP servers, and hooks are distributed as self-contained plugin units under `plugins/`, installed independently via `claude plugin install <name>@holocron`. Each plugin is an **apm package**: `plugins/<name>/apm.yml` plus a hand-authored `plugins/<name>/.apm/{skills,agents,hooks,commands,instructions,extensions}/` tree (ADR-0015). There is no hand-maintained `plugin.json` — every manifest and every host-visible content directory is compiled from that source.
|
||||
|
||||
Two compilers produce the plugin roots you see in the tree:
|
||||
|
||||
- **`apm pack` compiles the manifests** (ADR-0015). Per plugin: `.claude-plugin/plugin.json` and `.github/plugin/plugin.json`, both generated from `plugins/<name>/apm.yml`. Repo-wide, from the root `apm.yml`'s `marketplace:` block: `.claude-plugin/marketplace.json` (apm's `claude` output profile) and `.agents/plugins/marketplace.json` (its `codex` profile, a differently-shaped file). Those two are the only marketplace outputs apm has profiles for — the third root manifest, `.github/plugin/marketplace.json` (Copilot CLI's legacy path), is a byte-identical mirror of the Claude one maintained by `scripts/sync-marketplace-mirror.sh` and gated by the `check-marketplace-mirror-sync` pre-push hook.
|
||||
- **`scripts/sync-plugin-content.sh` compiles the content mirror** (ADR-0017). It wraps `apm pack --format plugin` and copies the resulting bundle's flat `agents/`, `skills/`, `commands/`, `instructions/`, `extensions/`, and merged `hooks/hooks.json` back to the plugin root. Claude Code's installer convention-scans those flat paths and has no `.apm/` awareness whatsoever, so the mirror exists solely to satisfy the host's discovery contract.
|
||||
|
||||
`.apm/` is the sole hand-edited authoring source for plugin content. An edit made in the flat mirror is discarded by the next sync and is reported as drift by the `check-plugin-content-sync` pre-push hook. Hand-authored material that is not an `.apm/` primitive — `README.md`, `docs/`, `bin/`, `sources.md`, and `.mcp.json` — lives at the plugin root and is untouched by either compiler.
|
||||
|
||||
## Governance layer
|
||||
|
||||
@@ -37,7 +44,7 @@ The governance layer has two phases:
|
||||
|
||||
## AGENTS.md pattern
|
||||
|
||||
This repo uses two `AGENTS.md` files as the provider-agnostic source of always-on rules (ADR-0012):
|
||||
This repo uses two `AGENTS.md` files as the provider-agnostic source of always-on rules (ADR-0003):
|
||||
|
||||
- **Repo-level `AGENTS.md`** — instructions for agents working inside this repo (structure, key rules). Imported by repo `CLAUDE.md` via `@AGENTS.md`.
|
||||
- **Global `core/AGENTS.md`** — Communication and Behavior rules that apply across all projects. Deployed to `~/.agents/AGENTS.md`; imported by `~/.claude/CLAUDE.md` via `@~/.agents/AGENTS.md`.
|
||||
|
||||
@@ -31,9 +31,11 @@ copilot plugin install ./plugins/bin
|
||||
## Contents
|
||||
|
||||
| Component | Path | Description |
|
||||
| -------------| ------------------------------------------------------| ---------------------------------------------------------------|
|
||||
| Skills | `skills/` | Slash commands available after install |
|
||||
| Agents | `agents/` | Role-based agents (`.md` for Claude, `.agent.md` for Copilot) |
|
||||
|---|---|---|
|
||||
| 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 and reinjected into both compiled `plugin.json` manifests |
|
||||
|
||||
`.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.
|
||||
|
||||
## Author
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "core",
|
||||
"version": "1.1.0",
|
||||
"description": "Cross-cutting utility skills for everyday AI-assisted coding \u2014 triage, diagnosis, architecture review, and session navigation.",
|
||||
"version": "1.1.1",
|
||||
"description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
"email": "defame1297@rkdr.net",
|
||||
@@ -11,10 +11,10 @@
|
||||
"homepage": "https://git.dev.rkdr.net/Defame1297/holocron/src/branch/main/plugins/core",
|
||||
"repository": "https://git.dev.rkdr.net/Defame1297/holocron/src/branch/main/plugins/core",
|
||||
"keywords": [
|
||||
"cross-cutting",
|
||||
"triage",
|
||||
"diagnose",
|
||||
"architecture",
|
||||
"debug"
|
||||
"agents-md",
|
||||
"documentation",
|
||||
"audit",
|
||||
"provider-adapter",
|
||||
"governance"
|
||||
]
|
||||
}
|
||||
|
||||
14
plugins/core/.github/plugin/plugin.json
vendored
14
plugins/core/.github/plugin/plugin.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "core",
|
||||
"version": "1.1.0",
|
||||
"description": "Cross-cutting utility skills for everyday AI-assisted coding \u2014 triage, diagnosis, architecture review, and session navigation.",
|
||||
"version": "1.1.1",
|
||||
"description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
"email": "defame1297@rkdr.net",
|
||||
@@ -11,10 +11,10 @@
|
||||
"homepage": "https://git.dev.rkdr.net/Defame1297/holocron/src/branch/main/plugins/core",
|
||||
"repository": "https://git.dev.rkdr.net/Defame1297/holocron/src/branch/main/plugins/core",
|
||||
"keywords": [
|
||||
"cross-cutting",
|
||||
"triage",
|
||||
"diagnose",
|
||||
"architecture",
|
||||
"debug"
|
||||
"agents-md",
|
||||
"documentation",
|
||||
"audit",
|
||||
"provider-adapter",
|
||||
"governance"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# core
|
||||
|
||||
Cross-cutting utility skills for everyday AI-assisted coding — triage, diagnosis, architecture review, and session navigation.
|
||||
Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -32,7 +32,9 @@ copilot plugin install ./plugins/core
|
||||
|
||||
| Component | Path | Description |
|
||||
|---|---|---|
|
||||
| Skills | `skills/` | Slash commands available after install |
|
||||
| Skills | `.apm/skills/` → `skills/` | Slash commands available after install |
|
||||
|
||||
`.apm/` is the authoring source; `skills/` is the generated mirror plugin hosts scan (ADR-0017).
|
||||
|
||||
## Skills
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: core
|
||||
version: 1.1.0
|
||||
description: Cross-cutting utility skills for everyday AI-assisted coding — triage, diagnosis, architecture review, and session navigation.
|
||||
version: 1.1.1
|
||||
description: Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.
|
||||
author:
|
||||
name: Defame1297
|
||||
email: defame1297@rkdr.net
|
||||
@@ -9,11 +9,11 @@ license: MIT
|
||||
homepage: https://git.dev.rkdr.net/Defame1297/holocron/src/branch/main/plugins/core
|
||||
repository: https://git.dev.rkdr.net/Defame1297/holocron/src/branch/main/plugins/core
|
||||
keywords:
|
||||
- cross-cutting
|
||||
- triage
|
||||
- diagnose
|
||||
- architecture
|
||||
- debug
|
||||
- agents-md
|
||||
- documentation
|
||||
- audit
|
||||
- provider-adapter
|
||||
- governance
|
||||
|
||||
# Constrains what .apm/ may contain: instructions, skill, hybrid, or prompts
|
||||
type: skill
|
||||
|
||||
@@ -7,3 +7,11 @@
|
||||
- **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md
|
||||
- **Contributing files:** SKILL.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## apm-github-repo
|
||||
|
||||
- **URL:** https://github.com/microsoft/apm
|
||||
- **Description:** APM's own Python source (`src/apm_cli/`), read directly for schema and compile-mapping ground truth where Context7's doc snippets were thin. Informed the research doc's primitive-schema and releasing pages; nothing in this skill's install/runtime guidance derives from it.
|
||||
- **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md
|
||||
- **Contributing files:** (none)
|
||||
- **Status:** `referenced`
|
||||
|
||||
@@ -37,6 +37,31 @@ version: 1.0.0
|
||||
|
||||
See `docs/research/docs/microsoft-apm/configuration.md` for the complete annotated schema.
|
||||
|
||||
## Bumping a package's own version (repo policy)
|
||||
|
||||
apm ships no version-bump command, so `version:` in a package's own `apm.yml` is a hand edit.
|
||||
Policy: **bump a package's own `apm.yml` `version:` whenever anything that reaches its compiled
|
||||
output changes.** Two triggers, not one:
|
||||
|
||||
- **Its `.apm/` content** — a new or removed skill/agent/hook, or a substantive edit to an
|
||||
existing one.
|
||||
- **Its own `apm.yml` manifest metadata** — `description`, `keywords`, `author`, `license`,
|
||||
`homepage`, `repository`. These are compiled verbatim into `.claude-plugin/plugin.json` and
|
||||
`.github/plugin/plugin.json`, so editing them republishes the package's public description
|
||||
under an unchanged version number, which is the same defect as shipping changed content
|
||||
silently. Purely local edits that reach no compiled output — a `README.md`, a `docs/` page —
|
||||
do not require a bump on their own.
|
||||
|
||||
The version belongs to the package, not to the repo: editing `plugins/foo/.apm/` never bumps
|
||||
`plugins/bar/apm.yml`.
|
||||
|
||||
Under a `per_package` strategy the same number is also carried in the catalog's
|
||||
`marketplace.packages[]` entry, so both copies move together in the same commit. The catalog's own
|
||||
version follows a separate rule — see `references/marketplace.md`. `apm pack --check-versions`
|
||||
fails the push when a package's version disagrees with the configured strategy, so a bump applied
|
||||
in only one of the two places is caught, but a bump skipped in both is not: nothing infers intent
|
||||
from a content diff.
|
||||
|
||||
## Dependency reference forms
|
||||
|
||||
`dependencies.apm` entries accept: a pinned tag (`owner/repo#tag`), a plain repo (uses default branch), a single primitive path within a repo, a raw git URL, a `git:`/`path:`/`ref:` object for finer control, or a local relative path (`./packages/my-shared-skills`).
|
||||
|
||||
@@ -4,6 +4,7 @@ source_keys:
|
||||
- context7-microsoft-apm
|
||||
- context7-websites-code-claude
|
||||
- claude-code-plugins-docs
|
||||
- apm-github-repo
|
||||
---
|
||||
|
||||
## Building a marketplace from a producer repo
|
||||
@@ -68,6 +69,33 @@ marketplace:
|
||||
|
||||
Without this block, the default versioning strategy ties every listed package to the marketplace/root version.
|
||||
|
||||
## Bumping the catalog's own version (repo policy)
|
||||
|
||||
The section above is apm's *mechanic* — how per-package versions are declared and how
|
||||
`marketplace.versioning.strategy` resolves them. This section is *policy*: apm has no version-bump
|
||||
automation, so the one number apm will never pick for you is the catalog's own
|
||||
`marketplace.version` in the root `apm.yml`. Bump it by hand, in the same commit as the change that
|
||||
earned it:
|
||||
|
||||
- **Minor** when a `marketplace.packages[]` entry is added or removed. The catalog's contents
|
||||
changed — a consumer resolving it now gets a different set of installable packages.
|
||||
- **Patch** when only `marketplace:`-block fields change and the set of packages is unchanged: the
|
||||
catalog description, owner, `build:`/`outputs:` config, or an existing entry's `version:`,
|
||||
description, or category. The catalog describes the same packages; only its metadata moved. An
|
||||
entry's `version:` is the most frequent of these by far — under `per_package` it moves here every
|
||||
time any package bumps (see `references/configure.md`), and that alone earns the catalog patch.
|
||||
|
||||
Keep the root `apm.yml`'s top-level `version:` in step with `marketplace.version`. They are separate
|
||||
keys — the top-level one is not inherited into the compiled `marketplace.json`, but `apm audit`
|
||||
reads it — so letting them diverge produces two different answers to "what version is this catalog".
|
||||
|
||||
Editing a package's *own* `.apm/` content is a different bump against a different number — see
|
||||
`references/configure.md`. Note what the tooling does and does not catch: `apm pack --check-clean`
|
||||
regenerates every marketplace output and diffs it against what is on disk, so a bump made in
|
||||
`apm.yml` but never re-packed fails the gate. A bump you never made at all fails nothing — no apm
|
||||
flag infers a required version change from a content diff, which is why this is policy and not a
|
||||
hook.
|
||||
|
||||
## Which mechanism to use
|
||||
|
||||
- **Local packages, no distribution needed yet** — local-path dependencies in `apm.yml` (`./packages/my-shared-skills`); no marketplace or registry involved.
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
- **Contributing files:** SKILL.md, references/configure.md, references/install.md, references/marketplace.md, references/compile.md, references/audit.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## apm-github-repo
|
||||
|
||||
- **URL:** https://github.com/microsoft/apm
|
||||
- **Description:** APM's own Python source (`src/apm_cli/`), read directly for schema and compile-mapping ground truth where Context7's doc snippets were thin. Backs the marketplace output-profile claim (`apm_cli/marketplace/output_profiles.py` — only `claude` and `codex` profiles exist, so there is no native Copilot marketplace output) and the `apm pack --check-clean`/`--check-versions` semantics.
|
||||
- **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md
|
||||
- **Contributing files:** references/marketplace.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## context7-websites-code-claude
|
||||
|
||||
- **URL:** context7:/websites/code_claude
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kyberforge",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kyberforge",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
|
||||
@@ -30,25 +30,31 @@ copilot plugin install ./plugins/kyberforge
|
||||
|
||||
## Contents
|
||||
|
||||
Authoring source lives in `.apm/`. The `skills/`, `agents/`, and `hooks/hooks.json` paths below are the generated mirror that plugin hosts actually scan — produced by `scripts/sync-plugin-content.sh`, never hand-edited (ADR-0017).
|
||||
|
||||
| Component | Path | Description |
|
||||
|---|---|---|
|
||||
| Skills | `skills/` | Slash commands available after install |
|
||||
| Agents | `agents/` | Role-based agents (`.md` for Claude, `.agent.md` for Copilot) |
|
||||
| Hooks | `hooks/hooks.json` (Claude) / `hooks.json` (Copilot) | Event-triggered automation |
|
||||
| MCP servers | `.mcp.json` | Model Context Protocol server definitions |
|
||||
| Skills | `.apm/skills/` → `skills/` | Slash commands available after install |
|
||||
| Agents | `.apm/agents/*.agent.md` → `agents/` | Role-based agents; one vendor-neutral `.agent.md` per agent, copied verbatim to both targets (ADR-0016) |
|
||||
| Hooks | `.apm/hooks/` → `hooks/hooks.json` | Event-triggered automation — read by Claude Code only, see below |
|
||||
| MCP servers | `.mcp.json` | Model Context Protocol server definitions (hand-authored at the plugin root) |
|
||||
|
||||
**Hooks are Claude Code-only in practice.** Claude Code convention-scans `hooks/hooks.json` at the plugin root, so that file is live. Copilot CLI has no default hooks path — `agents` and `skills` default to `agents/` and `skills/`, but `hooks` defaults to nothing (`docs/research/docs/github-copilot-plugins/configuration.md:47`), so Copilot reads hooks only via an explicit `"hooks": "hooks.json"` pointer in `plugin.json` (`docs/research/docs/github-copilot-plugins/examples.md:49`). apm's manifest compiler strips pointer fields from every manifest it generates, so neither `.claude-plugin/plugin.json` nor `.github/plugin/plugin.json` carries one, and re-injecting it is the option ADR-0017 explicitly rejected. Copilot therefore loads no hooks from this plugin — and did not load them from the old root-level `hooks.json` either. The Copilot half of this row is aspirational, not current behaviour.
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | Description |
|
||||
|---|---|
|
||||
| `forge` | Grill an unclassified "I want to add something" request, decide whether it's a skill, agent, plugin, or marketplace entry, then route to the matching author skill |
|
||||
| `skill-author` | Create or improve a skill from scratch, audit findings, or inline feedback |
|
||||
| `skill-audit` | Audit a skill directory against the agentskills.io spec and produce a findings report |
|
||||
| `agent-author` | Author an agent definition file |
|
||||
| `pc-author` | Create, add, remove, and configure `.pre-commit-config.yaml` |
|
||||
| `pc-run` | Install, run, autoupdate, and maintain the pre-commit setup |
|
||||
| `agent-audit` | Audit an agent definition across structure, provider safety, description and body quality, and provenance; produces a findings report |
|
||||
| `apm-install` | Install or upgrade the apm CLI and set up the agent runtimes it drives (Copilot CLI, Codex, Gemini, generic llm) |
|
||||
| `apm-workflow` | Author apm.yml, scaffold an apm package/marketplace, install dependencies, and compile/pack/publish/audit apm content |
|
||||
|
||||
Pre-commit tooling (`pc-author`, `pc-run`) lives in the `git` plugin, not here.
|
||||
|
||||
## Author
|
||||
|
||||
Defame1297
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: kyberforge
|
||||
version: 1.4.0
|
||||
version: 1.4.1
|
||||
description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.
|
||||
author:
|
||||
name: Defame1297
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
Plugin documentation. Not read automatically by Claude Code or GitHub Copilot CLI — reference specific files from skill bodies or agent prompts as needed.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `plugin-marketplace-architecture.md` | Claude Code vs Copilot CLI divergence, two-manifest pattern, plugin constraints, cross-tool compatibility |
|
||||
| `adding-agents.md` | How to add agent definitions to the kyberforge plugin |
|
||||
This directory currently holds no standalone documents of its own — everything under it is research material.
|
||||
|
||||
## research/
|
||||
|
||||
@@ -16,5 +11,9 @@ Upstream reference material gathered during skill authoring. Not shipped with th
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `research/docs/agentskillsio/` | agentskills.io spec, skill authoring, description optimization, eval design, scripts |
|
||||
| `research/docs/agentsmd/` | agents.md format spec and cross-tool configuration reference |
|
||||
| `research/examples/skill-write/` | Upstream skill examples reviewed when authoring skill-write and skill-audit |
|
||||
| `research/docs/claude-code-plugins/` | Claude Code plugin and marketplace manifests, plugin directory layout, agent definition format, `claude plugin validate` behaviour |
|
||||
| `research/docs/github-copilot-plugins/` | Copilot CLI plugin manifest and marketplace format, agent definition format, Copilot extensions and SDK |
|
||||
| `research/docs/microsoft-apm/` | apm CLI reference, `apm.yml` schema, primitive schemas (agent, prompt, instructions, hooks), monorepo repo shapes, marketplace/registries, packing and releasing |
|
||||
| `research/examples/skill-write/` | Upstream skill examples reviewed when authoring skill-author and skill-audit |
|
||||
|
||||
AGENTS.md research moved to `plugins/core/docs/research/docs/agentsmd/` when the AGENTS.md skills landed in the `core` plugin (ADR-0012) — it is no longer part of kyberforge's provenance chain.
|
||||
|
||||
@@ -7,3 +7,11 @@
|
||||
- **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md
|
||||
- **Contributing files:** SKILL.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## apm-github-repo
|
||||
|
||||
- **URL:** https://github.com/microsoft/apm
|
||||
- **Description:** APM's own Python source (`src/apm_cli/`), read directly for schema and compile-mapping ground truth where Context7's doc snippets were thin. Informed the research doc's primitive-schema and releasing pages; nothing in this skill's install/runtime guidance derives from it.
|
||||
- **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md
|
||||
- **Contributing files:** (none)
|
||||
- **Status:** `referenced`
|
||||
|
||||
@@ -37,6 +37,31 @@ version: 1.0.0
|
||||
|
||||
See `docs/research/docs/microsoft-apm/configuration.md` for the complete annotated schema.
|
||||
|
||||
## Bumping a package's own version (repo policy)
|
||||
|
||||
apm ships no version-bump command, so `version:` in a package's own `apm.yml` is a hand edit.
|
||||
Policy: **bump a package's own `apm.yml` `version:` whenever anything that reaches its compiled
|
||||
output changes.** Two triggers, not one:
|
||||
|
||||
- **Its `.apm/` content** — a new or removed skill/agent/hook, or a substantive edit to an
|
||||
existing one.
|
||||
- **Its own `apm.yml` manifest metadata** — `description`, `keywords`, `author`, `license`,
|
||||
`homepage`, `repository`. These are compiled verbatim into `.claude-plugin/plugin.json` and
|
||||
`.github/plugin/plugin.json`, so editing them republishes the package's public description
|
||||
under an unchanged version number, which is the same defect as shipping changed content
|
||||
silently. Purely local edits that reach no compiled output — a `README.md`, a `docs/` page —
|
||||
do not require a bump on their own.
|
||||
|
||||
The version belongs to the package, not to the repo: editing `plugins/foo/.apm/` never bumps
|
||||
`plugins/bar/apm.yml`.
|
||||
|
||||
Under a `per_package` strategy the same number is also carried in the catalog's
|
||||
`marketplace.packages[]` entry, so both copies move together in the same commit. The catalog's own
|
||||
version follows a separate rule — see `references/marketplace.md`. `apm pack --check-versions`
|
||||
fails the push when a package's version disagrees with the configured strategy, so a bump applied
|
||||
in only one of the two places is caught, but a bump skipped in both is not: nothing infers intent
|
||||
from a content diff.
|
||||
|
||||
## Dependency reference forms
|
||||
|
||||
`dependencies.apm` entries accept: a pinned tag (`owner/repo#tag`), a plain repo (uses default branch), a single primitive path within a repo, a raw git URL, a `git:`/`path:`/`ref:` object for finer control, or a local relative path (`./packages/my-shared-skills`).
|
||||
|
||||
@@ -4,6 +4,7 @@ source_keys:
|
||||
- context7-microsoft-apm
|
||||
- context7-websites-code-claude
|
||||
- claude-code-plugins-docs
|
||||
- apm-github-repo
|
||||
---
|
||||
|
||||
## Building a marketplace from a producer repo
|
||||
@@ -68,6 +69,33 @@ marketplace:
|
||||
|
||||
Without this block, the default versioning strategy ties every listed package to the marketplace/root version.
|
||||
|
||||
## Bumping the catalog's own version (repo policy)
|
||||
|
||||
The section above is apm's *mechanic* — how per-package versions are declared and how
|
||||
`marketplace.versioning.strategy` resolves them. This section is *policy*: apm has no version-bump
|
||||
automation, so the one number apm will never pick for you is the catalog's own
|
||||
`marketplace.version` in the root `apm.yml`. Bump it by hand, in the same commit as the change that
|
||||
earned it:
|
||||
|
||||
- **Minor** when a `marketplace.packages[]` entry is added or removed. The catalog's contents
|
||||
changed — a consumer resolving it now gets a different set of installable packages.
|
||||
- **Patch** when only `marketplace:`-block fields change and the set of packages is unchanged: the
|
||||
catalog description, owner, `build:`/`outputs:` config, or an existing entry's `version:`,
|
||||
description, or category. The catalog describes the same packages; only its metadata moved. An
|
||||
entry's `version:` is the most frequent of these by far — under `per_package` it moves here every
|
||||
time any package bumps (see `references/configure.md`), and that alone earns the catalog patch.
|
||||
|
||||
Keep the root `apm.yml`'s top-level `version:` in step with `marketplace.version`. They are separate
|
||||
keys — the top-level one is not inherited into the compiled `marketplace.json`, but `apm audit`
|
||||
reads it — so letting them diverge produces two different answers to "what version is this catalog".
|
||||
|
||||
Editing a package's *own* `.apm/` content is a different bump against a different number — see
|
||||
`references/configure.md`. Note what the tooling does and does not catch: `apm pack --check-clean`
|
||||
regenerates every marketplace output and diffs it against what is on disk, so a bump made in
|
||||
`apm.yml` but never re-packed fails the gate. A bump you never made at all fails nothing — no apm
|
||||
flag infers a required version change from a content diff, which is why this is policy and not a
|
||||
hook.
|
||||
|
||||
## Which mechanism to use
|
||||
|
||||
- **Local packages, no distribution needed yet** — local-path dependencies in `apm.yml` (`./packages/my-shared-skills`); no marketplace or registry involved.
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
- **Contributing files:** SKILL.md, references/configure.md, references/install.md, references/marketplace.md, references/compile.md, references/audit.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## apm-github-repo
|
||||
|
||||
- **URL:** https://github.com/microsoft/apm
|
||||
- **Description:** APM's own Python source (`src/apm_cli/`), read directly for schema and compile-mapping ground truth where Context7's doc snippets were thin. Backs the marketplace output-profile claim (`apm_cli/marketplace/output_profiles.py` — only `claude` and `codex` profiles exist, so there is no native Copilot marketplace output) and the `apm pack --check-clean`/`--check-versions` semantics.
|
||||
- **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md
|
||||
- **Contributing files:** references/marketplace.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## context7-websites-code-claude
|
||||
|
||||
- **URL:** context7:/websites/code_claude
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# tests/
|
||||
|
||||
Test files for scripts bundled with this skill.
|
||||
|
||||
## When to add tests
|
||||
|
||||
Add tests here when the skill has scripts in `scripts/` that are complex enough
|
||||
to break silently — validators, parsers, generators, anything with branching
|
||||
logic or edge cases. Test infrastructure (`.bats`, `*_test.*`, `test_*.sh`)
|
||||
belongs here, not in `scripts/`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tests require [bats-support](https://github.com/bats-core/bats-support) and
|
||||
[bats-assert](https://github.com/bats-core/bats-assert). The test files load
|
||||
helpers from the repo root's `tests/test_helper/`.
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bats-core/bats-support tests/test_helper/bats-support
|
||||
git clone https://github.com/bats-core/bats-assert tests/test_helper/bats-assert
|
||||
```
|
||||
|
||||
Run all tests for this skill (from the repo root):
|
||||
|
||||
```bash
|
||||
bats <destination-dir>/SKILL_NAME/tests/
|
||||
```
|
||||
|
||||
## If no tests are needed
|
||||
|
||||
Delete this README and the `tests/` directory entirely.
|
||||
@@ -19,6 +19,16 @@ set -euo pipefail
|
||||
# those paths resolve. The fallback block below restores that check, but only for
|
||||
# plugins without .apm/ -- apm-native plugins keep relying on the delegation above so
|
||||
# the two checks don't duplicate (and disagree) on the same manifest.
|
||||
#
|
||||
# Both of the above walk marketplace.json -> disk. Nothing walked disk -> marketplace,
|
||||
# so a plugins/<name>/ directory that never made it into marketplace.json was invisible
|
||||
# to every marketplace-derived gate at once (this script and sync-plugin-content.sh
|
||||
# --all both derive their plugin set from marketplace.json). The final block below
|
||||
# closes that direction: per ADR-0015 marketplace.json is compiled output of root
|
||||
# apm.yml's marketplace.packages[], so an on-disk apm package with no entry is
|
||||
# compiled-output drift of exactly the kind ADR-0017 wires pre-push gates for -- and it
|
||||
# is the same plugin set the validate-plugins pre-commit hook already globs as
|
||||
# plugins/*/.
|
||||
|
||||
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
||||
FAIL=0
|
||||
@@ -39,6 +49,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/marketplace-plugins.sh
|
||||
source "$SCRIPT_DIR/lib/marketplace-plugins.sh"
|
||||
|
||||
# Every local plugin directory marketplace.json claimed, canonicalized, so the
|
||||
# disk -> marketplace pass below can tell "listed" from "unlisted" regardless of how
|
||||
# the `source:` string was spelled (./plugins/x, plugins/x, plugins/x/).
|
||||
SEEN_PLUGIN_DIRS=()
|
||||
|
||||
while IFS=$'\t' read -r name plugin_dir; do
|
||||
source_rel="${plugin_dir#"$REPO_ROOT"/}"
|
||||
|
||||
@@ -46,6 +61,9 @@ while IFS=$'\t' read -r name plugin_dir; do
|
||||
err "plugin '$name': source directory not found: $source_rel"
|
||||
continue
|
||||
fi
|
||||
# -P so a plugin directory reached through a symlink compares equal to the same
|
||||
# directory reached directly; the disk-side walk below resolves the same way.
|
||||
SEEN_PLUGIN_DIRS+=("$(cd "$plugin_dir" && pwd -P)")
|
||||
|
||||
manifest="$plugin_dir/.claude-plugin/plugin.json"
|
||||
if [[ ! -f "$manifest" ]]; then
|
||||
@@ -80,6 +98,59 @@ while IFS=$'\t' read -r name plugin_dir; do
|
||||
done
|
||||
done < <(list_marketplace_local_plugins "$REPO_ROOT" "$MARKETPLACE")
|
||||
|
||||
# Disk -> marketplace. The trigger is any of the three markers that make a directory
|
||||
# a plugin rather than scratch -- apm.yml (the ADR-0015 authoring source), .apm/ (its
|
||||
# content tree), or a compiled .claude-plugin/plugin.json. Matching all three keeps
|
||||
# this set aligned with the plugins/*/ glob the validate-plugins pre-commit hook uses,
|
||||
# which is the disagreement this check exists to close; a directory with none of them
|
||||
# is scratch and stays out of scope.
|
||||
#
|
||||
# A candidate counts as listed if it is either a directory some local entry pointed at
|
||||
# (path match, canonicalized above) or a directory whose name matches a REMOTE entry's
|
||||
# name. The name axis exists only for a plugin vendored on disk but declared with the
|
||||
# remote-object `source:` shape: list_marketplace_local_plugins deliberately skips those,
|
||||
# so a path-only match would report a missing entry that is in fact already there.
|
||||
#
|
||||
# It is restricted to non-string sources on purpose. Applied to local entries too, the
|
||||
# name axis silently rescues genuine orphans, because a local entry's name need not equal
|
||||
# the basename of the directory it points at: an entry named "beta" pointing at
|
||||
# ./plugins/alpha would mark an unrelated, entirely unlisted plugins/beta/ as listed.
|
||||
# Local entries already have an exact path to match on, so they need no name fallback.
|
||||
MARKETPLACE_NAMES=()
|
||||
while IFS= read -r entry_name; do
|
||||
[[ -n "$entry_name" ]] && MARKETPLACE_NAMES+=("$entry_name")
|
||||
done < <(jq -r '.plugins[]? | select((.source | type) != "string") | .name // empty' "$MARKETPLACE")
|
||||
|
||||
for candidate in "$REPO_ROOT"/plugins/*/; do
|
||||
candidate="${candidate%/}"
|
||||
[[ -d "$candidate" ]] || continue
|
||||
if [[ ! -f "$candidate/apm.yml" && ! -d "$candidate/.apm" && ! -f "$candidate/.claude-plugin/plugin.json" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
candidate_abs="$(cd "$candidate" && pwd -P)"
|
||||
candidate_name="$(basename "$candidate")"
|
||||
listed=0
|
||||
for seen in ${SEEN_PLUGIN_DIRS[@]+"${SEEN_PLUGIN_DIRS[@]}"}; do
|
||||
if [[ "$seen" == "$candidate_abs" ]]; then
|
||||
listed=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ $listed -eq 0 ]]; then
|
||||
for entry_name in ${MARKETPLACE_NAMES[@]+"${MARKETPLACE_NAMES[@]}"}; do
|
||||
if [[ "$entry_name" == "$candidate_name" ]]; then
|
||||
listed=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ $listed -eq 0 ]]; then
|
||||
err "plugin directory '${candidate#"$REPO_ROOT"/}' has no entry in .claude-plugin/marketplace.json — it is skipped by every marketplace-derived check (this one, and sync-plugin-content.sh --all) while still being globbed by the validate-plugins hook. Add it to root apm.yml's marketplace.packages[] and recompile the manifests."
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
echo "Manifest check failed: $FAIL error(s)" >&2
|
||||
exit 1
|
||||
|
||||
@@ -46,6 +46,18 @@ FIXTURES=()
|
||||
cleanup() { [[ ${#FIXTURES[@]} -eq 0 ]] || rm -rf "${FIXTURES[@]}"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Per-run scratch directory for the per-fixture output captures below. These
|
||||
# used to be fixed paths in the shared system temp directory, which made this
|
||||
# script non-reentrant: tests/run-tests.sh now fans its scripts out
|
||||
# concurrently, and two instances sharing one path clobber each other's
|
||||
# captures. Fixture 6 reads its capture back (`[[ -z "$(cat ...)" ]]`), so a
|
||||
# cross-run write there silently flips a real verdict, and a pre-existing
|
||||
# directory sitting at one of the paths breaks the run outright. Keep these
|
||||
# under a per-run mktemp -d; tests/test-check-scope-walkup-sync.sh asserts it.
|
||||
# Registered in FIXTURES so the single cleanup trap already here removes it.
|
||||
RUN_TMP="$(mktemp -d)"
|
||||
FIXTURES+=("$RUN_TMP")
|
||||
|
||||
# Fill a new-agent.sh-scaffolded pair's FILL IN: placeholders with valid
|
||||
# content, isolating the scope/counterpart-lookup question from unrelated
|
||||
# content-quality FAILs when cross-checking against validate.sh.
|
||||
@@ -78,11 +90,11 @@ else
|
||||
else
|
||||
fill_agent_pair "$F1_HOME/.claude/agents/$NAME1.md" "$NAME1"
|
||||
fill_agent_pair "$F1_HOME/.copilot/agents/$NAME1.agent.md" "$NAME1"
|
||||
if env HOME="$F1_HOME" bash "$VALIDATE" "$F1_HOME/.claude/agents/$NAME1.md" >/tmp/f1.out 2>&1; then
|
||||
if env HOME="$F1_HOME" bash "$VALIDATE" "$F1_HOME/.claude/agents/$NAME1.md" >"$RUN_TMP/f1.out" 2>&1; then
|
||||
ok "validate.sh agrees: user scope, counterpart found under \$HOME/.copilot"
|
||||
else
|
||||
err "validate.sh disagreed with new-agent.sh's user-scope classification at root exactly \$HOME"
|
||||
sed 's/^/ /' /tmp/f1.out
|
||||
sed 's/^/ /' "$RUN_TMP/f1.out"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -111,22 +123,22 @@ else
|
||||
ok "new-agent.sh: nested marker-less dir under \$HOME scaffolds project scope at the nested dir"
|
||||
fill_agent_pair "$F2_NESTED/.claude/agents/$NAME2.md" "$NAME2"
|
||||
fill_agent_pair "$F2_NESTED/.github/agents/$NAME2.agent.md" "$NAME2"
|
||||
if env HOME="$F2_HOME" bash "$VALIDATE" "$F2_NESTED/.claude/agents/$NAME2.md" >/tmp/f2.out 2>&1; then
|
||||
if env HOME="$F2_HOME" bash "$VALIDATE" "$F2_NESTED/.claude/agents/$NAME2.md" >"$RUN_TMP/f2.out" 2>&1; then
|
||||
ok "validate.sh agrees: project scope, counterpart found at the nested dir (not \$HOME/.copilot)"
|
||||
else
|
||||
err "validate.sh disagreed with new-agent.sh: misclassified the nested marker-less \$HOME subdirectory"
|
||||
sed 's/^/ /' /tmp/f2.out
|
||||
sed 's/^/ /' "$RUN_TMP/f2.out"
|
||||
fi
|
||||
# new-skill.sh has no user/project distinction of its own (no $HOME
|
||||
# awareness at all — see new-skill.sh's find_package_root), but it shares
|
||||
# the same .git/apm.yml walk-up primitive. It must land its standalone
|
||||
# scaffold at the given path too, not get redirected toward $HOME.
|
||||
if env HOME="$F2_HOME" bash "$NEW_SKILL" probe-home-nested-skill "$F2_NESTED" >/tmp/f2skill.out 2>&1 \
|
||||
if env HOME="$F2_HOME" bash "$NEW_SKILL" probe-home-nested-skill "$F2_NESTED" >"$RUN_TMP/f2skill.out" 2>&1 \
|
||||
&& [[ -d "$F2_NESTED/probe-home-nested-skill" ]]; then
|
||||
ok "new-skill.sh agrees: standalone mode scaffolds at the nested dir, not redirected toward \$HOME"
|
||||
else
|
||||
err "new-skill.sh disagreed with new-agent.sh/validate.sh on the nested marker-less \$HOME subdirectory"
|
||||
sed 's/^/ /' /tmp/f2skill.out
|
||||
sed 's/^/ /' "$RUN_TMP/f2skill.out"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -156,11 +168,11 @@ else
|
||||
else
|
||||
fill_agent_pair "$F3_PROBE/.claude/agents/$NAME3.md" "$NAME3"
|
||||
fill_agent_pair "$F3_PROBE/.github/agents/$NAME3.agent.md" "$NAME3"
|
||||
if env HOME="$F3_HOME" bash "$VALIDATE" "$F3_PROBE/.claude/agents/$NAME3.md" >/tmp/f3.out 2>&1; then
|
||||
if env HOME="$F3_HOME" bash "$VALIDATE" "$F3_PROBE/.claude/agents/$NAME3.md" >"$RUN_TMP/f3.out" 2>&1; then
|
||||
ok "validate.sh agrees: .git boundary keeps this project scope, not promoted to user scope at \$HOME"
|
||||
else
|
||||
err "validate.sh disagreed with new-agent.sh on the .git-boundary-before-\$HOME fixture"
|
||||
sed 's/^/ /' /tmp/f3.out
|
||||
sed 's/^/ /' "$RUN_TMP/f3.out"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -188,11 +200,11 @@ else
|
||||
else
|
||||
fill_agent_pair "$F3B_PROBE/.claude/agents/$NAME3B.md" "$NAME3B"
|
||||
fill_agent_pair "$F3B_PROBE/.github/agents/$NAME3B.agent.md" "$NAME3B"
|
||||
if bash "$VALIDATE" "$F3B_PROBE/.claude/agents/$NAME3B.md" >/tmp/f3b.out 2>&1; then
|
||||
if bash "$VALIDATE" "$F3B_PROBE/.claude/agents/$NAME3B.md" >"$RUN_TMP/f3b.out" 2>&1; then
|
||||
ok "validate.sh agrees: scope root is <root>, not the .git ancestor above it"
|
||||
else
|
||||
err "validate.sh disagreed with new-agent.sh: resolved scope to the .git ancestor instead of <root>"
|
||||
sed 's/^/ /' /tmp/f3b.out
|
||||
sed 's/^/ /' "$RUN_TMP/f3b.out"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -214,19 +226,19 @@ elif [[ ! -f "$F4_ROOT/.apm/agents/$NAME4.agent.md" ]]; then
|
||||
err "new-agent.sh did not scaffold plugin scope at the type-bearing apm.yml root"
|
||||
else
|
||||
ok "new-agent.sh: plugin scope at type-bearing apm.yml root"
|
||||
if bash "$NEW_SKILL" probe-plugin-skill "$F4_ROOT" >/tmp/f4skill.out 2>&1 \
|
||||
if bash "$NEW_SKILL" probe-plugin-skill "$F4_ROOT" >"$RUN_TMP/f4skill.out" 2>&1 \
|
||||
&& [[ -d "$F4_ROOT/.apm/skills/probe-plugin-skill" ]]; then
|
||||
ok "new-skill.sh agrees: package mode at the same apm.yml root"
|
||||
else
|
||||
err "new-skill.sh disagreed with new-agent.sh on the type-bearing apm.yml root"
|
||||
sed 's/^/ /' /tmp/f4skill.out
|
||||
sed 's/^/ /' "$RUN_TMP/f4skill.out"
|
||||
fi
|
||||
fill_agent_pair "$F4_ROOT/.apm/agents/$NAME4.agent.md" "$NAME4"
|
||||
if bash "$VALIDATE" "$F4_ROOT/.apm/agents/$NAME4.agent.md" >/tmp/f4validate.out 2>&1; then
|
||||
if bash "$VALIDATE" "$F4_ROOT/.apm/agents/$NAME4.agent.md" >"$RUN_TMP/f4validate.out" 2>&1; then
|
||||
ok "validate.sh agrees: plugin/APM scope, structural checks pass"
|
||||
else
|
||||
err "validate.sh disagreed with new-agent.sh: did not treat the type-bearing apm.yml root as plugin scope"
|
||||
sed 's/^/ /' /tmp/f4validate.out
|
||||
sed 's/^/ /' "$RUN_TMP/f4validate.out"
|
||||
fi
|
||||
# source_keys + a matching sources.md round-trips only if validate-provenance.sh
|
||||
# resolves the SAME plugin root new-agent.sh/new-skill.sh did.
|
||||
@@ -251,11 +263,11 @@ EOF
|
||||
- **Research doc:** (none)
|
||||
- **Status:** \`extracted\`
|
||||
EOF
|
||||
if bash "$VALIDATE_PROVENANCE" "$F4_ROOT/.apm/agents/$NAME4.agent.md" >/tmp/f4prov.out 2>&1; then
|
||||
if bash "$VALIDATE_PROVENANCE" "$F4_ROOT/.apm/agents/$NAME4.agent.md" >"$RUN_TMP/f4prov.out" 2>&1; then
|
||||
ok "validate-provenance.sh agrees: resolves the same plugin root, sources.md round-trips"
|
||||
else
|
||||
err "validate-provenance.sh disagreed on the plugin root for the type-bearing apm.yml fixture"
|
||||
sed 's/^/ /' /tmp/f4prov.out
|
||||
sed 's/^/ /' "$RUN_TMP/f4prov.out"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -280,11 +292,11 @@ else
|
||||
else
|
||||
fill_agent_pair "$F5_ROOT/.claude/agents/$NAME5.md" "$NAME5"
|
||||
fill_agent_pair "$F5_ROOT/.github/agents/$NAME5.agent.md" "$NAME5"
|
||||
if env HOME="$F5_UNRELATED_HOME" bash "$VALIDATE" "$F5_ROOT/.claude/agents/$NAME5.md" >/tmp/f5.out 2>&1; then
|
||||
if env HOME="$F5_UNRELATED_HOME" bash "$VALIDATE" "$F5_ROOT/.claude/agents/$NAME5.md" >"$RUN_TMP/f5.out" 2>&1; then
|
||||
ok "validate.sh agrees: filesystem-boundary fallback resolves to project scope"
|
||||
else
|
||||
err "validate.sh disagreed with new-agent.sh on the filesystem-boundary fallback fixture"
|
||||
sed 's/^/ /' /tmp/f5.out
|
||||
sed 's/^/ /' "$RUN_TMP/f5.out"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -325,12 +337,12 @@ EOF
|
||||
# No sources.md exists anywhere under $F6_HOME or at the ancestor package
|
||||
# root — if find_plugin_root walked past $HOME to the ancestor apm.yml,
|
||||
# this would FAIL on Check 0 (source_keys declared but sources.md absent).
|
||||
if env HOME="$F6_HOME" bash "$VALIDATE_PROVENANCE" "$F6_HOME/.apm/agents/probe-prov.agent.md" >/tmp/f6.out 2>&1 \
|
||||
&& [[ -z "$(cat /tmp/f6.out)" ]]; then
|
||||
if env HOME="$F6_HOME" bash "$VALIDATE_PROVENANCE" "$F6_HOME/.apm/agents/probe-prov.agent.md" >"$RUN_TMP/f6.out" 2>&1 \
|
||||
&& [[ -z "$(cat "$RUN_TMP/f6.out")" ]]; then
|
||||
ok "validate-provenance.sh agrees: \$HOME boundary stops the walk, exits 0 silently (not plugin scope)"
|
||||
else
|
||||
err "validate-provenance.sh walked past \$HOME to the ancestor apm.yml — disagrees with new-agent.sh"
|
||||
sed 's/^/ /' /tmp/f6.out
|
||||
sed 's/^/ /' "$RUN_TMP/f6.out"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -71,6 +71,13 @@ for ini in "$SKILL_INI" "$AGENT_INI"; do
|
||||
err "$rel_ini is missing — without it vale falls back to an upward config search and lints with whatever it finds"
|
||||
continue
|
||||
fi
|
||||
# Present but unreadable is its own case: every assertion below is a grep, and
|
||||
# grep exits 2 on a read error. The override capture swallows that into an
|
||||
# empty result, which would read as "no findings" rather than "not checked".
|
||||
if [[ ! -r "$ini" ]]; then
|
||||
err "$rel_ini is not readable — none of its assertions could run, and an unreadable file cannot be distinguished from a clean one downstream"
|
||||
continue
|
||||
fi
|
||||
# StylesPath is resolved relative to the .vale.ini, which is the only reason
|
||||
# the bundled styles are found from a consuming repo's clone prefix.
|
||||
if ! grep -Eq '^[[:space:]]*StylesPath[[:space:]]*=[[:space:]]*styles[[:space:]]*$' "$ini"; then
|
||||
@@ -81,8 +88,71 @@ for ini in "$SKILL_INI" "$AGENT_INI"; do
|
||||
if ! grep -Eq '^[[:space:]]*BasedOnStyles[[:space:]]*=.*Kyberforge([[:space:],]|$)' "$ini"; then
|
||||
err "$rel_ini has no section whose BasedOnStyles names Kyberforge — every rule the audit prefilters on lives in that style"
|
||||
fi
|
||||
# Per-rule overrides are the third way to retire a rule without touching a
|
||||
# style file or a glob. CONTEXT.md's "Vale audit prefilter" entry: "Every rule
|
||||
# is `level: error` and every alert is a FAIL — no ignorable tier". Vale's exit
|
||||
# code keys on `error` alerts alone, so any override that leaves a rule at
|
||||
# anything other than `error` still lints the file, still exits 0, and still
|
||||
# shows `Passed` in pre-commit. The glob probe below cannot backstop this: it
|
||||
# keys on one `Kyberforge.VagueWording` alert, so DescriptionOpener,
|
||||
# PaddingPhrase, SentenceOpenerThereIs and ProactivePhrase can each be retired
|
||||
# underneath a passing probe.
|
||||
#
|
||||
# Asserted as an ALLOWLIST, not a blocklist of `NO|warning|suggestion`, because
|
||||
# that is vale 3.15.2's own semantic: only the exact tokens `YES` and `error`
|
||||
# keep a rule blocking. `warning`/`suggestion` downgrade it (alert still
|
||||
# printed, exit 0 — invisible, since pre-commit swallows a passing hook's
|
||||
# output); every other value — `NO`, `false`, `0`, `off`, `n`, empty,
|
||||
# `garbage`, and lowercase `yes`, `true`, `1`, `on` — silences the rule
|
||||
# outright. Lowercase `yes` is the trap a blocklist cannot cover: it reads as
|
||||
# "enabled" to a human and disables the rule. Verified by enumerating the
|
||||
# value space against vale 3.15.2.
|
||||
#
|
||||
# The allowlist demands a BARE `YES`/`error` with nothing after it, which also
|
||||
# rejects `error # note` and `error ; note`. Vale itself strips those — a
|
||||
# whitespace-preceded `#` or `;` comment is removed and the rule stays live —
|
||||
# so rejecting them is deliberately stricter than vale, not a workaround for
|
||||
# it. Uniformity is worth more here than the ability to annotate a line that
|
||||
# should not exist: no shipped `.vale.ini` has any override line at all, and
|
||||
# the failure mode is a loud false positive rather than a silent pass. The
|
||||
# genuine hazard is the no-space form — `error# note` and `error; note` are
|
||||
# NOT stripped and silence the rule outright — and a rule that demands a bare
|
||||
# token catches those without having to reimplement vale's comment parsing.
|
||||
#
|
||||
# `[A-Za-z0-9_-]` on both halves of the name, not `[A-Za-z]`: a rule named
|
||||
# `Kyberforge.Vague2` is genuinely silenced by `= NO` (verified: 1 error ->
|
||||
# 0 errors), so an alpha-only class would let a digit-bearing rule name slip
|
||||
# past the gate. All five current rule names are pure alpha, so this is
|
||||
# forward cover, not a live hole.
|
||||
bad_overrides="$(
|
||||
grep -E '^[[:space:]]*Kyberforge[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+[[:space:]]*=' "$ini" \
|
||||
| grep -Ev '^[[:space:]]*Kyberforge[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+[[:space:]]*=[[:space:]]*(YES|error)[[:space:]]*$' \
|
||||
|| true
|
||||
)"
|
||||
# No `grep -q` in that pipeline on purpose: `-q` exits on its first match, and
|
||||
# under `set -o pipefail` the resulting SIGPIPE on the upstream grep would make
|
||||
# the whole pipeline report 141 and read as "no findings".
|
||||
if [[ -n "$bad_overrides" ]]; then
|
||||
err "$rel_ini overrides a Kyberforge rule to something other than a bare YES or error (first: '${bad_overrides%%$'\n'*}') — every rule in this prefilter is level: error and every alert is a FAIL, and any other value downgrades or silences the rule while vale still exits 0. A trailing comment is rejected too: vale strips a spaced '# ...' but not 'error# ...', so this asks for the bare token rather than guessing which form you meant"
|
||||
fi
|
||||
done
|
||||
|
||||
# KyberforgeCopilot is agent-audit's alone — CONTEXT.md describes it as "scoped
|
||||
# only to `.agent.md` files for the Copilot-only 'Use proactively has no effect'
|
||||
# check". The loop above deliberately asserts only `Kyberforge`, since
|
||||
# skill-audit's copy legitimately has no Copilot style, so dropping
|
||||
# `, KyberforgeCopilot` from agent-audit's `[**/*.agent.md]` section unloaded the
|
||||
# whole style silently: no glob broke, the styles/ diff above stayed clean (the
|
||||
# style directory is still shipped, just never loaded), the two .vale.ini files
|
||||
# are deliberately unequal so no equality check applies, and the probe below
|
||||
# still passed because it keys on a Kyberforge alert. Assert the style is loaded
|
||||
# whenever it is shipped.
|
||||
if [[ -d "$AGENT_AUDIT/assets/vale/styles/KyberforgeCopilot" && -f "$AGENT_INI" ]]; then
|
||||
if ! grep -Eq '^[[:space:]]*BasedOnStyles[[:space:]]*=.*KyberforgeCopilot([[:space:],]|$)' "$AGENT_INI"; then
|
||||
err "${AGENT_INI#"$REPO_ROOT"/} ships a styles/KyberforgeCopilot style but no section's BasedOnStyles names it — the style is never loaded, so its Copilot-only rules lint nothing"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Prints the `files:` regex of every hook, in ONE manifest ($2), whose entry
|
||||
# is $1's vale-wrap.sh. Records are delimited by their `- id:` line, so the
|
||||
# check does not depend on `entry:` preceding `files:` within a record.
|
||||
@@ -227,10 +297,24 @@ while IFS='|' read -r skill rel scope; do
|
||||
# shape — per ADR-0016 every `.apm/agents/*` file is named `*.agent.md`, so
|
||||
# `.pre-commit-config.yaml`'s regex correctly no longer matches it and that's
|
||||
# not drift. `demo.agent.md` is the real, current shape and is `shared`.
|
||||
#
|
||||
# The two `.claude/`-prefixed probes carry the location-independence CONTEXT.md
|
||||
# asserts: "A `SKILL.md` outside `plugins/` (e.g. project-scope
|
||||
# `.claude/skills/foo/SKILL.md`) still matches `[**/SKILL.md]` and gets linted
|
||||
# normally — the globs constrain filename shape, not location." Every other
|
||||
# probe here starts with `plugins/`, so narrowing a glob to a `plugins/`-shaped
|
||||
# path (`[**/SKILL.md]` -> `[**/.apm/skills/*/SKILL.md]`) left all of them
|
||||
# matching while the project-scope shape started linting as `0 errors ... in 0
|
||||
# files` — the exact "vale lints zero files, hook shows Passed" failure the
|
||||
# comment at the top of this section describes. Both are `hooks-only`: only
|
||||
# `.pre-commit-hooks.yaml` is layout-agnostic, and `.pre-commit-config.yaml`
|
||||
# pinning this repo's own `plugins/**/.apm/` layout is by design, not drift.
|
||||
done <<'EOF_PROBE'
|
||||
skill-audit|plugins/demo/.apm/skills/demo/SKILL.md|shared
|
||||
skill-audit|.claude/skills/demo/SKILL.md|hooks-only
|
||||
agent-audit|plugins/demo/.apm/agents/demo.md|hooks-only
|
||||
agent-audit|plugins/demo/.apm/agents/demo.agent.md|shared
|
||||
agent-audit|.claude/agents/demo.md|hooks-only
|
||||
agent-audit|copilot/demo.agent.md|hooks-only
|
||||
EOF_PROBE
|
||||
|
||||
|
||||
@@ -29,6 +29,20 @@ fi
|
||||
[[ $# -eq 0 ]] || usage
|
||||
|
||||
if [[ ! -f "$SRC" ]]; then
|
||||
# A missing source with a surviving mirror is drift, not absence: the mirror
|
||||
# can only be stale (nothing is left for it to be byte-identical to), which is
|
||||
# precisely the silent divergence this script exists to prevent. Exiting 0
|
||||
# here would report "no drift" over a mirror of a file that no longer exists,
|
||||
# and would also swallow the case where REPO_ROOT resolved to the wrong tree —
|
||||
# `git rev-parse --show-toplevel` falls back to `pwd` outside a worktree.
|
||||
# scripts/sync-plugin-content.sh --check --all already errors on the same
|
||||
# condition ("requires .../marketplace.json"); this matches it.
|
||||
# Neither file present stays a genuine no-op: nothing to mirror, nothing stale.
|
||||
if [[ "$CHECK" -eq 1 && -f "$DST" ]]; then
|
||||
echo "DRIFT $DST: mirror exists but .claude-plugin/marketplace.json does not" >&2
|
||||
echo "Fix: restore .claude-plugin/marketplace.json (apm's compiled Claude marketplace output), or delete $DST" >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
|
||||
# Mirrors each plugin's .apm/{agents,skills,prompts,commands,instructions,extensions,hooks}
|
||||
# into flat plugin-root directories (agents/, skills/, commands/, instructions/,
|
||||
# extensions/, hooks.json) -- Claude Code's and GitHub Copilot's plugin loaders
|
||||
# extensions/, hooks/hooks.json) -- Claude Code's and GitHub Copilot's plugin loaders
|
||||
# convention-scan those flat paths at the plugin root; neither has any awareness of
|
||||
# apm's .apm/ nesting (confirmed via `strings` on the installed claude binary and a
|
||||
# live `claude --plugin-dir <bundle> -p ...` discoverability test -- see issue #90).
|
||||
@@ -43,9 +43,18 @@ set -euo pipefail
|
||||
# drift in name/version/description/mcpServers -- only the copy's manifest files,
|
||||
# never the real ones, can get created as a first-write.
|
||||
#
|
||||
# hooks.json is mirrored like the other MIRROR_DIRS content: synced when .apm/hooks/
|
||||
# produces one, and removed (real mode) / flagged as drift (--check) when it no
|
||||
# longer does but a root-level hooks.json is still sitting there from a prior sync.
|
||||
# The merged hooks file is mirrored like the other MIRROR_DIRS content: synced when
|
||||
# .apm/hooks/ produces one, and removed (real mode) / flagged as drift (--check) when
|
||||
# it no longer does but a mirrored copy is still sitting there from a prior sync.
|
||||
# It lands at hooks/hooks.json, not at the plugin root: Claude Code convention-scans
|
||||
# `hooks/hooks.json` "at the plugin root, not inside .claude-plugin/"
|
||||
# (plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md's "Plugin
|
||||
# Directory Layout" table; quoted verbatim in ADR-0017's own root-cause analysis), and
|
||||
# the compiled plugin.json carries no `hooks` pointer to override that -- apm's
|
||||
# build_plugin_manifest strips pointer fields unconditionally, and re-injecting one is
|
||||
# the option ADR-0017 explicitly rejected. A root-level hooks.json (this script's own
|
||||
# pre-fix output shape) is therefore scanned by nothing at all, and is deleted as stale
|
||||
# by a real sync / reported as drift by --check.
|
||||
#
|
||||
# tests/ subdirectories (e.g. .apm/skills/<name>/tests/*.bats) are excluded from the
|
||||
# mirror -- they are dev-time fixtures a plugin host never needs to discover, and several
|
||||
@@ -53,6 +62,15 @@ set -euo pipefail
|
||||
# `../../../../../../`) sized for the .apm/-nested depth. Mirroring them verbatim would
|
||||
# duplicate each file one directory level shallower than that walk-up expects, breaking
|
||||
# the duplicate and double-running the original under any repo-wide bats/test discovery.
|
||||
# The exclusion is depth-scoped to <category>/<name>/tests, because a skill may legitimately
|
||||
# ship a directory literally named tests as a template asset it scaffolds FROM
|
||||
# (skills/skill-author/assets/templates/tests) -- stripping that breaks the shipped
|
||||
# scaffolder, which sed's its way through the template tree file by file.
|
||||
#
|
||||
# --check does NOT reimplement any of the above. It runs the real sync functions against
|
||||
# a throwaway copy of the plugin root and diffs the result against the live mirror with
|
||||
# no exclusions, so the tests/ scoping and the hooks path exist in exactly one place and
|
||||
# check mode is structurally incapable of disagreeing with what a real sync produces.
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [--check] (--all | <plugin-dir> [<plugin-dir> ...])" >&2
|
||||
@@ -96,6 +114,11 @@ source "$SCRIPT_DIR/lib/batch-run.sh"
|
||||
# Convention subdirectories apm's plugin exporter can populate from .apm/.
|
||||
MIRROR_DIRS=(agents skills commands instructions extensions)
|
||||
|
||||
# Where the merged hooks file lands (Claude Code's convention-scanned path), and
|
||||
# the pre-fix root-level path a real sync now cleans up as stale.
|
||||
HOOKS_REL="hooks/hooks.json"
|
||||
LEGACY_HOOKS_REL="hooks.json"
|
||||
|
||||
FAIL=0
|
||||
SCRATCH_ROOT="$(mktemp -d)"
|
||||
trap 'rm -rf "$SCRATCH_ROOT"' EXIT
|
||||
@@ -142,69 +165,147 @@ normalize_trailing_newline() {
|
||||
printf '%s\n' "$(cat "$1")" >"$2"
|
||||
}
|
||||
|
||||
# Real-mode mirror write. There is no --check branch here on purpose: check mode
|
||||
# calls this same function against a throwaway copy of the plugin root and diffs
|
||||
# the result (see sync_one), so the tests/ exclusion below is the only copy of
|
||||
# that rule anywhere in this script.
|
||||
sync_dir() {
|
||||
local plugin_dir="$1" bundle_dir="$2" d="$3"
|
||||
local src="$bundle_dir/$d" dst="$plugin_dir/$d"
|
||||
|
||||
if [[ "$CHECK" -eq 1 ]]; then
|
||||
if [[ -d "$src" ]]; then
|
||||
if [[ ! -d "$dst" ]]; then
|
||||
echo "DRIFT $dst: missing (would be created from .apm/)" >&2
|
||||
FAIL=1
|
||||
elif ! diff -rq -x tests "$src" "$dst" >/dev/null 2>&1; then
|
||||
echo "DRIFT $dst: out of sync with .apm/" >&2
|
||||
diff -rq -x tests "$src" "$dst" 2>&1 | sed 's/^/ /' >&2
|
||||
FAIL=1
|
||||
fi
|
||||
elif [[ -d "$dst" ]]; then
|
||||
echo "DRIFT $dst: stale, no longer produced from .apm/" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
local target_dir="$1" bundle_dir="$2" d="$3"
|
||||
local src="$bundle_dir/$d" dst="$target_dir/$d"
|
||||
|
||||
if [[ -d "$src" ]]; then
|
||||
rm -rf "$dst"
|
||||
mkdir -p "$dst"
|
||||
cp -a "$src/." "$dst/"
|
||||
find "$dst" -type d -name tests -exec rm -rf {} +
|
||||
# Depth-scoped to <category>/<name>/tests (depth 2 relative to $dst), which is
|
||||
# where every dev-time fixture lives. A depth-agnostic `-name tests` also matched
|
||||
# template assets a skill ships for its own scaffolder to copy FROM -- e.g.
|
||||
# skills/skill-author/assets/templates/tests at depth 4, whose removal made the
|
||||
# mirrored new-skill.sh die on `sed: can't read .../tests/README.md` midway
|
||||
# through writing a scaffold.
|
||||
find "$dst" -mindepth 2 -maxdepth 2 -type d -name tests -exec rm -rf {} +
|
||||
elif [[ -d "$dst" ]]; then
|
||||
rm -rf "$dst"
|
||||
fi
|
||||
}
|
||||
|
||||
# Real-mode merged-hooks write; same single-implementation contract as sync_dir.
|
||||
sync_hooks_json() {
|
||||
local plugin_dir="$1" bundle_dir="$2"
|
||||
local src="$bundle_dir/hooks.json" dst="$plugin_dir/hooks.json"
|
||||
local target_dir="$1" bundle_dir="$2"
|
||||
local src="$bundle_dir/hooks.json"
|
||||
local dst="$target_dir/$HOOKS_REL" legacy="$target_dir/$LEGACY_HOOKS_REL"
|
||||
|
||||
if [[ "$CHECK" -eq 1 ]]; then
|
||||
if [[ -f "$src" ]]; then
|
||||
local normalized_src
|
||||
normalized_src="$(mktemp)"
|
||||
normalize_trailing_newline "$src" "$normalized_src"
|
||||
if [[ ! -f "$dst" ]] || ! diff -q "$normalized_src" "$dst" >/dev/null 2>&1; then
|
||||
echo "DRIFT $dst: out of sync with .apm/hooks/" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
rm -f "$normalized_src"
|
||||
elif [[ -f "$dst" ]]; then
|
||||
# Mirrors sync_dir()'s orphan handling: .apm/hooks/ no longer produces a
|
||||
# hooks.json, but one is still sitting at $dst from a prior sync -- that's
|
||||
# drift (stale mirrored output), not "no .apm/hooks/ content" (which would
|
||||
# mean $dst never existed in the first place).
|
||||
echo "DRIFT $dst: stale, no longer produced from .apm/hooks/" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
return 0
|
||||
# Earlier revisions of this script wrote the merged hooks file to the plugin
|
||||
# root. Nothing scans it there (see the header comment), so it is stale output
|
||||
# regardless of whether .apm/hooks/ still produces one -- clean it up first.
|
||||
if [[ -f "$legacy" ]]; then
|
||||
rm -f "$legacy"
|
||||
fi
|
||||
|
||||
if [[ -f "$src" ]]; then
|
||||
mkdir -p "$target_dir/hooks"
|
||||
normalize_trailing_newline "$src" "$dst"
|
||||
elif [[ -f "$dst" ]]; then
|
||||
# .apm/hooks/ no longer produces a hooks.json, but one is still sitting at
|
||||
# $dst from a prior sync -- that's stale mirrored output, not "no .apm/hooks/
|
||||
# content" (which would mean $dst never existed in the first place).
|
||||
rm -f "$dst"
|
||||
rmdir "$target_dir/hooks" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# --- check mode: compare the real plugin root against a synced throwaway copy ---
|
||||
#
|
||||
# Every function below is pure comparison. None of them knows what the mirror
|
||||
# rules are; $expected_dir is the output of the real sync functions above.
|
||||
|
||||
check_dir() {
|
||||
local plugin_dir="$1" expected_dir="$2" d="$3"
|
||||
local expected="$expected_dir/$d" actual="$plugin_dir/$d"
|
||||
|
||||
if [[ -d "$expected" ]]; then
|
||||
if [[ ! -d "$actual" ]]; then
|
||||
echo "DRIFT $actual: missing (would be created from .apm/)" >&2
|
||||
FAIL=1
|
||||
elif ! diff -rq "$expected" "$actual" >/dev/null 2>&1; then
|
||||
echo "DRIFT $actual: out of sync with .apm/" >&2
|
||||
# `|| true`: pipefail (set -o at the top) turns diff's exit 1 into a failed
|
||||
# pipeline, and set -e would abort sync_one right here -- reporting only the
|
||||
# first drifted directory per plugin and turning one push into N fix cycles.
|
||||
diff -rq "$expected" "$actual" 2>&1 | sed 's/^/ /' >&2 || true
|
||||
FAIL=1
|
||||
fi
|
||||
elif [[ -d "$actual" ]]; then
|
||||
echo "DRIFT $actual: stale, no longer produced from .apm/" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
}
|
||||
|
||||
check_file() {
|
||||
local plugin_dir="$1" expected_dir="$2" rel="$3" desc="$4"
|
||||
local expected="$expected_dir/$rel" actual="$plugin_dir/$rel"
|
||||
|
||||
if [[ -f "$expected" ]]; then
|
||||
if [[ ! -f "$actual" ]]; then
|
||||
echo "DRIFT $actual: missing (would be created from $desc)" >&2
|
||||
FAIL=1
|
||||
elif ! diff -q "$expected" "$actual" >/dev/null 2>&1; then
|
||||
echo "DRIFT $actual: out of sync with $desc" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
elif [[ -f "$actual" ]]; then
|
||||
echo "DRIFT $actual: stale, no longer produced from $desc" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
}
|
||||
|
||||
# Prints "<kind> <relative-path>" for every entry under the given relative paths.
|
||||
# `find` is used rather than a stat(1) call because stat's flags for mode
|
||||
# formatting are incompatible between GNU and BSD/macOS.
|
||||
path_manifest() {
|
||||
local root="$1"
|
||||
shift
|
||||
local rel f kind
|
||||
for rel in "$@"; do
|
||||
if [[ ! -e "$root/$rel" ]] && [[ ! -L "$root/$rel" ]]; then
|
||||
continue
|
||||
fi
|
||||
find "$root/$rel" -print 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
|
||||
if [[ -L "$f" ]]; then
|
||||
kind="symlink"
|
||||
elif [[ -d "$f" ]]; then
|
||||
kind="dir"
|
||||
elif [[ -x "$f" ]]; then
|
||||
kind="exec"
|
||||
else
|
||||
kind="file"
|
||||
fi
|
||||
printf '%s %s\n' "$kind" "${f#"$root"/}"
|
||||
done || true
|
||||
done
|
||||
}
|
||||
|
||||
# `diff -r` compares content only: it dereferences symlinks and ignores file modes
|
||||
# entirely. So `chmod -x` on a mirrored script, or swapping a mirrored file for a
|
||||
# symlink to identical content, both leave --check at exit 0 while a real sync
|
||||
# silently repairs them -- check and sync disagreeing, which is the one thing this
|
||||
# gate exists to prevent. Compare an explicit type+exec-bit manifest as well.
|
||||
check_path_modes() {
|
||||
local plugin_dir="$1" expected_dir="$2"
|
||||
shift 2
|
||||
local expected actual
|
||||
expected="$(mktemp)"
|
||||
actual="$(mktemp)"
|
||||
path_manifest "$expected_dir" "$@" >"$expected"
|
||||
path_manifest "$plugin_dir" "$@" >"$actual"
|
||||
if ! diff -q "$expected" "$actual" >/dev/null 2>&1; then
|
||||
echo "DRIFT $plugin_dir: mirrored file types/modes differ from a fresh sync" >&2
|
||||
diff "$expected" "$actual" 2>&1 | sed 's/^/ /' >&2 || true
|
||||
FAIL=1
|
||||
fi
|
||||
rm -f "$expected" "$actual"
|
||||
}
|
||||
|
||||
reinject_mcp_servers() {
|
||||
local plugin_dir="$1" target_dir="$2"
|
||||
local mcp_src="$plugin_dir/.mcp.json" dst="$target_dir/.github/plugin/plugin.json"
|
||||
@@ -237,7 +338,8 @@ sync_plugin_manifest() {
|
||||
FAIL=1
|
||||
elif ! diff -q "$src" "$dst" >/dev/null 2>&1; then
|
||||
echo "DRIFT $dst: out of sync with apm.yml/.mcp.json" >&2
|
||||
diff "$src" "$dst" 2>&1 | sed 's/^/ /' >&2
|
||||
# `|| true` for the same pipefail/set -e reason as check_dir's diff above.
|
||||
diff "$src" "$dst" 2>&1 | sed 's/^/ /' >&2 || true
|
||||
FAIL=1
|
||||
fi
|
||||
elif [[ -f "$dst" ]]; then
|
||||
@@ -307,11 +409,15 @@ sync_one() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Both modes run the identical real-mode mirror write; only the target differs.
|
||||
# In real mode that target IS the plugin root. In check mode it's the throwaway
|
||||
# pack_cwd copy, which then gets diffed against the plugin root below -- so
|
||||
# --check can only ever report what a real sync would actually change.
|
||||
local d
|
||||
for d in "${MIRROR_DIRS[@]}"; do
|
||||
sync_dir "$plugin_dir" "$bundle_dir" "$d"
|
||||
sync_dir "$pack_cwd" "$bundle_dir" "$d"
|
||||
done
|
||||
sync_hooks_json "$plugin_dir" "$bundle_dir"
|
||||
sync_hooks_json "$pack_cwd" "$bundle_dir"
|
||||
if [[ "$CHECK" -eq 0 ]]; then
|
||||
reinject_mcp_servers "$plugin_dir" "$plugin_dir"
|
||||
else
|
||||
@@ -319,6 +425,15 @@ sync_one() {
|
||||
# against what a real sync would actually produce (mcpServers included),
|
||||
# not apm's own Copilot-ecosystem output (which omits it).
|
||||
reinject_mcp_servers "$plugin_dir" "$pack_cwd"
|
||||
local -a checked_paths
|
||||
checked_paths=("${MIRROR_DIRS[@]}" "$HOOKS_REL" "$LEGACY_HOOKS_REL")
|
||||
for d in "${MIRROR_DIRS[@]}"; do
|
||||
check_dir "$plugin_dir" "$pack_cwd" "$d"
|
||||
done
|
||||
check_file "$plugin_dir" "$pack_cwd" "$HOOKS_REL" ".apm/hooks/"
|
||||
check_file "$plugin_dir" "$pack_cwd" "$LEGACY_HOOKS_REL" \
|
||||
".apm/hooks/ (Claude Code convention-scans $HOOKS_REL, not the plugin root)"
|
||||
check_path_modes "$plugin_dir" "$pack_cwd" "${checked_paths[@]}"
|
||||
sync_plugin_manifest "$plugin_dir" "$pack_cwd" ".claude-plugin/plugin.json"
|
||||
sync_plugin_manifest "$plugin_dir" "$pack_cwd" ".github/plugin/plugin.json"
|
||||
fi
|
||||
|
||||
@@ -66,6 +66,7 @@ batch_run "$SCRATCH_ROOT" ${batch_args[@]+"${batch_args[@]}"}
|
||||
FAIL=0
|
||||
TOTAL_OK=0
|
||||
TOTAL_NOT_OK=0
|
||||
TOTAL_PLANS=0
|
||||
i=0
|
||||
for f in ${TEST_FILES[@]+"${TEST_FILES[@]}"}; do
|
||||
i=$((i + 1))
|
||||
@@ -75,13 +76,38 @@ for f in ${TEST_FILES[@]+"${TEST_FILES[@]}"}; do
|
||||
echo ""
|
||||
file_ok="$(grep -c '^ok ' "$SCRATCH_ROOT/$i.log" || true)"
|
||||
file_not_ok="$(grep -c '^not ok ' "$SCRATCH_ROOT/$i.log" || true)"
|
||||
# The TAP plan line (`1..N`). Counted separately from the results because an
|
||||
# empty-but-valid file emits `1..0` and no result lines at all -- that is a
|
||||
# file bats really did run, so it has to be distinguishable from a file that
|
||||
# produced nothing whatsoever.
|
||||
file_plan="$(grep -c '^1\.\.[0-9]' "$SCRATCH_ROOT/$i.log" || true)"
|
||||
status="$(cat "$SCRATCH_ROOT/$i.status" 2>/dev/null || echo 1)"
|
||||
TOTAL_OK=$((TOTAL_OK + file_ok))
|
||||
TOTAL_NOT_OK=$((TOTAL_NOT_OK + file_not_ok))
|
||||
TOTAL_PLANS=$((TOTAL_PLANS + file_plan))
|
||||
if [[ "$file_not_ok" -gt 0 || "$status" -ne 0 ]]; then
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
|
||||
# Zero counted tests is never a clean run: files were found (the empty-TEST_FILES
|
||||
# case exits above), so nothing was executed. Without this, a `bats` that emits
|
||||
# nothing and exits 0 -- a broken binary, a formatter change, or a wholesale
|
||||
# `@test` removal -- reports "0 tests, 0 failures" and exits green, silently
|
||||
# turning a total harness failure into a pass.
|
||||
#
|
||||
# The two causes get different messages because they are different problems and
|
||||
# `1..0` is itself valid TAP: no plan lines at all means bats produced no output
|
||||
# to parse, while plans present with zero results means bats ran fine and the
|
||||
# files genuinely declare no tests.
|
||||
if [[ $((TOTAL_OK + TOTAL_NOT_OK)) -eq 0 ]]; then
|
||||
if [[ "$TOTAL_PLANS" -eq 0 ]]; then
|
||||
echo "Error: ${#TEST_FILES[@]} .bats file(s) ran but produced no TAP output at all — the bats harness is broken" >&2
|
||||
else
|
||||
echo "Error: ${#TEST_FILES[@]} .bats file(s) declared 0 tests — every @test appears to have been removed" >&2
|
||||
fi
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "$((TOTAL_OK + TOTAL_NOT_OK)) tests, $TOTAL_NOT_OK failures"
|
||||
exit "$FAIL"
|
||||
|
||||
@@ -9,6 +9,16 @@ FAIL=0
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# One trap over a registry rather than a fresh `trap 'rm -rf "$FIXTUREn"' EXIT`
|
||||
# per fixture: each such trap REPLACES the previous one, so only the last
|
||||
# fixture was ever cleaned and the rest leaked into TMPDIR every run. Same
|
||||
# pattern as tests/test-check-vale-style-sync.sh and
|
||||
# tests/test-check-scope-walkup-sync.sh; the emptiness guard is there because
|
||||
# bash 3.2 treats "${arr[@]}" on an empty array as unbound under `set -u`.
|
||||
FIXTURES=()
|
||||
cleanup() { [[ ${#FIXTURES[@]} -eq 0 ]] || rm -rf "${FIXTURES[@]}"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Helper: make a minimal valid repo fixture with marketplace + plugin structure.
|
||||
# Per ADR-0015/ADR-0017, the manifest check-manifests.sh validates is
|
||||
# .claude-plugin/plugin.json (compiled output) -- not the root-level plugin.json,
|
||||
@@ -40,7 +50,7 @@ JSON
|
||||
echo ""
|
||||
echo "--- exits 0 when all references are valid ---"
|
||||
FIXTURE="$(make_valid_fixture)"
|
||||
trap 'rm -rf "$FIXTURE"' EXIT
|
||||
FIXTURES+=("$FIXTURE")
|
||||
if bash "$SCRIPT" "$FIXTURE" > /dev/null 2>&1; then
|
||||
pass "exits 0 when all manifest references resolve"
|
||||
else
|
||||
@@ -51,7 +61,7 @@ fi
|
||||
echo ""
|
||||
echo "--- exits 1 when plugin source directory missing ---"
|
||||
FIXTURE2="$(mktemp -d)"
|
||||
trap 'rm -rf "$FIXTURE2"' EXIT
|
||||
FIXTURES+=("$FIXTURE2")
|
||||
mkdir -p "$FIXTURE2/.claude-plugin"
|
||||
cat > "$FIXTURE2/.claude-plugin/marketplace.json" <<'JSON'
|
||||
{
|
||||
@@ -71,7 +81,7 @@ fi
|
||||
echo ""
|
||||
echo "--- exits 1 when .claude-plugin/plugin.json missing from plugin directory ---"
|
||||
FIXTURE3="$(mktemp -d)"
|
||||
trap 'rm -rf "$FIXTURE3"' EXIT
|
||||
FIXTURES+=("$FIXTURE3")
|
||||
mkdir -p "$FIXTURE3/.claude-plugin"
|
||||
mkdir -p "$FIXTURE3/plugins/nomanifest"
|
||||
cat > "$FIXTURE3/.claude-plugin/marketplace.json" <<'JSON'
|
||||
@@ -95,7 +105,7 @@ fi
|
||||
echo ""
|
||||
echo "--- exits 1 for a broken local entry even when a remote entry is present ---"
|
||||
FIXTURE4="$(mktemp -d)"
|
||||
trap 'rm -rf "$FIXTURE4"' EXIT
|
||||
FIXTURES+=("$FIXTURE4")
|
||||
mkdir -p "$FIXTURE4/.claude-plugin"
|
||||
mkdir -p "$FIXTURE4/plugins/broken"
|
||||
cat > "$FIXTURE4/.claude-plugin/marketplace.json" <<'JSON'
|
||||
@@ -122,7 +132,7 @@ fi
|
||||
echo ""
|
||||
echo "--- catches a broken pointer field in a non-apm plugin's plugin.json ---"
|
||||
FIXTURE5="$(mktemp -d)"
|
||||
trap 'rm -rf "$FIXTURE5"' EXIT
|
||||
FIXTURES+=("$FIXTURE5")
|
||||
mkdir -p "$FIXTURE5/.claude-plugin"
|
||||
mkdir -p "$FIXTURE5/plugins/legacy/.claude-plugin"
|
||||
cat > "$FIXTURE5/.claude-plugin/marketplace.json" <<'JSON'
|
||||
@@ -149,7 +159,7 @@ fi
|
||||
echo ""
|
||||
echo "--- a non-apm plugin with valid pointer fields still passes ---"
|
||||
FIXTURE6="$(mktemp -d)"
|
||||
trap 'rm -rf "$FIXTURE6"' EXIT
|
||||
FIXTURES+=("$FIXTURE6")
|
||||
mkdir -p "$FIXTURE6/.claude-plugin"
|
||||
mkdir -p "$FIXTURE6/plugins/legacy-ok/.claude-plugin"
|
||||
mkdir -p "$FIXTURE6/plugins/legacy-ok/skills/real-skill"
|
||||
@@ -180,7 +190,7 @@ fi
|
||||
echo ""
|
||||
echo "--- an apm-native plugin's pointer fields are left to sync-plugin-content.sh --check ---"
|
||||
FIXTURE7="$(mktemp -d)"
|
||||
trap 'rm -rf "$FIXTURE7"' EXIT
|
||||
FIXTURES+=("$FIXTURE7")
|
||||
mkdir -p "$FIXTURE7/.claude-plugin"
|
||||
mkdir -p "$FIXTURE7/plugins/apm-plugin/.claude-plugin"
|
||||
mkdir -p "$FIXTURE7/plugins/apm-plugin/.apm"
|
||||
@@ -204,6 +214,185 @@ else
|
||||
fail "check-manifests.sh failed on an apm-native plugin -- pointer-field validation should be delegated, not duplicated"
|
||||
fi
|
||||
|
||||
# --- 8. Disk -> marketplace: an apm package dir with no marketplace entry is caught ---
|
||||
# Both this script and sync-plugin-content.sh --all derive their plugin set from
|
||||
# marketplace.json, so before this check an unlisted plugins/<name>/ was skipped by
|
||||
# every marketplace-derived gate at once while still being globbed by the
|
||||
# validate-plugins pre-commit hook -- two different notions of "the plugin set".
|
||||
# Per ADR-0015 marketplace.json is compiled from root apm.yml's marketplace.packages[],
|
||||
# so an on-disk apm package missing from it is compiled-output drift.
|
||||
echo ""
|
||||
echo "--- exits 1 for a plugins/<name>/ apm package with no marketplace entry ---"
|
||||
FIXTURE8="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE8")
|
||||
mkdir -p "$FIXTURE8/.claude-plugin"
|
||||
mkdir -p "$FIXTURE8/plugins/listed/.claude-plugin"
|
||||
mkdir -p "$FIXTURE8/plugins/orphan/.claude-plugin" "$FIXTURE8/plugins/orphan/.apm/skills"
|
||||
cat > "$FIXTURE8/.claude-plugin/marketplace.json" <<'JSON'
|
||||
{
|
||||
"name": "test-marketplace",
|
||||
"plugins": [
|
||||
{ "name": "listed", "source": "./plugins/listed" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
echo '{ "name": "listed" }' > "$FIXTURE8/plugins/listed/.claude-plugin/plugin.json"
|
||||
echo '{ "name": "orphan" }' > "$FIXTURE8/plugins/orphan/.claude-plugin/plugin.json"
|
||||
printf 'name: orphan\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE8/plugins/orphan/apm.yml"
|
||||
if bash "$SCRIPT" "$FIXTURE8" > /dev/null 2>&1; then
|
||||
fail "exited 0 for an on-disk apm package absent from marketplace.json -- expected exit 1"
|
||||
else
|
||||
pass "catches a plugins/<name>/ apm package that produced no marketplace entry"
|
||||
fi
|
||||
|
||||
# --- 9. A plugins/<name>/ dir with none of the three plugin markers is not flagged ---
|
||||
# The trigger is apm.yml || .apm/ || .claude-plugin/plugin.json -- broad enough to match
|
||||
# the plugins/*/ set the validate-plugins hook globs, which is the disagreement this check
|
||||
# closes. A directory carrying none of the three is scratch and stays out of scope.
|
||||
echo ""
|
||||
echo "--- a plugins/<name>/ directory with none of the three plugin markers is not flagged ---"
|
||||
FIXTURE9="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE9")
|
||||
mkdir -p "$FIXTURE9/.claude-plugin"
|
||||
mkdir -p "$FIXTURE9/plugins/listed/.claude-plugin"
|
||||
mkdir -p "$FIXTURE9/plugins/scratch/notes"
|
||||
cat > "$FIXTURE9/.claude-plugin/marketplace.json" <<'JSON'
|
||||
{
|
||||
"name": "test-marketplace",
|
||||
"plugins": [
|
||||
{ "name": "listed", "source": "./plugins/listed" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
echo '{ "name": "listed" }' > "$FIXTURE9/plugins/listed/.claude-plugin/plugin.json"
|
||||
if bash "$SCRIPT" "$FIXTURE9" > /dev/null 2>&1; then
|
||||
pass "a plugins/<name>/ directory with no plugin markers is left alone"
|
||||
else
|
||||
fail "flagged a non-package directory under plugins/ -- expected exit 0"
|
||||
fi
|
||||
|
||||
# --- 9b. Each of the three markers on its own is enough to trigger the check ---
|
||||
# Keying only off apm.yml would leave a plugin dir carrying just .apm/ or just a
|
||||
# compiled .claude-plugin/plugin.json invisible -- exactly the class of gap this
|
||||
# check exists to close, since validate-plugins would still glob it.
|
||||
marker_case() {
|
||||
local label="$1" marker_setup="$2" dir
|
||||
dir="$(mktemp -d)"
|
||||
FIXTURES+=("$dir")
|
||||
mkdir -p "$dir/.claude-plugin" "$dir/plugins/listed/.claude-plugin" "$dir/plugins/orphan"
|
||||
cat > "$dir/.claude-plugin/marketplace.json" <<'JSON'
|
||||
{
|
||||
"name": "test-marketplace",
|
||||
"plugins": [
|
||||
{ "name": "listed", "source": "./plugins/listed" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
echo '{ "name": "listed" }' > "$dir/plugins/listed/.claude-plugin/plugin.json"
|
||||
case "$marker_setup" in
|
||||
apm-dir) mkdir -p "$dir/plugins/orphan/.apm/skills" ;;
|
||||
plugin-json)
|
||||
mkdir -p "$dir/plugins/orphan/.claude-plugin"
|
||||
echo '{ "name": "orphan" }' > "$dir/plugins/orphan/.claude-plugin/plugin.json"
|
||||
;;
|
||||
esac
|
||||
if bash "$SCRIPT" "$dir" > /dev/null 2>&1; then
|
||||
fail "an unlisted plugin dir carrying only $label was not flagged"
|
||||
else
|
||||
pass "an unlisted plugin dir carrying only $label is flagged"
|
||||
fi
|
||||
}
|
||||
echo ""
|
||||
echo "--- .apm/ alone and .claude-plugin/plugin.json alone each trigger the check ---"
|
||||
marker_case ".apm/" apm-dir
|
||||
marker_case ".claude-plugin/plugin.json" plugin-json
|
||||
|
||||
# --- 9c. A vendored plugin declared with a remote-object source: is already listed ---
|
||||
# list_marketplace_local_plugins deliberately skips remote-object entries, so a
|
||||
# path-only listed/unlisted match reported a missing entry for a directory whose
|
||||
# entry is in fact right there -- telling the author to add what already exists.
|
||||
# The name axis of the match closes that.
|
||||
echo ""
|
||||
echo "--- a vendored plugin whose marketplace entry uses a remote source: is not flagged ---"
|
||||
FIXTURE9C="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE9C")
|
||||
mkdir -p "$FIXTURE9C/.claude-plugin" "$FIXTURE9C/plugins/vendored/.claude-plugin"
|
||||
cat > "$FIXTURE9C/.claude-plugin/marketplace.json" <<'JSON'
|
||||
{
|
||||
"name": "test-marketplace",
|
||||
"plugins": [
|
||||
{ "name": "vendored", "source": { "repo": "someorg/somerepo", "source": "github" } }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
echo '{ "name": "vendored" }' > "$FIXTURE9C/plugins/vendored/.claude-plugin/plugin.json"
|
||||
printf 'name: vendored\nversion: 1.2.3\ntype: skill\n' > "$FIXTURE9C/plugins/vendored/apm.yml"
|
||||
if bash "$SCRIPT" "$FIXTURE9C" > /dev/null 2>&1; then
|
||||
pass "a vendored dir matching a remote-source entry's name counts as listed"
|
||||
else
|
||||
fail "flagged a vendored plugin that already has a remote-source marketplace entry"
|
||||
fi
|
||||
|
||||
# --- 9d. The name axis must NOT rescue an orphan via a LOCAL entry's name ---
|
||||
# A local entry's name need not equal the basename of the directory it points at. An
|
||||
# entry named "beta" pointing at ./plugins/alpha must not mark an unrelated, entirely
|
||||
# unlisted plugins/beta/ as listed -- local entries match on their exact path, so
|
||||
# extending the name fallback to them just reopens the gap this check exists to close.
|
||||
echo ""
|
||||
echo "--- a local entry's name does not rescue a same-named but unlisted directory ---"
|
||||
FIXTURE9D="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE9D")
|
||||
mkdir -p "$FIXTURE9D/.claude-plugin" "$FIXTURE9D/plugins/alpha/.claude-plugin" "$FIXTURE9D/plugins/beta"
|
||||
cat > "$FIXTURE9D/.claude-plugin/marketplace.json" <<'JSON'
|
||||
{
|
||||
"name": "test-marketplace",
|
||||
"plugins": [
|
||||
{ "name": "beta", "source": "./plugins/alpha" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
echo '{ "name": "alpha" }' > "$FIXTURE9D/plugins/alpha/.claude-plugin/plugin.json"
|
||||
printf 'name: beta\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE9D/plugins/beta/apm.yml"
|
||||
if bash "$SCRIPT" "$FIXTURE9D" > /dev/null 2>&1; then
|
||||
fail "an unlisted plugins/beta/ was rescued by an unrelated local entry named beta -- expected exit 1"
|
||||
else
|
||||
pass "an unlisted directory is not rescued by a local entry that merely shares its name"
|
||||
fi
|
||||
|
||||
# --- 10. Marketplace `source:` spelling variants still count as "listed" ---
|
||||
# The disk -> marketplace comparison canonicalizes both sides, so `plugins/x` and
|
||||
# `./plugins/x/` must resolve to the same directory as the glob's `plugins/x/`.
|
||||
#
|
||||
# The entry names deliberately DIFFER from the directory basenames. With names equal to
|
||||
# basenames this fixture proved nothing whenever the name axis was permissive: deleting
|
||||
# the canonicalization entirely still left it passing, because the name match rescued it.
|
||||
# Restricting the name axis to remote entries fixed that, but making the names differ is
|
||||
# what keeps this assertion honest independently of that restriction.
|
||||
echo ""
|
||||
echo "--- a marketplace source without ./ or with a trailing slash still counts as listed ---"
|
||||
FIXTURE10="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE10")
|
||||
mkdir -p "$FIXTURE10/.claude-plugin"
|
||||
mkdir -p "$FIXTURE10/plugins/bare/.claude-plugin" "$FIXTURE10/plugins/trailing/.claude-plugin"
|
||||
cat > "$FIXTURE10/.claude-plugin/marketplace.json" <<'JSON'
|
||||
{
|
||||
"name": "test-marketplace",
|
||||
"plugins": [
|
||||
{ "name": "bare-entry", "source": "plugins/bare" },
|
||||
{ "name": "trailing-entry", "source": "./plugins/trailing/" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
echo '{ "name": "bare" }' > "$FIXTURE10/plugins/bare/.claude-plugin/plugin.json"
|
||||
echo '{ "name": "trailing" }' > "$FIXTURE10/plugins/trailing/.claude-plugin/plugin.json"
|
||||
printf 'name: bare\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE10/plugins/bare/apm.yml"
|
||||
printf 'name: trailing\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE10/plugins/trailing/apm.yml"
|
||||
if bash "$SCRIPT" "$FIXTURE10" > /dev/null 2>&1; then
|
||||
pass "source: spelling variants are canonicalized before the listed/unlisted comparison"
|
||||
else
|
||||
fail "flagged a listed plugin because its source: string was spelled differently"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
@@ -13,14 +13,21 @@ FIXTURES=()
|
||||
cleanup() { [[ ${#FIXTURES[@]} -eq 0 ]] || rm -rf "${FIXTURES[@]}"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Per-run scratch dir for the captured-output files below, for the same reason
|
||||
# scripts/check-scope-walkup-sync.sh has one: tests/run-tests.sh fans its test
|
||||
# scripts out concurrently, so a fixed path in the shared system temp directory
|
||||
# is mutable state shared between two simultaneous runs. In FIXTURES above.
|
||||
RUN_TMP="$(mktemp -d)"
|
||||
FIXTURES+=("$RUN_TMP")
|
||||
|
||||
# --- 1. Exits 0 against this repo's own (fixed) scripts ---
|
||||
echo ""
|
||||
echo "--- exits 0 against this repo's real scripts ---"
|
||||
if bash "$SCRIPT" "$REPO_ROOT" > /tmp/check-scope-walkup-sync-clean.out 2>&1; then
|
||||
if bash "$SCRIPT" "$REPO_ROOT" > "$RUN_TMP/clean.out" 2>&1; then
|
||||
pass "exits 0 against this repo's real scope walk-up scripts"
|
||||
else
|
||||
fail "exited non-zero against this repo's real (already-fixed) scripts"
|
||||
sed 's/^/ /' /tmp/check-scope-walkup-sync-clean.out
|
||||
sed 's/^/ /' "$RUN_TMP/clean.out"
|
||||
fi
|
||||
|
||||
# --- 2. Exits 0 as a no-op when the kyberforge skills aren't present ---
|
||||
@@ -107,7 +114,7 @@ content = pattern.sub(buggy + "\n", content)
|
||||
with open(path, 'w') as f:
|
||||
f.write(content)
|
||||
PYTHON
|
||||
if bash "$SCRIPT" "$FIXTURE_BUG" > /tmp/check-scope-walkup-sync-buggy.out 2>&1; then
|
||||
if bash "$SCRIPT" "$FIXTURE_BUG" > "$RUN_TMP/buggy.out" 2>&1; then
|
||||
fail "exited 0 against a validate.sh reverted to the \$HOME-collapse bug — expected exit 1"
|
||||
else
|
||||
pass "exits non-zero when validate.sh's detect_scope regresses to the \$HOME-collapse bug"
|
||||
@@ -130,12 +137,97 @@ content = pattern.sub("\n", content)
|
||||
with open(path, 'w') as f:
|
||||
f.write(content)
|
||||
PYTHON
|
||||
if bash "$SCRIPT" "$FIXTURE_BUG2" > /tmp/check-scope-walkup-sync-buggy2.out 2>&1; then
|
||||
if bash "$SCRIPT" "$FIXTURE_BUG2" > "$RUN_TMP/buggy2.out" 2>&1; then
|
||||
fail "exited 0 against a validate-provenance.sh with no \$HOME boundary check — expected exit 1"
|
||||
else
|
||||
pass "exits non-zero when validate-provenance.sh's find_plugin_root loses its \$HOME boundary check"
|
||||
fi
|
||||
|
||||
# --- 6. Reentrancy ---
|
||||
# The script and this test both used to capture output to fixed paths in the shared
|
||||
# system temp directory. tests/run-tests.sh runs its scripts concurrently, so two
|
||||
# instances shared those paths: the script's fixture 6 reads its capture back to
|
||||
# assert it is empty, so a write from the other instance turned a passing fixture
|
||||
# into a spurious FAIL, and a stale directory sitting at one of the paths broke the
|
||||
# run outright ("Is a directory").
|
||||
#
|
||||
# THE SOURCE ASSERTION BELOW IS THE REGRESSION GUARD. The race itself is not usefully
|
||||
# testable: 8 simultaneous instances of the broken script were measured exiting 0 with
|
||||
# no FAIL lines, so a concurrent pair reproduces the defect approximately never. Only
|
||||
# the "does either file name a shared temp path" invariant is deterministic, so that is
|
||||
# what actually holds the fix in place -- for BOTH files, since this one had the same
|
||||
# defect at 4 sites and was previously unguarded.
|
||||
#
|
||||
# Matching strategy: look for the shared temp directory anywhere on a line, then drop
|
||||
# whole-line comments. The obvious alternative -- strip comments with `sed 's/#.*//'`
|
||||
# and then match -- is wrong in this repo, because it truncates any line containing a
|
||||
# ${var#prefix} expansion and would silently stop seeing a redirect that follows one.
|
||||
# Comments that mention the shared temp path by name will trip this and have to be
|
||||
# reworded; that is the safe direction to fail in.
|
||||
SHARED_TMP_PATTERN='/'"tmp" # spelled by concatenation so this line cannot self-match
|
||||
|
||||
assert_no_shared_tmp() {
|
||||
local label="$1" file="$2" out="$3"
|
||||
# Without this, a missing file disarms the guard silently rather than failing:
|
||||
# grep exits 2, the comment filter sees empty input and exits 1, and pipefail
|
||||
# reports 2 -- a non-zero status, which is the "clean" branch below. A rename
|
||||
# would then quietly retire the assertion instead of breaking the build.
|
||||
if [[ ! -f "$file" ]]; then
|
||||
fail "$label: cannot check for shared system-temp paths — '$file' does not exist"
|
||||
return
|
||||
fi
|
||||
if grep -n "$SHARED_TMP_PATTERN" "$file" | grep -vE '^[0-9]+:[[:space:]]*#' > "$out"; then
|
||||
fail "$label names a shared system-temp path — scratch files must live under a per-run mktemp -d"
|
||||
sed 's/^/ /' "$out"
|
||||
else
|
||||
pass "$label names no shared system-temp paths"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "--- neither the script nor this test hardcodes a shared system-temp path ---"
|
||||
assert_no_shared_tmp "check-scope-walkup-sync.sh" "$SCRIPT" "$RUN_TMP/hardcoded-script.out"
|
||||
assert_no_shared_tmp "test-check-scope-walkup-sync.sh" "${BASH_SOURCE[0]}" "$RUN_TMP/hardcoded-test.out"
|
||||
|
||||
# The per-run scratch dir must be registered with the cleanup trap. This is a real,
|
||||
# deterministic property (it fails if RUN_TMP is created but never added to FIXTURES)
|
||||
# but note what it is NOT: it cannot detect the original defect, because a script
|
||||
# writing to the shared temp directory directly never touches TMPDIR, leaving the
|
||||
# probe dir empty by construction. It guards the cleanup wiring, not reentrancy.
|
||||
echo ""
|
||||
echo "--- the script's per-run scratch dir is cleaned up on exit ---"
|
||||
SCRATCH_PROBE="$(mktemp -d)"
|
||||
FIXTURES+=("$SCRATCH_PROBE")
|
||||
TMPDIR="$SCRATCH_PROBE" bash "$SCRIPT" "$REPO_ROOT" > "$RUN_TMP/scratch-probe.out" 2>&1
|
||||
LEFTOVER="$(find "$SCRATCH_PROBE" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')"
|
||||
if [[ "$LEFTOVER" == "0" ]]; then
|
||||
pass "the run left no scratch directory behind (RUN_TMP is registered in FIXTURES)"
|
||||
else
|
||||
fail "$LEFTOVER scratch entries survived the run — the per-run scratch dir is not registered with the cleanup trap"
|
||||
fi
|
||||
|
||||
# Smoke test only, deliberately kept despite not guarding the defect above: it is the
|
||||
# one assertion that exercises two instances actually running at the same time, so it
|
||||
# would still catch a coarse regression (e.g. a lockfile or a fixed fixture path that
|
||||
# makes concurrent runs fail outright). It is NOT evidence the race is fixed.
|
||||
echo ""
|
||||
echo "--- smoke: two simultaneous runs both still exit 0 ---"
|
||||
CONCURRENT_TMP="$(mktemp -d)"
|
||||
FIXTURES+=("$CONCURRENT_TMP")
|
||||
( TMPDIR="$CONCURRENT_TMP" bash "$SCRIPT" "$REPO_ROOT" > "$RUN_TMP/conc-a.out" 2>&1 ) &
|
||||
PID_A=$!
|
||||
( TMPDIR="$CONCURRENT_TMP" bash "$SCRIPT" "$REPO_ROOT" > "$RUN_TMP/conc-b.out" 2>&1 ) &
|
||||
PID_B=$!
|
||||
RC_A=0; wait "$PID_A" || RC_A=$?
|
||||
RC_B=0; wait "$PID_B" || RC_B=$?
|
||||
if [[ $RC_A -eq 0 && $RC_B -eq 0 ]]; then
|
||||
pass "two simultaneous runs both exit 0"
|
||||
else
|
||||
fail "a simultaneous pair of runs did not both exit 0 (a=$RC_A b=$RC_B)"
|
||||
sed 's/^/ A: /' "$RUN_TMP/conc-a.out"
|
||||
sed 's/^/ B: /' "$RUN_TMP/conc-b.out"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
@@ -209,6 +209,122 @@ else
|
||||
pass "exits non-zero when a .vale.ini no longer names the Kyberforge style"
|
||||
fi
|
||||
|
||||
# --- 9b. Exits 1 when a per-rule override leaves a rule at anything but error ---
|
||||
# The third way to switch a rule off without touching a style file or a glob.
|
||||
# CONTEXT.md's "Vale audit prefilter" entry: "Every rule is `level: error` and
|
||||
# every alert is a FAIL -- no ignorable tier". Vale's exit code keys on `error`
|
||||
# alerts alone, so any such override leaves the glob intact, the styles
|
||||
# byte-identical, and the run at `0 errors`, exit 0, `Passed`.
|
||||
#
|
||||
# Asserted as an ALLOWLIST because that is vale 3.15.2's own semantic, verified
|
||||
# by enumerating the value space: only the exact tokens `YES` and `error` keep a
|
||||
# rule blocking. `warning`/`suggestion` downgrade it (alert printed, exit 0 --
|
||||
# invisible, since pre-commit swallows a passing hook's output); EVERY other
|
||||
# value silences it outright, including `false`, `0`, `off`, an empty value,
|
||||
# `garbage`, and lowercase `yes`. That last one is why a blocklist of
|
||||
# `NO|warning|suggestion` was not enough: `= yes` reads as "enabled" to a human
|
||||
# and disables the rule. Case 10's glob probe backstops none of this -- it keys
|
||||
# on one Kyberforge.VagueWording alert, so DescriptionOpener, PaddingPhrase,
|
||||
# SentenceOpenerThereIs and ProactivePhrase can each be retired underneath it,
|
||||
# which is why the cases below deliberately target rules that probe never sees.
|
||||
#
|
||||
# Two cases below are about comment forms, and they are NOT symmetric in vale:
|
||||
# `error # note` (spaced) is stripped by vale and stays live, while `error# note`
|
||||
# (no space) is not stripped and silences the rule. The gate demands a bare
|
||||
# token, so it flags both -- deliberately stricter than vale for the spaced form,
|
||||
# and the only way to catch the no-space form without reimplementing vale's
|
||||
# comment parsing. `Kyberforge.Vague2` covers rule names carrying a digit: such a
|
||||
# rule is genuinely silenced by `= NO`, and an alpha-only name class in the gate
|
||||
# would not even see the line.
|
||||
echo ""
|
||||
echo "--- exits 1 when a .vale.ini overrides a Kyberforge rule to anything but YES/error ---"
|
||||
while IFS= read -r override; do
|
||||
[[ -n "$override" ]] || continue
|
||||
# `<EMPTY>` stands in for a bare `Rule =` with no value at all, which the
|
||||
# heredoc cannot carry as a trailing space without a linter eating it.
|
||||
override="${override/<EMPTY>/}"
|
||||
FIXTURE_OV="$(make_fixture)"
|
||||
FIXTURES+=("$FIXTURE_OV")
|
||||
echo "$override" >> "$FIXTURE_OV/plugins/kyberforge/.apm/skills/skill-audit/assets/vale/.vale.ini"
|
||||
if bash "$SCRIPT" "$FIXTURE_OV" > /dev/null 2>&1; then
|
||||
fail "exited 0 with '$override' in skill-audit's .vale.ini -- expected exit 1"
|
||||
else
|
||||
pass "exits non-zero on '$override'"
|
||||
fi
|
||||
done <<'EOF_OVERRIDES'
|
||||
Kyberforge.SentenceOpenerThereIs = NO
|
||||
Kyberforge.VagueWording = warning
|
||||
Kyberforge.SentenceOpenerThereIs = suggestion
|
||||
Kyberforge.SentenceOpenerThereIs = false
|
||||
Kyberforge.DescriptionOpener = 0
|
||||
Kyberforge.PaddingPhrase = off
|
||||
Kyberforge.SentenceOpenerThereIs = yes
|
||||
Kyberforge.DescriptionOpener = garbage
|
||||
Kyberforge.PaddingPhrase =<EMPTY>
|
||||
Kyberforge.SentenceOpenerThereIs = NO # keep quiet
|
||||
Kyberforge.DescriptionOpener = error# silenced, vale strips no comment without a space
|
||||
Kyberforge.PaddingPhrase = error; silenced too, same no-space rule for ';'
|
||||
Kyberforge.DescriptionOpener = error # stripped by vale, still rejected: bare token required
|
||||
Kyberforge.Vague2 = NO
|
||||
Kyberforge.Vague_2 = NO
|
||||
Kyberforge.Vague-2 = NO
|
||||
EOF_OVERRIDES
|
||||
# Same in agent-audit's copy: the check runs over both .vale.ini files, and a
|
||||
# rule retired in only the canonical copy is the likelier direction. `= false`
|
||||
# on ProactivePhrase is the sharpest shape -- one word off the original defect,
|
||||
# on a KyberforgeCopilot rule no glob probe covers.
|
||||
FIXTURE_OV_AGENT="$(make_fixture)"
|
||||
FIXTURES+=("$FIXTURE_OV_AGENT")
|
||||
echo "KyberforgeCopilot.ProactivePhrase = false" \
|
||||
>> "$FIXTURE_OV_AGENT/plugins/kyberforge/.apm/skills/agent-audit/assets/vale/.vale.ini"
|
||||
if bash "$SCRIPT" "$FIXTURE_OV_AGENT" > /dev/null 2>&1; then
|
||||
fail "exited 0 with 'KyberforgeCopilot.ProactivePhrase = false' in agent-audit's .vale.ini -- expected exit 1"
|
||||
else
|
||||
pass "exits non-zero when agent-audit's copy retires a KyberforgeCopilot rule"
|
||||
fi
|
||||
# The two allowlisted values must NOT trip the assertion -- otherwise it would
|
||||
# fire on any legitimate explicit enablement. Kept as a positive case so an
|
||||
# over-broad tightening of the regex shows up here rather than in the repo.
|
||||
FIXTURE_OV_OK="$(make_fixture)"
|
||||
FIXTURES+=("$FIXTURE_OV_OK")
|
||||
{
|
||||
echo "Kyberforge.SentenceOpenerThereIs = YES"
|
||||
echo "Kyberforge.VagueWording = error"
|
||||
} >> "$FIXTURE_OV_OK/plugins/kyberforge/.apm/skills/skill-audit/assets/vale/.vale.ini"
|
||||
if bash "$SCRIPT" "$FIXTURE_OV_OK" > /dev/null 2>&1; then
|
||||
pass "an explicit '= YES' / '= error' override is not flagged"
|
||||
else
|
||||
fail "flagged an explicit '= YES' / '= error' override -- those are the two values that keep a rule blocking"
|
||||
bash "$SCRIPT" "$FIXTURE_OV_OK" 2>&1 | sed 's/^/ /' || true
|
||||
fi
|
||||
|
||||
# --- 9c. Exits 1 when agent-audit ships KyberforgeCopilot but never loads it ---
|
||||
# Case 9 asserts only that Kyberforge is named, because skill-audit's copy
|
||||
# legitimately has no Copilot style. So dropping just `, KyberforgeCopilot` from
|
||||
# agent-audit's [**/*.agent.md] section unloaded the whole style silently: no
|
||||
# glob broke, the styles/ diff stayed clean (the directory is still shipped,
|
||||
# only never loaded), the two .vale.ini files are deliberately unequal so no
|
||||
# equality check applies, and case 10's probe still passed because it keys on a
|
||||
# Kyberforge alert. Verified dead by probing a `.agent.md` carrying
|
||||
# "Use proactively": 0 alerts under the broken config, KyberforgeCopilot.
|
||||
# ProactivePhrase under the shipped one. CONTEXT.md describes the style as
|
||||
# "scoped only to `.agent.md` files for the Copilot-only 'Use proactively has
|
||||
# no effect' check", so shipping it unloaded is drift.
|
||||
echo ""
|
||||
echo "--- exits 1 when the shipped KyberforgeCopilot style is named by no BasedOnStyles ---"
|
||||
FIXTURE11C="$(make_fixture)"
|
||||
FIXTURES+=("$FIXTURE11C")
|
||||
break_glob "$FIXTURE11C/plugins/kyberforge/.apm/skills/agent-audit/assets/vale/.vale.ini" \
|
||||
'BasedOnStyles = Kyberforge, KyberforgeCopilot' 'BasedOnStyles = Kyberforge'
|
||||
if bash "$SCRIPT" "$FIXTURE11C" > /dev/null 2>&1; then
|
||||
fail "exited 0 when KyberforgeCopilot was dropped from BasedOnStyles -- expected exit 1"
|
||||
else
|
||||
pass "exits non-zero when a shipped KyberforgeCopilot style is never loaded"
|
||||
fi
|
||||
# The assertion is conditional on the style being shipped: a copy with no
|
||||
# KyberforgeCopilot directory (skill-audit's, by design) must stay clean --
|
||||
# case 13 below covers the shipped-and-loaded pairing.
|
||||
|
||||
# --- 10. Exits 1 when a glob section stops matching the shape its hook lints ---
|
||||
# One case per glob section, because each covers a file shape the others don't:
|
||||
# agent-audit's [**/*.agent.md] is the only section covering a Copilot agent file
|
||||
@@ -241,6 +357,37 @@ else
|
||||
pass "exits non-zero when agent-audit's *.agent.md glob matches nothing"
|
||||
fi
|
||||
|
||||
# --- 10b. Exits 1 when a glob is narrowed to this repo's own plugins/ layout ---
|
||||
# Every probe path used to start with `plugins/`, so a glob narrowed from a
|
||||
# filename shape to a location (`[**/SKILL.md]` -> `[**/.apm/skills/*/SKILL.md]`)
|
||||
# still matched all of them and the check passed -- while a project-scope
|
||||
# `.claude/skills/foo/SKILL.md` started linting as `0 errors ... in 0 files`,
|
||||
# exit 0, hook `Passed`: the exact failure the script's own header comment says
|
||||
# it exists to catch. CONTEXT.md: "A `SKILL.md` outside `plugins/` (e.g.
|
||||
# project-scope `.claude/skills/foo/SKILL.md`) still matches `[**/SKILL.md]` and
|
||||
# gets linted normally -- the globs constrain filename shape, not location."
|
||||
# These narrowings are still valid glob syntax and break no `plugins/`-shaped
|
||||
# file, so only a non-`plugins/` probe path catches them.
|
||||
echo ""
|
||||
echo "--- exits 1 when a .vale.ini glob is narrowed from a filename shape to a location ---"
|
||||
FIXTURE14B="$(make_fixture)"
|
||||
FIXTURE14C="$(make_fixture)"
|
||||
FIXTURES+=("$FIXTURE14B" "$FIXTURE14C")
|
||||
break_glob "$FIXTURE14B/plugins/kyberforge/.apm/skills/skill-audit/assets/vale/.vale.ini" \
|
||||
'[**/SKILL.md]' '[**/.apm/skills/*/SKILL.md]'
|
||||
break_glob "$FIXTURE14C/plugins/kyberforge/.apm/skills/agent-audit/assets/vale/.vale.ini" \
|
||||
'[**/agents/*.md]' '[**/.apm/agents/*.md]'
|
||||
if bash "$SCRIPT" "$FIXTURE14B" > /dev/null 2>&1; then
|
||||
fail "exited 0 when skill-audit's glob stopped covering a SKILL.md outside plugins/ -- expected exit 1"
|
||||
else
|
||||
pass "exits non-zero when skill-audit's glob stops covering a project-scope SKILL.md"
|
||||
fi
|
||||
if bash "$SCRIPT" "$FIXTURE14C" > /dev/null 2>&1; then
|
||||
fail "exited 0 when agent-audit's glob stopped covering an agents/*.md outside plugins/ -- expected exit 1"
|
||||
else
|
||||
pass "exits non-zero when agent-audit's glob stops covering a project-scope agents/*.md"
|
||||
fi
|
||||
|
||||
# --- 11. Exits 1 when a probe path falls out of every hook's `files:` regex ---
|
||||
# The probe paths are hardcoded, so they can silently stop representing anything
|
||||
# the hooks lint. Rescoping the shipped agent hook away from the `.agent.md`
|
||||
|
||||
170
tests/test-run-bats.sh
Normal file
170
tests/test-run-bats.sh
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for tests/run-bats.sh's TAP-result accounting.
|
||||
#
|
||||
# run-bats.sh runs each .bats file in its own process and aggregates the TAP
|
||||
# streams. It used to derive its test count from that text without ever asserting
|
||||
# the count was non-zero, so a `bats` that produced no output and exited 0 was
|
||||
# reported as "0 tests, 0 failures" with exit 0 -- a total harness failure
|
||||
# rendered as a clean pass. The guard added for that distinguishes two causes,
|
||||
# because `1..0` is itself valid TAP: no plan lines at all means bats emitted
|
||||
# nothing to parse, while plans present with zero results means bats ran fine and
|
||||
# the files genuinely declare no tests.
|
||||
#
|
||||
# Both branches were code-only and asserted by nothing, which is the same
|
||||
# "green either way" hole the guard itself closes. This file covers them.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
RUN_BATS="$REPO_ROOT/tests/run-bats.sh"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
FIXTURES=()
|
||||
cleanup() { [[ ${#FIXTURES[@]} -eq 0 ]] || rm -rf "${FIXTURES[@]}"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Builds a throwaway tree that a copy of run-bats.sh will resolve as its own
|
||||
# REPO_ROOT (it derives that from its own location), so these cases drive the
|
||||
# real script without the repo's actual .bats files being involved. The fixtures
|
||||
# live under TMPDIR, never inside the repo, so the real suite cannot pick them up.
|
||||
#
|
||||
# This prints the directory and does NOT register it for cleanup: every caller
|
||||
# invokes it as `$(make_fake_repo)`, and an append made in here would land in the
|
||||
# command substitution's subshell and be lost. Registration is the caller's job.
|
||||
make_fake_repo() {
|
||||
local dir
|
||||
dir="$(mktemp -d)"
|
||||
mkdir -p "$dir/tests" "$dir/scripts/lib"
|
||||
cp "$REPO_ROOT/scripts/lib/batch-run.sh" "$dir/scripts/lib/batch-run.sh"
|
||||
cp "$RUN_BATS" "$dir/tests/run-bats.sh"
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
# Writes a stub `bats` from stdin. Executable, so run-bats.sh never falls through
|
||||
# to its submodule-init branch.
|
||||
install_stub_bats() {
|
||||
mkdir -p "$1/tests/bats/bin"
|
||||
cat > "$1/tests/bats/bin/bats"
|
||||
chmod +x "$1/tests/bats/bin/bats"
|
||||
}
|
||||
|
||||
# Two .bats files whose tests would fail if anything actually ran them. Their
|
||||
# content is irrelevant to the stub cases -- what matters is that files exist, so
|
||||
# run-bats.sh gets past its "no .bats test files found" early exit and the zero
|
||||
# count it then sees can only have come from the TAP stream.
|
||||
seed_bats_files() {
|
||||
printf '@test "a" { false; }\n' > "$1/tests/a.bats"
|
||||
printf '@test "b" { false; }\n' > "$1/tests/b.bats"
|
||||
}
|
||||
|
||||
# Runs the fixture's run-bats.sh, capturing output and exit code separately.
|
||||
FAKE_OUT=""
|
||||
FAKE_RC=0
|
||||
run_fake() {
|
||||
FAKE_RC=0
|
||||
FAKE_OUT="$(bash "$1/tests/run-bats.sh" 2>&1)" || FAKE_RC=$?
|
||||
}
|
||||
|
||||
# --- 1. A stub emitting nothing at all is a broken harness, not a clean run ---
|
||||
echo ""
|
||||
echo "--- a bats that emits no TAP output at all fails the run ---"
|
||||
DIR1="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR1")
|
||||
seed_bats_files "$DIR1"
|
||||
install_stub_bats "$DIR1" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
EOF
|
||||
run_fake "$DIR1"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a bats emitting nothing exited 0 — a total harness failure reported as a pass"
|
||||
elif echo "$FAKE_OUT" | grep -q "no TAP output at all"; then
|
||||
pass "an empty TAP stream fails the run and names the broken harness"
|
||||
else
|
||||
fail "the run failed but not with the broken-harness message: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 2. A stub emitting only a plan ran fine but declares no tests ---
|
||||
# This is what real bats produces for a .bats file with every @test removed, so
|
||||
# it must fail for a different, accurately-worded reason than case 1.
|
||||
echo ""
|
||||
echo "--- a bats emitting only a zero plan fails with the no-tests-declared message ---"
|
||||
DIR2="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR2")
|
||||
seed_bats_files "$DIR2"
|
||||
install_stub_bats "$DIR2" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "1..0"
|
||||
exit 0
|
||||
EOF
|
||||
run_fake "$DIR2"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "every test declaring zero tests exited 0 — wholesale @test removal reported as a pass"
|
||||
elif echo "$FAKE_OUT" | grep -q "declared 0 tests"; then
|
||||
pass "a plan-only TAP stream fails the run and names the removed tests"
|
||||
elif echo "$FAKE_OUT" | grep -q "no TAP output at all"; then
|
||||
fail "a valid '1..0' plan was misreported as a broken harness — the two branches are not distinguished"
|
||||
else
|
||||
fail "the run failed but not with the no-tests-declared message: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 3. A healthy TAP stream still passes and still counts correctly. The guard
|
||||
# must not turn into a blanket failure: this is the case that proves the two
|
||||
# above fail for their stated reason rather than because the guard fails always.
|
||||
echo ""
|
||||
echo "--- a healthy TAP stream passes with its full count ---"
|
||||
DIR3="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR3")
|
||||
seed_bats_files "$DIR3"
|
||||
install_stub_bats "$DIR3" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "1..2"
|
||||
echo "ok 1 first"
|
||||
echo "ok 2 second"
|
||||
exit 0
|
||||
EOF
|
||||
run_fake "$DIR3"
|
||||
if [[ $FAKE_RC -ne 0 ]]; then
|
||||
fail "a healthy TAP stream was failed by the zero-count guard: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^4 tests, 0 failures$"; then
|
||||
pass "two files reporting two passing tests each aggregate to 4 tests, 0 failures"
|
||||
else
|
||||
fail "a healthy TAP stream produced the wrong count: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 4. The same shapes out of the real bats binary. The stubs above encode an
|
||||
# assumption about what real bats emits; this pins that assumption. A genuinely
|
||||
# empty .bats file yields `1..0` and exit 0, so a suite holding one alongside a
|
||||
# real test file must still pass -- the zero-count guard fires on the aggregate,
|
||||
# not per file, and one declared test is enough to clear it.
|
||||
echo ""
|
||||
echo "--- an empty .bats file beside a real one still passes under the real bats ---"
|
||||
if [[ ! -x "$REPO_ROOT/tests/bats/bin/bats" ]]; then
|
||||
echo " SKIP: real bats is not initialized — run tests/run-bats.sh once to fetch the submodule"
|
||||
else
|
||||
DIR4="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR4")
|
||||
# Symlinked rather than copied: bats resolves its libexec relative to its own
|
||||
# path, so the tree has to stay intact. run-bats.sh excludes */tests/bats/* from
|
||||
# its own file search, so bats's bundled .bats suites are not collected here.
|
||||
ln -s "$REPO_ROOT/tests/bats" "$DIR4/tests/bats"
|
||||
: > "$DIR4/tests/empty.bats"
|
||||
printf '@test "a real passing test" { true; }\n' > "$DIR4/tests/real.bats"
|
||||
run_fake "$DIR4"
|
||||
if [[ $FAKE_RC -ne 0 ]]; then
|
||||
fail "an empty .bats file beside a real one failed the run: $FAKE_OUT"
|
||||
elif ! echo "$FAKE_OUT" | grep -q "^1\.\.0$"; then
|
||||
fail "real bats did not emit '1..0' for an empty file, so the case-2 stub no longer matches it: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^1 tests, 0 failures$"; then
|
||||
pass "an empty .bats file contributes a '1..0' plan and the suite still passes"
|
||||
else
|
||||
fail "the real-bats run passed with an unexpected count: $FAKE_OUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
@@ -9,15 +9,42 @@ FAIL=0
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# Minimal fixture: a bare directory with no .git of its own. Since
|
||||
# Fixture: a temp directory that is its own git worktree. Since
|
||||
# scripts/sync-marketplace-mirror.sh resolves REPO_ROOT via
|
||||
# `git rev-parse --show-toplevel 2>/dev/null || pwd`, and mktemp -d creates
|
||||
# directories outside any git worktree, cd'ing into the fixture before
|
||||
# invoking the script makes REPO_ROOT resolve to the fixture itself -- so
|
||||
# every test runs against an isolated .claude-plugin/ + .github/plugin/ pair
|
||||
# instead of this repo's real marketplace.json files.
|
||||
# `git rev-parse --show-toplevel 2>/dev/null || pwd`, isolation has to come
|
||||
# from that call answering "the fixture" -- so the fixture owns a real .git.
|
||||
#
|
||||
# Relying instead on `git rev-parse` FAILING inside a bare `mktemp -d` (its
|
||||
# previous form) is not isolation: two ordinary conditions make it succeed and
|
||||
# resolve to the LIVE repo, at which point every test writes to this repo's own
|
||||
# tracked .github/plugin/marketplace.json. Both are reproduced and fixed here:
|
||||
# - TMPDIR pointing inside a git worktree, which puts the fixture in one.
|
||||
# - An inherited GIT_DIR/GIT_WORK_TREE, which re-targets `git -C` and
|
||||
# `git rev-parse` regardless of cwd. tests/run-tests.sh is itself a pre-push
|
||||
# hook, and git hooks export exactly those variables -- the same leak
|
||||
# tests/test-git-hooks-install.sh:6-10 already defends against.
|
||||
# run_script() strips GIT_DIR/GIT_WORK_TREE for the second; `git init` here
|
||||
# covers the first and makes the resolution positive rather than accidental.
|
||||
# run-tests.sh:52-53 asserts no test writes back into the live repo tree, and
|
||||
# that claim now carries the concurrent runner.
|
||||
make_fixture() {
|
||||
mktemp -d
|
||||
local dir
|
||||
dir="$(mktemp -d)"
|
||||
# Physical path: `git rev-parse --show-toplevel` reports the resolved path,
|
||||
# and on macOS `mktemp -d` hands back one under the /tmp -> /private/tmp
|
||||
# symlink. Without -P the script's REPO_ROOT and the assertions' $FIXTURE
|
||||
# would name the same directory differently and every diff would compare
|
||||
# against a path the script never wrote.
|
||||
dir="$(cd "$dir" && pwd -P)"
|
||||
# Checked, not best-effort: this init IS the isolation invariant. `echo "$dir"`
|
||||
# is the last statement, so a silently-failed init would return a plain temp
|
||||
# dir, `git rev-parse --show-toplevel` would walk up to whatever repo encloses
|
||||
# it, and the suite would go back to writing into the live tree.
|
||||
if ! env -u GIT_DIR -u GIT_WORK_TREE git -C "$dir" init -q >/dev/null 2>&1; then
|
||||
echo "make_fixture: 'git init' failed in $dir — every test would then resolve REPO_ROOT to an enclosing repo and write outside the fixture" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
SRC_REL=".claude-plugin/marketplace.json"
|
||||
@@ -37,10 +64,14 @@ write_dst() {
|
||||
printf '%s' "$content" > "$dir/$DST_REL"
|
||||
}
|
||||
|
||||
# `env -u GIT_DIR -u GIT_WORK_TREE` mirrors tests/test-git-hooks-install.sh:10:
|
||||
# under a git hook (run-tests.sh runs as pre-push) those are exported, and the
|
||||
# script's `git rev-parse --show-toplevel` would then answer with the LIVE repo
|
||||
# no matter which directory it was invoked from.
|
||||
run_script() {
|
||||
local dir="$1"
|
||||
shift
|
||||
(cd "$dir" && bash "$SCRIPT" "$@")
|
||||
(cd "$dir" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" "$@")
|
||||
}
|
||||
|
||||
CLEANUP_DIRS=()
|
||||
@@ -67,6 +98,31 @@ else
|
||||
fail "missing source should exit 0 in --check mode, not report drift"
|
||||
fi
|
||||
|
||||
# --- 2b. Source missing but a mirror still present: --check must FAIL ---
|
||||
# --check used to exit 0 on any missing source, so deleting
|
||||
# .claude-plugin/marketplace.json left a stale .github/plugin/marketplace.json
|
||||
# reported as "no drift" -- a mirror of a file that no longer exists. That is
|
||||
# the silent divergence this script's header says it prevents ("keeps that
|
||||
# legacy mirror byte-identical ... instead of letting it silently drift"), and
|
||||
# scripts/sync-plugin-content.sh --check --all already errors on the same
|
||||
# condition. Case 2 above still holds: neither file present stays a no-op.
|
||||
echo ""
|
||||
echo "--- missing source with a surviving mirror: --check reports drift ---"
|
||||
FIXTURE2B="$(make_fixture)"; track "$FIXTURE2B"
|
||||
write_dst "$FIXTURE2B" "$CONTENT_A"
|
||||
if run_script "$FIXTURE2B" --check > /dev/null 2>&1; then
|
||||
fail "exited 0 with a stale mirror and no source -- expected drift (exit 1)"
|
||||
else
|
||||
pass "a mirror with no source left to mirror is reported as drift"
|
||||
fi
|
||||
# Real-sync mode keeps its no-op: it has nothing to copy, and deleting a
|
||||
# tracked file is not this script's call to make.
|
||||
if run_script "$FIXTURE2B" > /dev/null 2>&1 && [[ -f "$FIXTURE2B/$DST_REL" ]]; then
|
||||
pass "real-sync mode still no-ops on a missing source, leaving the mirror alone"
|
||||
else
|
||||
fail "real-sync mode should no-op on a missing source, not fail or delete the mirror"
|
||||
fi
|
||||
|
||||
# --- 3. Source exists, mirror missing entirely: --check reports drift (exit 1) ---
|
||||
echo ""
|
||||
echo "--- --check reports drift when the mirror file does not exist yet ---"
|
||||
@@ -179,6 +235,26 @@ else
|
||||
fail "a second sync run introduced unexpected drift"
|
||||
fi
|
||||
|
||||
# --- 12. Fixture isolation survives an inherited GIT_DIR/GIT_WORK_TREE ---
|
||||
# The whole suite's isolation is REPO_ROOT resolving to the fixture. With
|
||||
# GIT_DIR/GIT_WORK_TREE exported -- which is every git-hook context, and
|
||||
# run-tests.sh runs as pre-push -- `git rev-parse --show-toplevel` answers with
|
||||
# THAT repo from any cwd, so the script wrote to the live tree and 5 of these
|
||||
# cases failed. Point both variables at a decoy repo (never the live one, so
|
||||
# this assertion cannot itself write where it must not) and assert the fixture
|
||||
# still wins: the mirror lands in the fixture and the decoy stays untouched.
|
||||
echo ""
|
||||
echo "--- fixture isolation holds with GIT_DIR/GIT_WORK_TREE inherited from elsewhere ---"
|
||||
FIXTURE12="$(make_fixture)"; track "$FIXTURE12"
|
||||
DECOY="$(make_fixture)"; track "$DECOY"
|
||||
write_src "$FIXTURE12" "$CONTENT_A"
|
||||
if (export GIT_DIR="$DECOY/.git" GIT_WORK_TREE="$DECOY"; run_script "$FIXTURE12" > /dev/null 2>&1) \
|
||||
&& [[ -f "$FIXTURE12/$DST_REL" ]] && [[ ! -e "$DECOY/$DST_REL" ]]; then
|
||||
pass "an inherited GIT_DIR/GIT_WORK_TREE does not redirect writes out of the fixture"
|
||||
else
|
||||
fail "an inherited GIT_DIR/GIT_WORK_TREE redirected the sync outside the fixture"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
@@ -14,14 +14,27 @@ if ! command -v apm &>/dev/null; then
|
||||
exit 77
|
||||
fi
|
||||
|
||||
# Minimal fixture: one skill (with a tests/ fixture that must NOT be mirrored), one
|
||||
# agent, one hooks.json -- enough to exercise every mirrored category
|
||||
# (scripts/sync-plugin-content.sh's MIRROR_DIRS plus hooks.json) without needing
|
||||
# network access (no apm.yml dependencies).
|
||||
# Minimal fixture exercising every mirrored category -- all five of
|
||||
# scripts/sync-plugin-content.sh's MIRROR_DIRS (agents, skills, commands,
|
||||
# instructions, extensions) plus the merged hooks file -- without needing network
|
||||
# access (no apm.yml dependencies). Two deliberately-shaped skill subdirectories:
|
||||
#
|
||||
# skills/hello/tests/ -- a dev-time fixture that must NOT be mirrored
|
||||
# skills/hello/assets/templates/tests/ -- a template asset that MUST be mirrored
|
||||
#
|
||||
# Those two are the same basename at different depths. The exclusion is depth-scoped
|
||||
# for exactly this reason: the real skill-author skill ships a template tree it
|
||||
# scaffolds from, and a depth-agnostic strip amputated it.
|
||||
#
|
||||
# skills/hello/scripts/run.sh is executable so the mode/symlink drift checks have a
|
||||
# real executable to tamper with.
|
||||
make_fixture() {
|
||||
local dir
|
||||
dir="$(mktemp -d)"
|
||||
mkdir -p "$dir/.apm/skills/hello/tests" "$dir/.apm/agents" "$dir/.apm/hooks"
|
||||
mkdir -p "$dir/.apm/skills/hello/tests" "$dir/.apm/skills/hello/scripts" \
|
||||
"$dir/.apm/skills/hello/assets/templates/tests" "$dir/.apm/agents" \
|
||||
"$dir/.apm/hooks" "$dir/.apm/commands" "$dir/.apm/instructions" \
|
||||
"$dir/.apm/extensions"
|
||||
cat > "$dir/apm.yml" <<'YAML'
|
||||
name: fixture
|
||||
version: 0.0.1
|
||||
@@ -48,12 +61,35 @@ EOF
|
||||
cat > "$dir/.apm/skills/hello/tests/sample.bats" <<'EOF'
|
||||
@test "dummy" { true; }
|
||||
EOF
|
||||
cat > "$dir/.apm/skills/hello/assets/templates/tests/README.md" <<'EOF'
|
||||
Template asset: scaffolded into a new skill, not a dev fixture of this one.
|
||||
EOF
|
||||
cat > "$dir/.apm/skills/hello/scripts/run.sh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo hi
|
||||
EOF
|
||||
chmod +x "$dir/.apm/skills/hello/scripts/run.sh"
|
||||
cat > "$dir/.apm/agents/foo.agent.md" <<'EOF'
|
||||
---
|
||||
name: foo
|
||||
description: foo
|
||||
---
|
||||
Foo.
|
||||
EOF
|
||||
cat > "$dir/.apm/commands/mycmd.md" <<'EOF'
|
||||
---
|
||||
description: mycmd
|
||||
---
|
||||
Do a thing.
|
||||
EOF
|
||||
cat > "$dir/.apm/instructions/style.instructions.md" <<'EOF'
|
||||
---
|
||||
applyTo: "**"
|
||||
---
|
||||
Be consistent.
|
||||
EOF
|
||||
cat > "$dir/.apm/extensions/thing.md" <<'EOF'
|
||||
Extension content.
|
||||
EOF
|
||||
cat > "$dir/.apm/hooks/hooks.json" <<'EOF'
|
||||
{"hooks": {"PreToolUse": []}}
|
||||
@@ -119,16 +155,45 @@ fi
|
||||
|
||||
# --- 2. A real sync creates the flat mirror and exits 0 ---
|
||||
echo ""
|
||||
echo "--- real sync creates skills/, agents/, hooks.json ---"
|
||||
echo "--- real sync creates every MIRROR_DIRS category plus hooks/hooks.json ---"
|
||||
if bash "$SCRIPT" "$FIXTURE" > /dev/null 2>&1 \
|
||||
&& [[ -f "$FIXTURE/skills/hello/SKILL.md" ]] \
|
||||
&& [[ -f "$FIXTURE/agents/foo.agent.md" ]] \
|
||||
&& [[ -f "$FIXTURE/hooks.json" ]]; then
|
||||
pass "sync creates the expected flat mirror"
|
||||
&& [[ -f "$FIXTURE/commands/mycmd.md" ]] \
|
||||
&& [[ -f "$FIXTURE/instructions/style.instructions.md" ]] \
|
||||
&& [[ -f "$FIXTURE/extensions/thing.md" ]] \
|
||||
&& [[ -f "$FIXTURE/hooks/hooks.json" ]]; then
|
||||
pass "sync creates the expected flat mirror for all five MIRROR_DIRS plus hooks/hooks.json"
|
||||
else
|
||||
fail "sync did not create the expected flat mirror"
|
||||
fi
|
||||
|
||||
# --- 2b. The merged hooks file goes to hooks/hooks.json, never the plugin root ---
|
||||
# Claude Code convention-scans `hooks/hooks.json` at the plugin root (see
|
||||
# plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md's
|
||||
# "Plugin Directory Layout" table, quoted in ADR-0017), and the compiled plugin.json
|
||||
# carries no `hooks` pointer that could redirect it. A root-level hooks.json is read
|
||||
# by nothing.
|
||||
echo ""
|
||||
echo "--- the merged hooks file is not left at the plugin root ---"
|
||||
if [[ ! -e "$FIXTURE/hooks.json" ]]; then
|
||||
pass "no root-level hooks.json after a sync"
|
||||
else
|
||||
fail "sync wrote hooks.json to the plugin root — Claude Code scans hooks/hooks.json"
|
||||
fi
|
||||
|
||||
# --- 2c. hooks/hooks.json is newline-terminated ---
|
||||
# normalize_trailing_newline() exists so pre-commit's end-of-file-fixer does not
|
||||
# re-dirty the tree on every sync: apm's bundle exporter emits hooks.json with no
|
||||
# trailing newline, the committed file has one.
|
||||
echo ""
|
||||
echo "--- the synced hooks file ends in a newline ---"
|
||||
if [[ -n "$(tail -c 1 "$FIXTURE/hooks/hooks.json")" ]]; then
|
||||
fail "hooks/hooks.json has no trailing newline — end-of-file-fixer will re-dirty it every sync"
|
||||
else
|
||||
pass "hooks/hooks.json is newline-terminated"
|
||||
fi
|
||||
|
||||
# --- 3. tests/ fixtures are excluded from the mirror ---
|
||||
echo ""
|
||||
echo "--- tests/ subdirectories are not mirrored ---"
|
||||
@@ -138,6 +203,20 @@ else
|
||||
fail "skills/hello/tests/ was copied into the mirror — should be excluded"
|
||||
fi
|
||||
|
||||
# --- 3b. ...but a deeper tests/ that is a template ASSET must survive ---
|
||||
# The exclusion above is depth-scoped to <category>/<name>/tests. Stripping every
|
||||
# directory named tests at any depth also deletes template trees a skill ships for
|
||||
# its own scaffolder to copy from — which is what broke the mirrored
|
||||
# skills/skill-author/scripts/new-skill.sh (`sed: can't read .../tests/README.md`,
|
||||
# half-written scaffold left behind) while the .apm/ original still worked.
|
||||
echo ""
|
||||
echo "--- a tests/ directory nested under assets/templates/ is preserved ---"
|
||||
if [[ -f "$FIXTURE/skills/hello/assets/templates/tests/README.md" ]]; then
|
||||
pass "skills/hello/assets/templates/tests/ survived the sync"
|
||||
else
|
||||
fail "skills/hello/assets/templates/tests/ was stripped — template assets are not dev fixtures"
|
||||
fi
|
||||
|
||||
# --- 4. --check is clean immediately after a real sync ---
|
||||
echo ""
|
||||
echo "--- --check is clean right after syncing ---"
|
||||
@@ -277,29 +356,186 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 13. --check detects an orphaned hooks.json after .apm/hooks/ is removed ---
|
||||
# --- 13. --check detects an orphaned hooks file after .apm/hooks/ is removed ---
|
||||
echo ""
|
||||
echo "--- --check detects an orphaned hooks.json when .apm/hooks/ is removed ---"
|
||||
echo "--- --check detects an orphaned hooks/hooks.json when .apm/hooks/ is removed ---"
|
||||
FIXTURE13="$(make_fixture)"; track "$FIXTURE13"
|
||||
bash "$SCRIPT" "$FIXTURE13" > /dev/null 2>&1
|
||||
if [[ ! -f "$FIXTURE13/hooks.json" ]]; then
|
||||
fail "initial sync did not create hooks.json -- can't test the orphan case"
|
||||
if [[ ! -f "$FIXTURE13/hooks/hooks.json" ]]; then
|
||||
fail "initial sync did not create hooks/hooks.json -- can't test the orphan case"
|
||||
fi
|
||||
rm -rf "$FIXTURE13/.apm/hooks"
|
||||
if bash "$SCRIPT" --check "$FIXTURE13" > /dev/null 2>&1; then
|
||||
fail "no drift reported for an orphaned hooks.json after .apm/hooks/ removal"
|
||||
fail "no drift reported for an orphaned hooks/hooks.json after .apm/hooks/ removal"
|
||||
else
|
||||
pass "orphaned hooks.json is detected as drift"
|
||||
pass "orphaned hooks/hooks.json is detected as drift"
|
||||
bash "$SCRIPT" "$FIXTURE13" > /dev/null 2>&1
|
||||
if [[ ! -e "$FIXTURE13/hooks.json" ]]; then
|
||||
pass "re-sync removes the orphaned hooks.json"
|
||||
if [[ ! -e "$FIXTURE13/hooks/hooks.json" ]]; then
|
||||
pass "re-sync removes the orphaned hooks/hooks.json"
|
||||
else
|
||||
fail "re-sync left the orphaned hooks.json in place"
|
||||
fail "re-sync left the orphaned hooks/hooks.json in place"
|
||||
fi
|
||||
if bash "$SCRIPT" --check "$FIXTURE13" > /dev/null 2>&1; then
|
||||
pass "re-sync clears the orphaned-hooks.json drift"
|
||||
pass "re-sync clears the orphaned-hooks drift"
|
||||
else
|
||||
fail "re-sync did not clear the orphaned-hooks.json drift"
|
||||
fail "re-sync did not clear the orphaned-hooks drift"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 14. A legacy root-level hooks.json is stale output, not content ---
|
||||
# Every plugin synced by an earlier revision of this script carries one. Nothing
|
||||
# reads it (no `hooks` pointer in the compiled plugin.json, and Claude Code's
|
||||
# convention scan looks at hooks/hooks.json), so --check must flag it and a real
|
||||
# sync must delete it.
|
||||
echo ""
|
||||
echo "--- a legacy root-level hooks.json is reported as drift and removed by a sync ---"
|
||||
FIXTURE14="$(make_fixture)"; track "$FIXTURE14"
|
||||
bash "$SCRIPT" "$FIXTURE14" > /dev/null 2>&1
|
||||
printf '{"hooks": {"PreToolUse": []}}\n' > "$FIXTURE14/hooks.json"
|
||||
if bash "$SCRIPT" --check "$FIXTURE14" > /dev/null 2>&1; then
|
||||
fail "no drift reported for a leftover root-level hooks.json"
|
||||
else
|
||||
pass "a leftover root-level hooks.json is reported as drift"
|
||||
bash "$SCRIPT" "$FIXTURE14" > /dev/null 2>&1
|
||||
if [[ ! -e "$FIXTURE14/hooks.json" ]] && [[ -f "$FIXTURE14/hooks/hooks.json" ]]; then
|
||||
pass "re-sync deletes the root-level hooks.json and keeps hooks/hooks.json"
|
||||
else
|
||||
fail "re-sync did not clean up the root-level hooks.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 15. Deleting a skill from .apm/ leaves a stale mirror a re-sync must clear ---
|
||||
# Without sync_dir()'s rm -rf of the destination before recopying, --check would
|
||||
# report a drift that no amount of re-syncing could ever clear -- a permanently
|
||||
# unfixable pre-push failure. This is the assertion that pins that wipe.
|
||||
echo ""
|
||||
echo "--- a skill deleted from .apm/ is removed from the mirror by a re-sync ---"
|
||||
FIXTURE15="$(make_fixture)"; track "$FIXTURE15"
|
||||
mkdir -p "$FIXTURE15/.apm/skills/doomed"
|
||||
cat > "$FIXTURE15/.apm/skills/doomed/SKILL.md" <<'EOF'
|
||||
---
|
||||
name: doomed
|
||||
description: doomed
|
||||
---
|
||||
Doomed.
|
||||
EOF
|
||||
bash "$SCRIPT" "$FIXTURE15" > /dev/null 2>&1
|
||||
if [[ ! -f "$FIXTURE15/skills/doomed/SKILL.md" ]]; then
|
||||
fail "initial sync did not mirror skills/doomed -- can't test the stale-skill case"
|
||||
else
|
||||
rm -rf "$FIXTURE15/.apm/skills/doomed"
|
||||
if bash "$SCRIPT" --check "$FIXTURE15" > /dev/null 2>&1; then
|
||||
fail "no drift reported for a mirrored skill deleted from .apm/"
|
||||
else
|
||||
pass "a mirrored skill deleted from .apm/ is reported as drift"
|
||||
bash "$SCRIPT" "$FIXTURE15" > /dev/null 2>&1
|
||||
if [[ ! -e "$FIXTURE15/skills/doomed" ]]; then
|
||||
pass "re-sync removes the stale skills/doomed/ from the mirror"
|
||||
else
|
||||
fail "re-sync left the stale skills/doomed/ behind — this drift would be unfixable"
|
||||
fi
|
||||
if bash "$SCRIPT" --check "$FIXTURE15" > /dev/null 2>&1; then
|
||||
pass "re-sync clears the stale-skill drift"
|
||||
else
|
||||
fail "re-sync did not clear the stale-skill drift"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 16. Drift in each of the less-obvious MIRROR_DIRS is detected ---
|
||||
# agents/ and skills/ are exercised everywhere above; commands/, instructions/, and
|
||||
# extensions/ were previously unreachable by the fixture, so dropping them from
|
||||
# MIRROR_DIRS entirely still passed the suite.
|
||||
echo ""
|
||||
echo "--- drift in commands/, instructions/, and extensions/ is detected ---"
|
||||
for CATEGORY_PATH in commands/mycmd.md instructions/style.instructions.md extensions/thing.md; do
|
||||
FIXTURE16="$(make_fixture)"; track "$FIXTURE16"
|
||||
bash "$SCRIPT" "$FIXTURE16" > /dev/null 2>&1
|
||||
if [[ ! -f "$FIXTURE16/$CATEGORY_PATH" ]]; then
|
||||
fail "sync did not mirror $CATEGORY_PATH at all — is its category still in MIRROR_DIRS?"
|
||||
continue
|
||||
fi
|
||||
printf 'tampered\n' >> "$FIXTURE16/$CATEGORY_PATH"
|
||||
if bash "$SCRIPT" --check "$FIXTURE16" > /dev/null 2>&1; then
|
||||
fail "no drift reported after tampering with $CATEGORY_PATH"
|
||||
else
|
||||
pass "drift in $CATEGORY_PATH is detected"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- 17. --check reports every drift in one run, not just the first ---
|
||||
# The DRIFT-detail `diff | sed` pipelines return non-zero under `set -o pipefail`;
|
||||
# without an explicit `|| true` guard, `set -e` aborts the per-plugin subshell after
|
||||
# the first reported drift, turning one push into N fix/re-push cycles.
|
||||
echo ""
|
||||
echo "--- --check reports all independent drifts in a single run ---"
|
||||
FIXTURE17="$(make_fixture)"; track "$FIXTURE17"
|
||||
bash "$SCRIPT" "$FIXTURE17" > /dev/null 2>&1
|
||||
printf 'tampered\n' >> "$FIXTURE17/agents/foo.agent.md"
|
||||
printf 'tampered\n' >> "$FIXTURE17/skills/hello/SKILL.md"
|
||||
printf 'tampered\n' >> "$FIXTURE17/commands/mycmd.md"
|
||||
printf 'tampered\n' >> "$FIXTURE17/instructions/style.instructions.md"
|
||||
mkdir -p "$FIXTURE17/hooks"
|
||||
printf '{"hooks": {"PreToolUse": [], "tampered": true}}\n' > "$FIXTURE17/hooks/hooks.json"
|
||||
CHECK17="$(bash "$SCRIPT" --check "$FIXTURE17" 2>&1 || true)"
|
||||
MISSED=""
|
||||
for CATEGORY_PATH in agents skills commands instructions hooks/hooks.json; do
|
||||
case "$CHECK17" in
|
||||
*"DRIFT $FIXTURE17/$CATEGORY_PATH"*) ;;
|
||||
*) MISSED="$MISSED $CATEGORY_PATH" ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z "$MISSED" ]]; then
|
||||
pass "all five independent drifts are reported in one --check run"
|
||||
else
|
||||
fail "--check stopped early — never reported drift for:$MISSED"
|
||||
fi
|
||||
|
||||
# --- 18. --check sees a mode change on a mirrored executable ---
|
||||
# `diff -r` compares content only, so a chmod -x left --check at exit 0 while a real
|
||||
# sync silently restored the bit — check and sync disagreeing.
|
||||
echo ""
|
||||
echo "--- --check detects a mode change on a mirrored executable ---"
|
||||
FIXTURE18="$(make_fixture)"; track "$FIXTURE18"
|
||||
bash "$SCRIPT" "$FIXTURE18" > /dev/null 2>&1
|
||||
if [[ ! -x "$FIXTURE18/skills/hello/scripts/run.sh" ]]; then
|
||||
fail "sync did not preserve the executable bit on skills/hello/scripts/run.sh"
|
||||
else
|
||||
pass "sync preserves the executable bit on a mirrored script"
|
||||
chmod -x "$FIXTURE18/skills/hello/scripts/run.sh"
|
||||
if bash "$SCRIPT" --check "$FIXTURE18" > /dev/null 2>&1; then
|
||||
fail "no drift reported after chmod -x on a mirrored executable"
|
||||
else
|
||||
pass "a mode change on a mirrored executable is detected as drift"
|
||||
bash "$SCRIPT" "$FIXTURE18" > /dev/null 2>&1
|
||||
if [[ -x "$FIXTURE18/skills/hello/scripts/run.sh" ]]; then
|
||||
pass "re-sync restores the executable bit"
|
||||
else
|
||||
fail "re-sync did not restore the executable bit"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 19. --check sees a mirrored file replaced by a symlink ---
|
||||
# `diff -r` dereferences symlinks, so a symlink to byte-identical content reads as
|
||||
# no drift while a real sync replaces it with a regular file.
|
||||
echo ""
|
||||
echo "--- --check detects a mirrored file swapped for a symlink ---"
|
||||
FIXTURE19="$(make_fixture)"; track "$FIXTURE19"
|
||||
bash "$SCRIPT" "$FIXTURE19" > /dev/null 2>&1
|
||||
SYMLINK_TARGET="$FIXTURE19/decoy-agent.md"
|
||||
cp "$FIXTURE19/agents/foo.agent.md" "$SYMLINK_TARGET"
|
||||
rm -f "$FIXTURE19/agents/foo.agent.md"
|
||||
ln -s "$SYMLINK_TARGET" "$FIXTURE19/agents/foo.agent.md"
|
||||
if bash "$SCRIPT" --check "$FIXTURE19" > /dev/null 2>&1; then
|
||||
fail "no drift reported after replacing a mirrored file with a symlink to identical content"
|
||||
else
|
||||
pass "a mirrored file replaced by a symlink is detected as drift"
|
||||
bash "$SCRIPT" "$FIXTURE19" > /dev/null 2>&1
|
||||
if [[ -f "$FIXTURE19/agents/foo.agent.md" ]] && [[ ! -L "$FIXTURE19/agents/foo.agent.md" ]]; then
|
||||
pass "re-sync restores it to a regular file"
|
||||
else
|
||||
fail "re-sync did not restore the symlinked mirror entry to a regular file"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -442,77 +442,214 @@ fi
|
||||
# is safe on 3.2. Neither is an array seeded with at least one element where it
|
||||
# is declared and never reset to empty: it cannot be empty at any expansion
|
||||
# site, so the construct is not a hazard there and demanding the guarded form
|
||||
# would be a wrong test. The file list covers every script this repo ships or
|
||||
# runs that a macOS user reaches: the wrapper itself, the two pre-commit hook
|
||||
# scripts, the test runner AGENTS.md tells contributors to run by hand, its
|
||||
# bats-dispatch companion, and the three test-*.sh scripts whose
|
||||
# `trap 'rm -rf "${CLEANUP_DIRS[@]}"' EXIT` cleanup traps were unguarded (PR
|
||||
# #95 review finding #7 named two of them; a repo-wide grep for the same
|
||||
# pattern turned up test-check-release-needed.sh as a third) until they were
|
||||
# switched to the guarded form.
|
||||
# `mapfile` is checked alongside, because it is bash 4.0+ and the expansion scan
|
||||
# cannot see it — run-tests.sh carried one until it was replaced with a
|
||||
# `while read` loop, and nothing would have caught its return. `declare -A`
|
||||
# (bash 4.0+ associative arrays) is checked for the same reason — the
|
||||
# expansion scan cannot see it, and check-vale-style-sync.sh carried a pair of
|
||||
# them until they were replaced with index-scanned plain arrays.
|
||||
# would be a wrong test. Neither is an expansion whose own line first proves the
|
||||
# array non-empty (`[[ ${#a[@]} -eq 0 ]] || rm -rf "${a[@]}"`) — the short-circuit
|
||||
# means the expansion is unreachable when the array is empty.
|
||||
#
|
||||
# The scanned file list is DERIVED, not hand-maintained. A hardcoded list only
|
||||
# guards the scripts someone remembered to add to it, and it silently fails to
|
||||
# cover anything new: it omitted scripts/lib/batch-run.sh — the shared runner
|
||||
# this branch introduced, whose own header (batch-run.sh:9-11) documents it as
|
||||
# bash-3.2-safe — along with four other scripts/*.sh. Deriving the list means a
|
||||
# new script is covered the moment it lands. AGENTS.md names bash 3.2 as an
|
||||
# explicit repo target, so the scope is three globs, each floor-asserted below:
|
||||
# - scripts/**/*.sh — repo tooling and pre-commit hook scripts
|
||||
# - tests/*.sh — the runners and every regression test
|
||||
# - plugins/*/.apm/**/*.sh — the scripts plugins ship to users
|
||||
# plugins/*/skills/** is deliberately NOT scanned: it is the generated mirror of
|
||||
# .apm/, so scanning both double-reports every finding, and mirror-vs-source
|
||||
# drift is already sync-plugin-content.sh --check's job. Scanning .apm/ is what
|
||||
# closed the gap where skill-audit's vale-wrap.sh was covered but agent-audit's
|
||||
# byte-identical copy of it was not.
|
||||
# providers/**/*.sh is the one shipped script deliberately left out:
|
||||
# providers/claude-code/statusline-command.sh seeds `parts=()` empty at :83 and
|
||||
# expands it unguarded at :96. That is a real latent hazard rather than a false
|
||||
# positive — it just cannot abort today because the file enables no `set -u`.
|
||||
# Fixing it is a change to a file this case does not own; once :96 uses the
|
||||
# guarded form, add a `providers` glob to the table below.
|
||||
#
|
||||
# Four constructs are checked, because the expansion scan cannot see any of the
|
||||
# other three:
|
||||
# - `mapfile`/`readarray` — bash 4.0+ builtins. run-tests.sh carried one until
|
||||
# it was replaced with a `while read` loop.
|
||||
# - `declare -A` — bash 4.0+ associative arrays. check-vale-style-sync.sh
|
||||
# carried a pair until they became index-scanned plain arrays.
|
||||
# - `wait -n` — bash 4.3+. A prior review round found this live in run-bats.sh.
|
||||
# - `nproc` — GNU coreutils, absent on macOS entirely. Same review round, same
|
||||
# file. `getconf _NPROCESSORS_ONLN` is the portable spelling batch-run.sh
|
||||
# settled on.
|
||||
# The last two had no static guard anywhere before this, and
|
||||
# `shellcheck --severity=warning` (.pre-commit-config.yaml) pins no target
|
||||
# version, so it does not catch them either — meaning the guard against the last
|
||||
# regression would not have caught the last regression.
|
||||
echo ""
|
||||
echo "--- no unguarded array expansion remains in the macOS-facing scripts ---"
|
||||
echo "--- no bash-4-only construct remains in the macOS-facing scripts ---"
|
||||
# Files a script pulls in via `source`, as named by its `# shellcheck source=`
|
||||
# directives. Array seeding often lives in the sourced file (install.sh's
|
||||
# DEPLOY_* come from deploy-manifest.sh), so the seeding check has to look
|
||||
# there too or it reports a false hazard. shellcheck resolves these paths
|
||||
# against either the repo root or the script's own directory depending on
|
||||
# configuration, so both are tried and whichever exists is used.
|
||||
sourced_files() {
|
||||
local file="$1" rel cand
|
||||
while IFS= read -r rel; do
|
||||
for cand in "$REPO_ROOT/$rel" "$(dirname "$file")/$rel"; do
|
||||
if [[ -f "$cand" ]]; then
|
||||
printf '%s\n' "$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done < <(
|
||||
grep -oE '^[[:space:]]*#[[:space:]]*shellcheck[[:space:]]+source=[^[:space:]]+' "$file" \
|
||||
| sed -E 's/.*source=//' || true
|
||||
)
|
||||
return 0
|
||||
}
|
||||
unguarded_expansions() {
|
||||
local file="$1" hit name
|
||||
local file="$1" hit name seed_file
|
||||
local seed_files
|
||||
seed_files=("$file")
|
||||
while IFS= read -r seed_file; do
|
||||
seed_files+=("$seed_file")
|
||||
done < <(sourced_files "$file")
|
||||
while IFS= read -r hit; do
|
||||
name="$(printf '%s\n' "$hit" \
|
||||
| grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\[@\]\}' | head -1 \
|
||||
| sed -E 's/^\$\{//; s/\[@\]\}$//')"
|
||||
if grep -qE "^[[:space:]]*((local|declare|readonly)[[:space:]]+)?(-a[[:space:]]+)?$name=\([^)]" "$file" \
|
||||
&& ! grep -qE "^[[:space:]]*$name=\(\)" "$file"; then
|
||||
# Shell-maintained arrays are never seeded by a `NAME=(...)` line, so the
|
||||
# seeding exemption below can never clear them: without this case they are
|
||||
# permanent false positives. Exempted are the ones measured non-empty inside
|
||||
# a running script -- element counts taken at a script's top level:
|
||||
# PIPESTATUS >=1 once any command has run (0 only before the very first,
|
||||
# where the variable is meaningless anyway)
|
||||
# BASH_SOURCE 1 (one frame per sourced/executed file)
|
||||
# BASH_LINENO 1 (maintained in parallel with BASH_SOURCE)
|
||||
# BASH_VERSINFO 6 (always exactly six)
|
||||
# GROUPS 1
|
||||
# DIRSTACK 1 (always holds at least the current directory)
|
||||
#
|
||||
# FUNCNAME, BASH_ARGV, BASH_ARGC, BASH_REMATCH and COMP_WORDS are
|
||||
# deliberately NOT exempted despite being shell-maintained, because they are
|
||||
# genuinely empty in reachable states: FUNCNAME is 0 outside a function,
|
||||
# BASH_ARGV is 0 without `shopt -s extdebug`, BASH_ARGC is 0 *inside a
|
||||
# function* (it looks safe when measured at top level, where it is 1 -- it is
|
||||
# not), BASH_REMATCH is 0 until a `=~` match succeeds, COMP_WORDS is 0
|
||||
# outside completion. Expanding any of those bare really does abort on bash
|
||||
# 3.2 under `set -u`, so flagging them is the correct answer rather than a
|
||||
# false positive. Case 26 pins this split so neither half drifts.
|
||||
case "$name" in
|
||||
PIPESTATUS|BASH_SOURCE|BASH_LINENO|BASH_VERSINFO|GROUPS|DIRSTACK) continue ;;
|
||||
esac
|
||||
# Same-line emptiness short-circuit: the expansion cannot be reached empty.
|
||||
if printf '%s\n' "$hit" \
|
||||
| grep -qE "\\\$\{#$name\[@\]\}[[:space:]]*-(eq|lt)[[:space:]]*[01][^|]*\|\|"; then
|
||||
continue
|
||||
fi
|
||||
for seed_file in ${seed_files[@]+"${seed_files[@]}"}; do
|
||||
# `([^)]|$)` after the paren, not just `[^)]`: a multi-line declaration
|
||||
# (`DEPLOY_FILES=(` with its elements on the following lines) ends the line
|
||||
# right there, and requiring a character after the paren missed it. An
|
||||
# empty `name=()` still does not match, which is what the check is for.
|
||||
if grep -qE "^[[:space:]]*((local|declare|readonly)[[:space:]]+)?(-a[[:space:]]+)?$name=\(([^)]|$)" "$seed_file" \
|
||||
&& ! grep -qE "^[[:space:]]*$name=\(\)" "$seed_file"; then
|
||||
continue 2
|
||||
fi
|
||||
done
|
||||
printf '%s:%s\n' "${file##*/}" "$hit"
|
||||
done < <(
|
||||
# Blank out whole-line comments (keeping line numbers), delete every
|
||||
# correctly guarded expansion, then anything still matching is a candidate.
|
||||
# correctly guarded expansion — in both its bare spelling and the
|
||||
# backslash-escaped one that appears inside this file's own PASS message —
|
||||
# then anything still matching is a candidate.
|
||||
awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$file" \
|
||||
| sed -E 's/\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\+"\$\{\1\[@\]\}"\}//g' \
|
||||
| sed -E 's/\\?\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\+\\?"\\?\$\{\1\[@\]\}\\?"\}//g' \
|
||||
| grep -nE '\$\{[A-Za-z_][A-Za-z0-9_]*\[@\]\}' || true
|
||||
)
|
||||
}
|
||||
# Blanks WHOLE-LINE comments only (keeping line numbers so hits stay locatable),
|
||||
# so prose naming a hazard on its own line is not a hit.
|
||||
#
|
||||
# KNOW THIS BEFORE YOU EDIT ANY SCANNED FILE. Nothing else is stripped — this is
|
||||
# a line-based scanner, not a shell parser — so all of the following DO trip the
|
||||
# case even though none is a real hazard:
|
||||
# true # avoid nproc on macOS <- trailing comment naming a hazard
|
||||
# echo "avoid mapfile in scripts" <- hazard named inside a string
|
||||
# <<EOF ... nproc ... EOF <- hazard named in a heredoc body
|
||||
# foo # see ${x[@]} <- trailing comment holding an expansion
|
||||
# Teaching it to parse shell would cost far more than it returns, so the rule is:
|
||||
# put the mention on its own comment line, or break the token with a one-character
|
||||
# bracket class the way the `npro[c]` rule below does — `npro[c]` matches exactly
|
||||
# what a bare spelling would while containing no bare spelling itself. This
|
||||
# matters because a static check that cries wolf is a static check someone
|
||||
# eventually deletes.
|
||||
strip_comments() { awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$1"; }
|
||||
bash32_glob() {
|
||||
case "$1" in
|
||||
scripts) find "$REPO_ROOT/scripts" -name '*.sh' -type f ;;
|
||||
tests) find "$REPO_ROOT/tests" -maxdepth 1 -name '*.sh' -type f ;;
|
||||
plugins) find "$REPO_ROOT/plugins" -path '*/.apm/*' -name '*.sh' -type f ;;
|
||||
*) echo "bash32_glob: unknown glob '$1'" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
# Floors are PER GLOB, not on the merged total. A single total floor cannot
|
||||
# detect the failure this assertion exists to name: with 12 + 17 + 13 files,
|
||||
# losing the whole `scripts` glob still leaves 30 and losing the whole `tests`
|
||||
# glob still leaves 25, so any total floor low enough to survive normal churn
|
||||
# is too low to notice an entire glob silently resolving to nothing. Each floor
|
||||
# sits a little under its current count so ordinary file removal does not trip
|
||||
# it, but a broken or renamed path does. Parallel arrays rather than an
|
||||
# associative one — `declare -A` is bash 4.0+, which this very case forbids.
|
||||
BASH32_GLOB_NAMES=(scripts tests plugins)
|
||||
BASH32_GLOB_FLOORS=(10 14 10)
|
||||
BASH32_SCRIPTS=()
|
||||
BASH32_IDX=0
|
||||
while [[ $BASH32_IDX -lt ${#BASH32_GLOB_NAMES[@]} ]]; do
|
||||
BASH32_GLOB="${BASH32_GLOB_NAMES[$BASH32_IDX]}"
|
||||
BASH32_FLOOR="${BASH32_GLOB_FLOORS[$BASH32_IDX]}"
|
||||
BASH32_COUNT=0
|
||||
while IFS= read -r BASH32_FOUND; do
|
||||
BASH32_SCRIPTS+=("$BASH32_FOUND")
|
||||
BASH32_COUNT=$((BASH32_COUNT + 1))
|
||||
done < <(bash32_glob "$BASH32_GLOB" | sort)
|
||||
if [[ $BASH32_COUNT -lt $BASH32_FLOOR ]]; then
|
||||
fail "the bash-3.2 scan's '$BASH32_GLOB' glob derived $BASH32_COUNT file(s), under its floor of $BASH32_FLOOR — that path is wrong, so those scripts are silently unscanned"
|
||||
fi
|
||||
BASH32_IDX=$((BASH32_IDX + 1))
|
||||
done
|
||||
HAZARDS16=""
|
||||
for BASH32_SCRIPT in \
|
||||
"$SCRIPT" \
|
||||
"$REPO_ROOT/scripts/skill-size-check.sh" \
|
||||
"$REPO_ROOT/scripts/check-release-needed.sh" \
|
||||
"$REPO_ROOT/scripts/check-vale-style-sync.sh" \
|
||||
"$REPO_ROOT/tests/run-tests.sh" \
|
||||
"$REPO_ROOT/tests/run-bats.sh" \
|
||||
"$REPO_ROOT/tests/test-sync-marketplace-mirror.sh" \
|
||||
"$REPO_ROOT/tests/test-sync-plugin-content.sh" \
|
||||
"$REPO_ROOT/tests/test-check-release-needed.sh"; do
|
||||
for BASH32_SCRIPT in ${BASH32_SCRIPTS[@]+"${BASH32_SCRIPTS[@]}"}; do
|
||||
FOUND16="$(unguarded_expansions "$BASH32_SCRIPT")"
|
||||
if [[ -n "$FOUND16" ]]; then
|
||||
HAZARDS16+="$FOUND16 "
|
||||
fi
|
||||
# `mapfile`/`readarray` are bash 4.0+ builtins with no 3.2 fallback. Whole-line
|
||||
# comments are blanked first so prose naming the builtin is not a hit.
|
||||
FOUND16B="$(awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$BASH32_SCRIPT" \
|
||||
| grep -nE '(^|[^[:alnum:]_])(mapfile|readarray)[[:space:]]' || true)"
|
||||
# `mapfile`/`readarray`: bash 4.0+ builtins with no 3.2 fallback.
|
||||
# `declare -A`: bash 4.0+ associative arrays. The flag cluster can carry other
|
||||
# letters in any order (-Ag, -rA, ...); what matters is a literal uppercase A
|
||||
# appearing in it, so match on that rather than the exact string "-A".
|
||||
# `wait -n`: bash 4.3+. `nproc`: GNU coreutils, not present on macOS.
|
||||
# The last two close on `[^[:alnum:]_]`, not `[[:space:]]`: the real spellings
|
||||
# are `$(nproc)` and `wait -n;`, and a whitespace-only terminator misses both.
|
||||
# `npro[c]` matches exactly what `nproc` would, but keeps the literal string
|
||||
# "nproc" out of this file — the scan reads this file too, so a bare spelling
|
||||
# here would report itself as a hazard.
|
||||
for BASH32_RULE in \
|
||||
'(^|[^[:alnum:]_])(mapfile|readarray)[[:space:]]' \
|
||||
'(^|[^[:alnum:]_])declare[[:space:]]+-[a-zA-Z]*A[a-zA-Z]*([[:space:]]|$)' \
|
||||
'(^|[^[:alnum:]_])wait[[:space:]]+-n([^[:alnum:]_]|$)' \
|
||||
'(^|[^[:alnum:]_])npro[c]([^[:alnum:]_]|$)'; do
|
||||
FOUND16B="$(strip_comments "$BASH32_SCRIPT" | grep -nE "$BASH32_RULE" || true)"
|
||||
if [[ -n "$FOUND16B" ]]; then
|
||||
HAZARDS16+="${BASH32_SCRIPT##*/}:$FOUND16B "
|
||||
fi
|
||||
# `declare -A` (associative arrays) is bash 4.0+ with no 3.2 fallback. The
|
||||
# flag cluster can carry other letters in any order (-Ag, -rA, ...); what
|
||||
# matters is a literal uppercase A appearing in it, so match on that rather
|
||||
# than the exact string "-A".
|
||||
FOUND16C="$(awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$BASH32_SCRIPT" \
|
||||
| grep -nE '(^|[^[:alnum:]_])declare[[:space:]]+-[a-zA-Z]*A[a-zA-Z]*([[:space:]]|$)' || true)"
|
||||
if [[ -n "$FOUND16C" ]]; then
|
||||
HAZARDS16+="${BASH32_SCRIPT##*/}:$FOUND16C "
|
||||
fi
|
||||
done
|
||||
done
|
||||
if [[ -n "$HAZARDS16" ]]; then
|
||||
fail "unguarded array expansion(s) abort on bash < 4.4 under set -u: $(echo "$HAZARDS16" | tr '\n' ' ')"
|
||||
fail "bash-4-only construct(s) found in ${#BASH32_SCRIPTS[@]} scanned script(s): $(echo "$HAZARDS16" | tr '\n' ' ')"
|
||||
else
|
||||
pass "every array expansion uses the bash-3.2-safe \${arr[@]+\"\${arr[@]}\"} form"
|
||||
# This message deliberately names none of the four hazards in their literal
|
||||
# spelling: the scan reads this file too, so a literal here is indistinguishable
|
||||
# from a real occurrence and the case would fail on its own success message.
|
||||
pass "all ${#BASH32_SCRIPTS[@]} scanned scripts are free of every bash-4-only construct this case checks for"
|
||||
fi
|
||||
|
||||
# --- 17. The invocations whose arrays are closest to empty actually run. Under
|
||||
@@ -594,11 +731,14 @@ DESC19_A="Use when the caller helps with a specific job"
|
||||
DESC19_B="and the second physical line will utilize the wrap"
|
||||
|
||||
# make_form_fixture spells the same two-clause description in one YAML scalar
|
||||
# form: single, folded, plain, dquote, squote, or keyonly.
|
||||
# form: single, folded, plain, dquote, squote, or keyonly. It prints the fixture
|
||||
# dir and does NOT call new_fixture itself: every caller invokes it inside `$( )`,
|
||||
# so a registration made in here would land in the command substitution's
|
||||
# subshell and never reach cleanup_all -- which leaked all six fixtures per run.
|
||||
# Registration is therefore the caller's job, in the caller's shell.
|
||||
make_form_fixture() {
|
||||
local form="$1" dir
|
||||
dir="$(mktemp -d)"
|
||||
new_fixture "$dir"
|
||||
(cd "$dir" && git init -q)
|
||||
mkdir -p "$dir/plugins/testplugin/skills/zzzskill"
|
||||
{
|
||||
@@ -636,7 +776,13 @@ alert_text() {
|
||||
|
||||
echo ""
|
||||
echo "--- every multi-line description form reports what its single-line form reports ---"
|
||||
# FORM_FIXTURES19 is bookkeeping for case 25, which asserts every dir recorded
|
||||
# here also reached EXTRA_FIXTURES. It is appended to independently of
|
||||
# new_fixture so that dropping the new_fixture calls is detectable.
|
||||
FORM_FIXTURES19=()
|
||||
FIXTURE19_SINGLE="$(make_form_fixture single)"
|
||||
FORM_FIXTURES19+=("$FIXTURE19_SINGLE")
|
||||
new_fixture "$FIXTURE19_SINGLE"
|
||||
BASELINE19="$(alert_text "$(run_wrap "$FIXTURE19_SINGLE" --config "$VALE_CONFIG" "$REL_SKILL19")")"
|
||||
if [[ -z "$BASELINE19" ]]; then
|
||||
# The loop below has to be skipped, not merely reported on: an empty baseline
|
||||
@@ -646,6 +792,8 @@ if [[ -z "$BASELINE19" ]]; then
|
||||
else
|
||||
for FORM19 in folded plain dquote squote keyonly; do
|
||||
DIR19="$(make_form_fixture "$FORM19")"
|
||||
FORM_FIXTURES19+=("$DIR19")
|
||||
new_fixture "$DIR19"
|
||||
BARE19="$(cd "$DIR19" && vale --config "$VALE_CONFIG" "$REL_SKILL19" 2>&1 || true)"
|
||||
GOT19="$(alert_text "$(run_wrap "$DIR19" --config "$VALE_CONFIG" "$REL_SKILL19")")"
|
||||
if echo "$BARE19" | grep -q "VagueWording"; then
|
||||
@@ -864,6 +1012,85 @@ for FORM24 in "--output line" "--output=line" "--output JSON" "--output=JSON"; d
|
||||
fi
|
||||
done
|
||||
|
||||
# --- 25. Every fixture this run created is on the cleanup list. make_form_fixture
|
||||
# used to call `new_fixture` itself, but every caller invokes it as
|
||||
# `$(make_form_fixture ...)` — a command substitution — so the append landed in a
|
||||
# subshell and was gone by the time the caller resumed. cleanup_all then removed
|
||||
# five of the six form fixtures it never heard about, leaking one temp dir per
|
||||
# YAML form per run (six in total, measured under a private TMPDIR).
|
||||
#
|
||||
# Nothing in the suite noticed: a leak fails no assertion, and the run reported
|
||||
# "39 passed, 0 failed" with the bug present exactly as it does with the bug
|
||||
# fixed. Leak-freeness was only ever observable by watching TMPDIR from outside,
|
||||
# which is not a regression test. This case makes it one — it compares the dirs
|
||||
# case 19 created against the dirs registered for cleanup, so re-introducing the
|
||||
# subshell registration fails the run rather than quietly leaking again.
|
||||
echo ""
|
||||
echo "--- every fixture created by this run is registered for cleanup ---"
|
||||
UNREGISTERED25=""
|
||||
for DIR25 in ${FORM_FIXTURES19[@]+"${FORM_FIXTURES19[@]}"}; do
|
||||
REGISTERED25=0
|
||||
for KNOWN25 in ${EXTRA_FIXTURES[@]+"${EXTRA_FIXTURES[@]}"}; do
|
||||
if [[ "$KNOWN25" == "$DIR25" ]]; then
|
||||
REGISTERED25=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ $REGISTERED25 -eq 0 ]]; then
|
||||
UNREGISTERED25+="$DIR25 "
|
||||
fi
|
||||
done
|
||||
if [[ ${#FORM_FIXTURES19[@]} -ne 6 ]]; then
|
||||
fail "expected 6 form fixtures to have been created, saw ${#FORM_FIXTURES19[@]} — case 25 is not checking what it claims"
|
||||
elif [[ -n "$UNREGISTERED25" ]]; then
|
||||
fail "fixture(s) created but never registered for cleanup, so they leak: $UNREGISTERED25"
|
||||
else
|
||||
pass "all ${#FORM_FIXTURES19[@]} form fixtures are registered for cleanup"
|
||||
fi
|
||||
|
||||
# --- 26. Case 16's shell-special-array exemption is pinned to a fixture. No file
|
||||
# the scan currently reads expands any of those arrays, so the exemption is inert
|
||||
# in practice: it could be deleted, or quietly widened to cover an array that can
|
||||
# genuinely be empty, and every existing case would still pass. This drives the
|
||||
# real unguarded_expansions over a fixture holding one expansion of each, and
|
||||
# asserts the split in BOTH directions -- exempt arrays absent from the findings,
|
||||
# never-safe arrays present. The membership is not cosmetic: the exempt ones are
|
||||
# shell-maintained and always non-empty, while the flagged ones are empty in
|
||||
# reachable states (see the comment on the exemption for the measured counts), so
|
||||
# widening the list to include one of the latter would suppress a real abort.
|
||||
echo ""
|
||||
echo "--- the shell-special-array exemption covers exactly the never-empty arrays ---"
|
||||
FIXTURE26="$(mktemp -d)"
|
||||
new_fixture "$FIXTURE26"
|
||||
EXEMPT26="PIPESTATUS BASH_SOURCE BASH_LINENO BASH_VERSINFO GROUPS DIRSTACK"
|
||||
FLAGGED26="FUNCNAME BASH_ARGV BASH_ARGC BASH_REMATCH COMP_WORDS"
|
||||
{
|
||||
echo "#!/usr/bin/env bash"
|
||||
for ARR26 in $EXEMPT26 $FLAGGED26; do
|
||||
echo "echo \"\${${ARR26}[@]}\""
|
||||
done
|
||||
} > "$FIXTURE26/probe.sh"
|
||||
FOUND26="$(unguarded_expansions "$FIXTURE26/probe.sh")"
|
||||
MISSING26=""
|
||||
LEAKED26=""
|
||||
for ARR26 in $EXEMPT26; do
|
||||
if echo "$FOUND26" | grep -q "{$ARR26\[@\]}"; then
|
||||
LEAKED26+="$ARR26 "
|
||||
fi
|
||||
done
|
||||
for ARR26 in $FLAGGED26; do
|
||||
if ! echo "$FOUND26" | grep -q "{$ARR26\[@\]}"; then
|
||||
MISSING26+="$ARR26 "
|
||||
fi
|
||||
done
|
||||
if [[ -n "$LEAKED26" ]]; then
|
||||
fail "always-non-empty shell array(s) reported as hazards, so the exemption is not applying: $LEAKED26"
|
||||
elif [[ -n "$MISSING26" ]]; then
|
||||
fail "shell array(s) that CAN be empty were exempted, suppressing a real bash-3.2 abort: $MISSING26"
|
||||
else
|
||||
pass "all 6 never-empty shell arrays are exempt and all 5 sometimes-empty ones are still flagged"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
Reference in New Issue
Block a user