Merge pull request #2188 from open-gsd/feat/2182-capability-registry
feat(#2182): add Community Capability Registry discoverability catalog
This commit is contained in:
5
.changeset/gallant-rams-rally.md
Normal file
5
.changeset/gallant-rams-rally.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 2188
|
||||
---
|
||||
**Discover third-party GSD Capabilities in a new Community Capability Registry.** — A non-endorsing discoverability catalog where authors register a Capability via a documentation PR; each entry carries a live latest-release badge and a per-entry GitHub Discussion for community ranking and comments. (#2188)
|
||||
105
.github/PULL_REQUEST_TEMPLATE/registry-entry.md
vendored
Normal file
105
.github/PULL_REQUEST_TEMPLATE/registry-entry.md
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
## Registry Entry PR
|
||||
|
||||
> **Using the wrong template?**
|
||||
> — Bug fix: use [fix.md](?template=fix.md)
|
||||
> — New feature (not a registry listing): use [feature.md](?template=feature.md)
|
||||
> — Enhancement to existing behavior: use [enhancement.md](?template=enhancement.md)
|
||||
|
||||
Full schema and process: [docs/registries/README.md](../../docs/registries/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Registry type
|
||||
|
||||
<!-- Check exactly one. -->
|
||||
|
||||
- [ ] Capability Registry entry — adds/updates one object in `docs/registries/capabilities.json`
|
||||
- [ ] EoS Registry entry — adds/updates one object in `docs/registries/eos.json`
|
||||
|
||||
## The entry
|
||||
|
||||
<!-- Paste the exact JSON object you added, unmodified. -->
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "",
|
||||
"name": "",
|
||||
"type": "",
|
||||
"repo": "",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"enginesGsd": "",
|
||||
"install": "",
|
||||
"uninstall": "",
|
||||
"interactions": {},
|
||||
"discussion": ""
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Required-field checklist
|
||||
|
||||
- [ ] `id`, `name`, `type`, `repo`, `description`, `author`, `license`, `enginesGsd`, `install`, `uninstall`, `interactions`, `discussion` are all present and non-empty
|
||||
- [ ] **(Capability entries only)** `interactions.loopExtensionPoints` is a non-empty subset of the 12 Loop Extension Points, `interactions.hookKinds` ⊆ `{step, contribution, gate}`, and `interactions.configKeys` / `requires` / `runtimeCompat` / `produces` / `consumes` are present (empty arrays are fine where nothing applies)
|
||||
- [ ] **(EoS entries only)** `protocolVersion` is an integer ≥ 1, `interactions.interfacePoints` is a non-empty subset of the six interface points, `interactions.profile` is one of `programmatic-cli` / `declarative-cli` / `ide`, and `interactions.axes` has exactly the eight required axis keys
|
||||
|
||||
## Ownership & non-endorsement
|
||||
|
||||
- [ ] `repo` links to a repository **I own or am the primary maintainer of** — not a fork, mirror, or someone else's project
|
||||
- [ ] I understand that inclusion in this registry means only that a maintainer merged this PR — it is **not** an endorsement, and GSD has not reviewed, tested, audited, or verified my solution or its claimed GSD interactions
|
||||
- [ ] I understand this entry is removed only for illegal content, malware, spam, or a dead/non-functional link — never for quality — and a maintainer may remove it on that narrow basis without further notice
|
||||
|
||||
## One entry, one PR
|
||||
|
||||
- [ ] This PR adds or updates exactly **one** entry, in exactly one of `capabilities.json` / `eos.json`
|
||||
- [ ] I have not bundled any other registry entry, code change, or unrelated docs change into this PR
|
||||
|
||||
## Generated file in sync
|
||||
|
||||
- [ ] I ran `npm run gen:registry` after editing the JSON source, and this PR includes the regenerated `docs/registries/capability-registry.md` or `docs/registries/eos-registry.md`
|
||||
- [ ] I did **not** hand-edit the generated `.md` file directly — all edits were made to the JSON source
|
||||
|
||||
## Documentation
|
||||
|
||||
> CI enforces `lint:docs` for any changeset fragment typed `Added` / `Changed` / `Deprecated` / `Removed` — it must also touch a file under `docs/`. The JSON source and its regenerated markdown, both under `docs/registries/`, satisfy this.
|
||||
|
||||
- [ ] This PR includes both the JSON source file and the regenerated markdown file under `docs/registries/`
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `npm run validate:registry` passes locally against my entry
|
||||
- [ ] `discussion` links to a GitHub Discussion in the `Registry` category (or notes that one will be created on merge, per [docs/registries/README.md](../../docs/registries/README.md))
|
||||
- [ ] `.changeset/` fragment added with an `Added` type describing the new listing
|
||||
|
||||
---
|
||||
|
||||
## Example filled entry
|
||||
|
||||
<!-- Reference only — delete this section before submitting your PR. -->
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "linear-issue-sync",
|
||||
"name": "Linear Issue Sync",
|
||||
"type": "capability",
|
||||
"repo": "some-org/gsd-cap-linear-sync",
|
||||
"description": "Mirrors ROADMAP.md items to Linear issues as a ship:post contribution.",
|
||||
"author": "Some Org <hello@some-org.example>",
|
||||
"license": "MIT",
|
||||
"enginesGsd": ">=1.6.0",
|
||||
"install": "gsd capability install https://github.com/some-org/gsd-cap-linear-sync.git#v1.0.0",
|
||||
"uninstall": "gsd capability remove linear-issue-sync",
|
||||
"interactions": {
|
||||
"loopExtensionPoints": ["ship:post"],
|
||||
"hookKinds": ["contribution"],
|
||||
"configKeys": ["linear-issue-sync.enabled"],
|
||||
"requires": [],
|
||||
"runtimeCompat": ["all"],
|
||||
"produces": ["linear-issue-links"],
|
||||
"consumes": ["ROADMAP.md"]
|
||||
},
|
||||
"discussion": "https://github.com/open-gsd/gsd-core/discussions/1234"
|
||||
}
|
||||
```
|
||||
@@ -199,6 +199,12 @@ ADR-857 phase 3b seam that merges capability-declared config slices into the `lo
|
||||
### Capability Registry Overlay
|
||||
Runtime seam (`gsd-core/bin/lib/capability-loader.cjs`, ADR-1244 D2) that composes the frozen first-party Capability Registry (`capability-registry.cjs`) with a validated installed overlay of third-party capability manifests discovered at load time. Install roots are global (`$GSD_HOME/.gsd/capabilities/<id>/capability.json`, where `GSD_HOME` defaults to `~`) and project (`<projectRoot>/.gsd/capabilities/<id>/capability.json`). Primary interface: `loadRegistry({ includeInstalled }) → registry` — when `includeInstalled` is true the overlay is merged via the canonical `buildRegistry` so all derived views (bySkill, byAgent, byLoopPoint, configKeys) cover first-party and overlay entries identically. First-party always wins: any overlay entry whose id, owned skill/agent stem, or federated config key collides with first-party, or whose id uses a reserved `gsd-`/`gsd-core-`/`anthropic-` prefix, is rejected at load time. Load-time re-gate: an overlay failing schema validation or whose `engines.gsd` semver range does not satisfy the running GSD version is skipped with a warning and never crashes the load loop. Per-hook-kind policy: a skipped capability that declared a `gate`-kind hook fails CLOSED (the loop resolver injects a blocking gate); skipped `step` or `contribution` capabilities skip open. A capability dir whose co-located ledger entry carries an in-flight `_pending` intent (a crashed/uncommitted install or upgrade, ADR-1244 Phase 4) is skipped OPEN (never activated until reconciliation commits or rolls it back). #1459 user-owned consent gate: a PROJECT-scope overlay is activated (declarative surfaces AND command dispatch) ONLY when the user-owned Capability Consent Store holds a record for `(realpath(projectRoot), id)` whose stored `contentHash` equals the bundle content hash the loader RECOMPUTES at load (`bundleContentHash(capDir)` over the whole on-disk bundle) — NOT the repo-plantable ledger integrity nor the executable-only disclosure signature — otherwise the cap is DISCOVERED-BUT-INACTIVE (a warning carrying `kind:'unconsented'`, no surfaces, empty commandRoots), so a forged/cloned in-repo project ledger or any post-consent tamper no longer activates anything; GLOBAL scope (under the user's own home) is trusted without a record, and the global-vs-project root dedup/escalation is realpath-keyed so a symlinked `GSD_HOME` aliasing the project root cannot bypass the gate (finding 1). The consent lookup is wrapped to fail CLOSED (inactive); both the per-scope ledger AND the `capability.json` manifest are read via the shared bounded `readSmallRegularFile` (a repo-planted FIFO/oversized ledger or manifest can no longer hang or OOM the loader — finding 2). The loader reuses the ledger's shared `isValidLedgerEntry` for committed-entry parity. Consumers wired to the overlay-aware registry: `config-loader.cjs`, `config-schema.cjs`, `capability-state.cjs`, `loop-resolver.cjs`.
|
||||
|
||||
### Community Capability Registry
|
||||
Human-facing discoverability catalog (`docs/registries/capability-registry.md`, generated from `docs/registries/capabilities.json`; issue #2182) listing third-party Feature Capabilities registered by a docs PR so a solo developer can find one before installing it. Distinct from **Capability Registry** (the generated runtime manifest compiled from first-party `capability.json` declarations, ADR-894) and **Capability Registry Overlay** (the runtime seam that merges an installed third-party manifest into that generated registry at load time, ADR-1244 D2): this registry is a static document rendered by `scripts/gen-registry.cjs`, not a runtime data structure or loader. Each entry enumerates the capability's Loop Extension Points and hook kinds so a reader can judge blast radius before running `gsd capability install`, and declares its `engines.gsd` range. Inclusion is an explicit non-endorsement — a maintainer merged a link, nothing more — per `docs/registries/README.md`.
|
||||
|
||||
### EoS Registry
|
||||
Human-facing discoverability catalog (`docs/registries/eos-registry.md`, generated from `docs/registries/eos.json`; issue #2182) listing third-party Embeddable Orchestration System (EoS) host integrations — projects that embed GSD as an orchestration engine behind the ADR-1239 six-interface-point Host-Integration Interface. Entries are registered by the same docs-PR process, schema conventions, and non-endorsement stance as the **Community Capability Registry**, but enumerate the six interface points, the eight negotiated axes, and `protocolVersion` in place of Loop Extension Points and hook kinds. It has no generated-manifest or Capability Registry Overlay counterpart: an ADR-1239 host integration runs inside the third-party host, not inside GSD's own capability loader, so there is nothing for a runtime registry to merge. See `docs/registries/README.md` for the full entry schema.
|
||||
|
||||
### Capability Validator
|
||||
Shared conformance validator (`gsd-core/bin/lib/capability-validator.cjs`, ADR-1244 D2) extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share one validator implementation. Exports the same `validateCapability(manifest)` surface consumed by both the generator (build-time) and `capability-loader.cjs` (runtime). Generative-parity is CI-guarded: a drift between the generator's validation logic and the extracted module is a hard failure. Callers that previously inlined validation against the generator's internal helpers are migrated to import this module directly. Source of truth: `gsd-core/bin/lib/capability-validator.cjs`.
|
||||
|
||||
|
||||
@@ -987,6 +987,8 @@ continues. Drift detection cannot fail verification.
|
||||
|
||||
## Infrastructure Features
|
||||
|
||||
> **Looking for a third-party add-on instead?** See the [GSD Community Capability Registry & EoS Registry](registries/README.md) — non-endorsing discoverability catalogs for community-contributed Capabilities and EoS host integrations.
|
||||
|
||||
### 34. Git Integration
|
||||
|
||||
**Purpose:** Atomic commits, branching strategies, and clean history management.
|
||||
|
||||
@@ -1010,3 +1010,4 @@ To disable parallel execution entirely: `/gsd-settings` → set `parallelization
|
||||
- [Commands](COMMANDS.md)
|
||||
- [Configuration](CONFIGURATION.md)
|
||||
- [The phase loop](explanation/the-phase-loop.md)
|
||||
- [Community Capability Registry & EoS Registry](registries/README.md) — discover third-party Capabilities and EoS host integrations
|
||||
|
||||
210
docs/registries/README.md
Normal file
210
docs/registries/README.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# GSD Registries: Community Capability Registry & EoS Registry
|
||||
|
||||
Specification, entry schema, and submission process for GSD's two third-party discoverability catalogs — the **GSD Community Capability Registry** and the **GSD EoS Registry**.
|
||||
|
||||
---
|
||||
|
||||
## Non-endorsement stance
|
||||
|
||||
> Inclusion in this registry means only that a maintainer merged a PR that linked to the author's repository. It is not an endorsement. GSD has not reviewed, tested, audited, or verified the correctness, quality, safety, or security of any listed solution, nor its claimed GSD interactions. Use at your own risk; evaluate the linked source yourself. Entries are removed only for illegal content, malware, spam, or a link that is dead/completely non-functional — never curated for quality.
|
||||
|
||||
This stance applies identically to every entry in both registries. It is reproduced verbatim at the top of each generated catalog (`capability-registry.md`, `eos-registry.md`).
|
||||
|
||||
## Narrow removal policy
|
||||
|
||||
A merged entry is removed **only** for one of these reasons:
|
||||
|
||||
- The linked content is illegal.
|
||||
- The linked repository distributes malware.
|
||||
- The entry is spam (not a genuine, working solution).
|
||||
- The linked repository or its default branch is dead or completely non-functional (404, archived-and-empty, permanently inaccessible).
|
||||
|
||||
A registry entry is **never** removed for quality, staleness of a working project, disagreement with its design, or because a maintainer would have built it differently. The registry is a directory, not a curated marketplace — see [Non-endorsement stance](#non-endorsement-stance) above.
|
||||
|
||||
---
|
||||
|
||||
## What gets listed
|
||||
|
||||
Two independent catalogs, sharing one schema shape, one non-endorsement stance, and one submission process:
|
||||
|
||||
- **Community Capability Registry** (`docs/registries/capability-registry.md`, generated from `docs/registries/capabilities.json`) — third-party **Feature Capabilities**: plug-ins that attach at GSD's Loop Extension Points (ADR-857, ADR-894, ADR-1244) and are installed with `gsd capability install <spec>`.
|
||||
- **EoS Registry** (`docs/registries/eos-registry.md`, generated from `docs/registries/eos.json`) — third-party **Embeddable Orchestration System (EoS)** host integrations: projects that embed GSD as an orchestration engine inside a host through the ADR-1239 Host-Integration Interface.
|
||||
|
||||
Both registries are non-endorsing discoverability catalogs (issue #2182). Neither is the runtime **Capability Registry** (the generated manifest consumed at load time, ADR-894 §5) or the **Capability Registry Overlay** (the runtime loader that merges an installed third-party manifest into that generated registry, ADR-1244 D2) — see `CONTEXT.md` → "Community Capability Registry" and "EoS Registry" for the full disambiguation.
|
||||
|
||||
---
|
||||
|
||||
## Entry schema
|
||||
|
||||
Every entry is one JSON object in `docs/registries/capabilities.json` or `docs/registries/eos.json`, validated by `scripts/registry-schema.cjs`. Field names below are exact and case-sensitive; unknown top-level keys are rejected.
|
||||
|
||||
### Capability entries (`capabilities.json`, `type: "capability"`)
|
||||
|
||||
| Field | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `id` | yes | Unique slug across the registry (`^[a-z0-9]+(-[a-z0-9]+)*$`). |
|
||||
| `name` | yes | Human-readable name. |
|
||||
| `type` | yes | Must equal `"capability"`. |
|
||||
| `repo` | yes | `owner/repo` on github.com — the author's own repository. |
|
||||
| `description` | yes | One-paragraph plain-language description of the solution and the problem it solves. |
|
||||
| `author` | yes | Author name (and, optionally, contact). |
|
||||
| `license` | yes | SPDX identifier (or `UNLICENSED` / `Proprietary`). |
|
||||
| `enginesGsd` | yes | Declared `engines.gsd` semver range (ADR-1244 D1), e.g. `>=1.6.0`. |
|
||||
| `install` | yes | Exact, copy-pasteable install command — the ADR-1244 URL-import flow, e.g. `gsd capability install https://github.com/OWNER/REPO.git#v1.0.0`. |
|
||||
| `uninstall` | yes | Exact, copy-pasteable removal command, e.g. `gsd capability remove <id>`. |
|
||||
| `interactions` | yes | Object — see below. |
|
||||
| `discussion` | yes | URL of this entry's GitHub Discussion (`https://github.com/<owner>/<repo>/discussions/<n>`). |
|
||||
|
||||
`interactions` (Capability):
|
||||
|
||||
| Field | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `loopExtensionPoints` | yes, non-empty | Subset of the 12 Loop Extension Points the capability registers on: `discuss:pre`, `discuss:post`, `plan:pre`, `plan:post`, `execute:pre`, `execute:wave:pre`, `execute:wave:post`, `execute:post`, `verify:pre`, `verify:post`, `ship:pre`, `ship:post`. |
|
||||
| `hookKinds` | yes | Subset of `step`, `contribution`, `gate` — the hook kind registered at each point above. |
|
||||
| `configKeys` | yes | Array of federated config keys the capability owns (may be empty). |
|
||||
| `requires` | yes | Array of other Capability ids this capability depends on (may be empty). |
|
||||
| `runtimeCompat` | yes | Array of compatible runtimes; `["all"]` is allowed. |
|
||||
| `produces` | yes | Array describing artifacts/data the capability produces (may be empty). |
|
||||
| `consumes` | yes | Array describing artifacts/data the capability consumes (may be empty). |
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "linear-issue-sync",
|
||||
"name": "Linear Issue Sync",
|
||||
"type": "capability",
|
||||
"repo": "some-org/gsd-cap-linear-sync",
|
||||
"description": "Mirrors ROADMAP.md items to Linear issues as a ship:post contribution.",
|
||||
"author": "Some Org <hello@some-org.example>",
|
||||
"license": "MIT",
|
||||
"enginesGsd": ">=1.6.0",
|
||||
"install": "gsd capability install https://github.com/some-org/gsd-cap-linear-sync.git#v1.0.0",
|
||||
"uninstall": "gsd capability remove linear-issue-sync",
|
||||
"interactions": {
|
||||
"loopExtensionPoints": ["ship:post"],
|
||||
"hookKinds": ["contribution"],
|
||||
"configKeys": ["linear-issue-sync.enabled"],
|
||||
"requires": [],
|
||||
"runtimeCompat": ["all"],
|
||||
"produces": ["linear-issue-links"],
|
||||
"consumes": ["ROADMAP.md"]
|
||||
},
|
||||
"discussion": "https://github.com/open-gsd/gsd-core/discussions/1234"
|
||||
}
|
||||
```
|
||||
|
||||
### EoS entries (`eos.json`, `type: "eos"`)
|
||||
|
||||
| Field | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `id` | yes | Unique slug across the registry. |
|
||||
| `name` | yes | Human-readable name. |
|
||||
| `type` | yes | Must equal `"eos"`. |
|
||||
| `repo` | yes | `owner/repo` on github.com — the author's own repository. |
|
||||
| `description` | yes | One-paragraph plain-language description of the host integration. |
|
||||
| `author` | yes | Author name (and, optionally, contact). |
|
||||
| `license` | yes | SPDX identifier (or `UNLICENSED` / `Proprietary`). |
|
||||
| `enginesGsd` | yes | Declared `engines.gsd` semver range this integration targets. |
|
||||
| `protocolVersion` | yes | Integer ≥ 1 — the ADR-1239 `PROTOCOL_VERSION` this integration implements. |
|
||||
| `install` | yes | The host-plugin's own install steps (free string). |
|
||||
| `uninstall` | yes | The host-plugin's own teardown steps (free string). |
|
||||
| `interactions` | yes | Object — see below. |
|
||||
| `discussion` | yes | URL of this entry's GitHub Discussion. |
|
||||
|
||||
`interactions` (EoS):
|
||||
|
||||
| Field | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `interfacePoints` | yes, non-empty | Subset of the six ADR-1239 interface points it binds: `command`, `dispatch`, `model`, `hooks`, `state`, `artifact`. |
|
||||
| `profile` | yes | One of the three host-capability profiles: `programmatic-cli`, `declarative-cli`, `ide`. |
|
||||
| `axes` | yes | Object with **exactly** the eight ADR-1239 negotiated axes keys: `embeddingMode`, `commandSurface`, `dispatch`, `modelMode`, `hookBus`, `stateIO`, `transport`, `runtime`. |
|
||||
|
||||
`axes` value vocabulary:
|
||||
|
||||
| Axis | Allowed values |
|
||||
|---|---|
|
||||
| `embeddingMode` | `imperative`, `declarative` |
|
||||
| `commandSurface` | `slash-file`, `slash-programmatic`, `slash-toml`, `palette`, `prose-only` |
|
||||
| `dispatch` | Free descriptive string (ADR-1239 `dispatch` is a structured object; the registry accepts a human summary). |
|
||||
| `modelMode` | `active`, `passive` |
|
||||
| `hookBus` | `host`, `engine`, `none` |
|
||||
| `stateIO` | `filesystem`, `sandboxed-storage`, `session-log-append` |
|
||||
| `transport` | `mcp`, `native-extension` |
|
||||
| `runtime` | `node`, `bun`, `sandboxed-web`, `python`, `go`, `rust`, `electron`, `other` |
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "acme-editor-embed",
|
||||
"name": "Acme Editor GSD Embed",
|
||||
"type": "eos",
|
||||
"repo": "some-org/acme-gsd-embed",
|
||||
"description": "Embeds GSD as an orchestration engine inside the Acme editor's command palette.",
|
||||
"author": "Some Org <hello@some-org.example>",
|
||||
"license": "Apache-2.0",
|
||||
"enginesGsd": ">=1.6.0",
|
||||
"protocolVersion": 1,
|
||||
"install": "Install the Acme GSD Embed extension from the Acme marketplace — see https://github.com/some-org/acme-gsd-embed#install",
|
||||
"uninstall": "Remove the extension from Acme's extension manager.",
|
||||
"interactions": {
|
||||
"interfacePoints": ["command", "dispatch", "model", "hooks", "state", "artifact"],
|
||||
"profile": "ide",
|
||||
"axes": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "palette",
|
||||
"dispatch": "Routes palette invocations through Acme's own task-runner to gsd_run",
|
||||
"modelMode": "active",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "native-extension",
|
||||
"runtime": "electron"
|
||||
}
|
||||
},
|
||||
"discussion": "https://github.com/open-gsd/gsd-core/discussions/1235"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Submission process
|
||||
|
||||
Registration is a **documentation PR**, per [CONTRIBUTING.md → Documentation Updates](../../CONTRIBUTING.md#documentation-updates--update-the-relevant-docs):
|
||||
|
||||
1. **Fork** the repository.
|
||||
2. **Edit** `docs/registries/capabilities.json` (Capability Registry) or `docs/registries/eos.json` (EoS Registry) and append exactly one entry matching the [schema](#entry-schema) above.
|
||||
3. **Run `npm run gen:registry`** to regenerate the corresponding `docs/registries/capability-registry.md` or `docs/registries/eos-registry.md`. Commit both the JSON source and the regenerated markdown.
|
||||
4. **Open a PR** from a `docs/<issue#>-<slug>` branch (see CONTRIBUTING.md branch-naming conventions) using the [registry-entry PR template](../../.github/PULL_REQUEST_TEMPLATE/registry-entry.md).
|
||||
5. A maintainer reviews and merges. The only gate is whether the entry is a real, linkable solution with all required fields present — not a quality judgment (see [Non-endorsement stance](#non-endorsement-stance)).
|
||||
|
||||
**One entry = one PR.** Do not bundle multiple registry additions, updates, or removals into a single PR.
|
||||
|
||||
**The generated `.md` files are GENERATED — never hand-edit them.** `docs/registries/capability-registry.md` and `docs/registries/eos-registry.md` are produced by `scripts/gen-registry.cjs` from `capabilities.json` / `eos.json`. A PR that edits the generated markdown without a matching JSON source change will fail the `gen:registry --check` drift gate. Always edit the JSON and regenerate.
|
||||
|
||||
---
|
||||
|
||||
## Latest-release tracking
|
||||
|
||||
Each entry embeds a live [shields.io](https://shields.io) badge and a permalink to the linked repository's latest GitHub Release:
|
||||
|
||||
```
|
||||

|
||||
```
|
||||
|
||||
```
|
||||
https://github.com/OWNER/REPO/releases/latest
|
||||
```
|
||||
|
||||
There is no re-registration on new releases: register once, and your GitHub Releases are the update channel forever. The badge and permalink are rendered live by GitHub's markdown viewer directly from the linked repository — the registry itself never needs a follow-up PR when you cut a new version.
|
||||
|
||||
---
|
||||
|
||||
## Ranking + comments
|
||||
|
||||
Ranking and community feedback live in **GitHub Discussions**, not in the registry markdown. Each merged entry gets exactly one Discussion in a dedicated `Registry` Discussions category:
|
||||
|
||||
- **Upvotes** on the Discussion post and on individual comments, with GitHub's built-in **Top** sort surfacing the most-upvoted community feedback first.
|
||||
- **Threaded comments** for experience reports, questions, and follow-up from other users.
|
||||
|
||||
**Operational setup (one-time, per repo):** a repo admin creates the `Registry` category under this repository's Discussions settings. From then on, every merged entry gets its own Discussion thread created in that category, and the thread's URL is recorded in the entry's `discussion` field (see [Entry schema](#entry-schema) above) so the generated catalog links directly to it.
|
||||
1
docs/registries/capabilities.json
Normal file
1
docs/registries/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
9
docs/registries/capability-registry.md
Normal file
9
docs/registries/capability-registry.md
Normal file
@@ -0,0 +1,9 @@
|
||||
<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/capabilities.json — do not edit by hand; run `npm run gen:registry` -->
|
||||
|
||||
# GSD Community Capability Registry
|
||||
|
||||
> **Not an endorsement.** Inclusion means only that a maintainer merged a PR linking the author's repository — GSD has not reviewed, tested, or verified any listing. See the [registry README](./README.md).
|
||||
|
||||
_To add your capability, see the [registry README](./README.md)._
|
||||
|
||||
_No entries yet — be the first: see [README](./README.md)._
|
||||
@@ -87,6 +87,8 @@
|
||||
"gen:loop-host-contract": "node scripts/gen-loop-host-contract.cjs --write",
|
||||
"gen:plugin-skills": "node scripts/gen-plugin-skills.cjs --write",
|
||||
"gen:capability-registry": "node scripts/gen-capability-registry.cjs --write",
|
||||
"gen:registry": "node scripts/gen-registry.cjs --write",
|
||||
"validate:registry": "node scripts/validate-registry.cjs",
|
||||
"prepack": "npm run build:lib",
|
||||
"prepare": "npm run build:lib",
|
||||
"version": "node scripts/sync-manifest-versions.cjs --stage && node scripts/gen-capability-registry.cjs --write && git add gsd-core/bin/lib/capability-registry.cjs",
|
||||
@@ -95,7 +97,7 @@
|
||||
"pretest:coverage": "npm run build:lib && npm run lint:skill-deps",
|
||||
"lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs",
|
||||
"lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/validate-registry.cjs",
|
||||
"lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
|
||||
"lint:regression-names": "node scripts/lint-regression-test-names.cjs",
|
||||
"lint:descriptions": "node scripts/lint-descriptions.cjs",
|
||||
@@ -103,7 +105,7 @@
|
||||
"lint:test-file-count": "node scripts/lint-test-file-count.cjs",
|
||||
"lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs",
|
||||
"lint:changeset": "node scripts/changeset/lint.cjs",
|
||||
"lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check",
|
||||
"lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check",
|
||||
"lint:docs": "node scripts/lint-docs-required.cjs",
|
||||
"lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs",
|
||||
"ci:test-scope": "node scripts/ci-test-scope.cjs",
|
||||
|
||||
128
scripts/gen-registry.cjs
Normal file
128
scripts/gen-registry.cjs
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* scripts/gen-registry.cjs — generates docs/registries/capability-registry.md
|
||||
* (and, once PR2 ships docs/registries/eos.json, docs/registries/eos-registry.md)
|
||||
* from the corresponding source JSON, via registry-schema.cjs#renderMarkdown.
|
||||
* Issue #2182.
|
||||
*
|
||||
* NOT to be confused with `scripts/gen-capability-registry.cjs`: that script
|
||||
* generates the RUNTIME capability manifest consumed by the host at runtime
|
||||
* (`gsd-core/bin/lib/capability-registry.cjs`, built from every
|
||||
* `capabilities/<id>/capability.json` declaration). THIS script instead
|
||||
* generates the human-facing DOCUMENTATION catalog pages
|
||||
* (`docs/registries/*.md`) from the third-party discoverability registry
|
||||
* source JSON (`docs/registries/{capabilities,eos}.json`). The two pipelines
|
||||
* are independent — do not conflate them.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/gen-registry.cjs # print rendered markdown(s) to stdout
|
||||
* node scripts/gen-registry.cjs --write # write the *-registry.md file(s)
|
||||
* node scripts/gen-registry.cjs --check # exit 1 if a committed *-registry.md is stale
|
||||
*
|
||||
* Root is resolved from process.cwd() (not __dirname) — mirrors
|
||||
* scripts/validate-registry.cjs so both are drivable as subprocesses against
|
||||
* isolated temp-fixture directories via `cwd`.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
const { renderMarkdown } = require('./registry-schema.cjs');
|
||||
|
||||
const SOURCES = [
|
||||
{ type: 'capability', jsonFile: 'capabilities.json', mdFile: 'capability-registry.md' },
|
||||
{ type: 'eos', jsonFile: 'eos.json', mdFile: 'eos-registry.md' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The generator always writes LF; a Windows checkout (autocrlf) may present
|
||||
* committed files with CRLF. Normalize before comparing so `--check` only
|
||||
* fails on real content drift, not checkout-introduced line-ending noise.
|
||||
*
|
||||
* @param {string} content
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeLineEndings(content) {
|
||||
return content.replace(/\r/g, '');
|
||||
}
|
||||
|
||||
function getRegistriesDir() {
|
||||
return path.join(process.cwd(), 'docs', 'registries');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the markdown for a single registry type from its committed source
|
||||
* JSON.
|
||||
*
|
||||
* Only `eos.json` is optional (pre-PR2, before that source JSON ships) —
|
||||
* an absent `eos.json` returns null and callers treat that as "nothing to
|
||||
* do". `capabilities.json` is the primary registry source: a missing
|
||||
* `capabilities.json` is ALWAYS an error (never a silent "up to date"
|
||||
* pass), mirroring the type distinction in `scripts/validate-registry.cjs`
|
||||
* (`type === 'eos' && !exists → continue`).
|
||||
*
|
||||
* @param {'capability'|'eos'} type
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function renderFor(type) {
|
||||
const source = SOURCES.find((s) => s.type === type);
|
||||
if (!source) throw new Error(`gen-registry: unknown registry type "${type}"`);
|
||||
|
||||
const jsonPath = path.join(getRegistriesDir(), source.jsonFile);
|
||||
if (!fs.existsSync(jsonPath)) {
|
||||
if (type === 'eos') return null;
|
||||
throw new ExitError(
|
||||
1,
|
||||
`${source.jsonFile} does not exist at ${jsonPath}. Run:\n node scripts/gen-registry.cjs --write\n(after adding docs/registries/${source.jsonFile})`,
|
||||
);
|
||||
}
|
||||
|
||||
const entries = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||
return renderMarkdown(entries, { type, sourceFile: source.jsonFile });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [, , flag] = process.argv;
|
||||
const registriesDir = getRegistriesDir();
|
||||
let anyDrift = false;
|
||||
|
||||
for (const { type, mdFile } of SOURCES) {
|
||||
const rendered = renderFor(type);
|
||||
if (rendered === null) continue; // source JSON absent (eos.json before PR2)
|
||||
|
||||
const mdPath = path.join(registriesDir, mdFile);
|
||||
|
||||
if (flag === '--check') {
|
||||
if (!fs.existsSync(mdPath)) {
|
||||
process.stderr.write(`${mdFile} does not exist. Run:\n node scripts/gen-registry.cjs --write\n`);
|
||||
anyDrift = true;
|
||||
continue;
|
||||
}
|
||||
const committed = fs.readFileSync(mdPath, 'utf8');
|
||||
if (normalizeLineEndings(committed) !== normalizeLineEndings(rendered)) {
|
||||
process.stderr.write(`${mdFile} is stale. Run:\n node scripts/gen-registry.cjs --write\n`);
|
||||
anyDrift = true;
|
||||
}
|
||||
} else if (flag === '--write') {
|
||||
fs.mkdirSync(registriesDir, { recursive: true });
|
||||
fs.writeFileSync(mdPath, rendered);
|
||||
process.stdout.write(`Wrote ${mdPath}\n`);
|
||||
} else {
|
||||
process.stdout.write(rendered + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (flag === '--check') {
|
||||
if (anyDrift) throw new ExitError(1, 'registry markdown is stale');
|
||||
process.stdout.write('docs/registries/*.md are up to date.\n');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) runMain(main);
|
||||
|
||||
module.exports = { main, renderFor, SOURCES, normalizeLineEndings };
|
||||
565
scripts/registry-schema.cjs
Normal file
565
scripts/registry-schema.cjs
Normal file
@@ -0,0 +1,565 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* scripts/registry-schema.cjs — pure schema/vocab constants + validation +
|
||||
* markdown-generation logic for the two third-party discoverability catalogs
|
||||
* (issue #2182):
|
||||
*
|
||||
* - `docs/registries/capabilities.json` → "GSD Community Capability Registry"
|
||||
* - `docs/registries/eos.json` → "GSD EoS Registry" (PR2)
|
||||
*
|
||||
* The vocabulary constants below are ADDITIVE CONTRACTS that track the
|
||||
* runtime/ADR closed vocabularies they describe — they are a documentation-
|
||||
* registry-scoped mirror, not the runtime source of truth:
|
||||
*
|
||||
* - `LOOP_POINTS` mirrors ADR-857 "Loop Extension Points (the 12)"
|
||||
* (docs/adr/857-capability-system.md §"Loop Extension Points (the 12)").
|
||||
* The canonical runtime set lives in `src/loop-resolver.cts`
|
||||
* (`CANONICAL_POINTS` / `CANONICAL_POINTS_FALLBACK`, derived from
|
||||
* `loop-host-contract.cjs`) — changing that set requires updating this
|
||||
* list too, since a registry entry's `loopExtensionPoints` describes
|
||||
* which of those 12 points a third-party capability extends.
|
||||
* - `HOOK_KINDS` mirrors ADR-857 Decision 4 "three hook kinds": `step`
|
||||
* (runs as its own sequenced unit), `contribution` (injects into the
|
||||
* core step's prompt/context), `gate` (checks and optionally blocks).
|
||||
* - `INTERFACE_POINTS` mirrors ADR-1239 "The six interface points" (the
|
||||
* Host-Integration Interface integration surface): command/workflow
|
||||
* invocation, agent dispatch, model invocation, lifecycle hooks,
|
||||
* state+config IO, artifact surface.
|
||||
* - `PROFILES` mirrors ADR-1239 "Host-capability profiles (negotiation
|
||||
* baselines)": `programmatic-cli`, `declarative-cli`, `ide`.
|
||||
* - `AXES` mirrors ADR-1239 "the eight negotiated axes" (the negotiated
|
||||
* capability schema exchanged at `initialize`): `embeddingMode`,
|
||||
* `commandSurface`, `dispatch`, `modelMode`, `hookBus`, `stateIO`,
|
||||
* `transport`, `runtime`. Seven of the eight are closed enums here;
|
||||
* `dispatch` is ADR-1239's structured negotiated object
|
||||
* (`{ namedDispatch, nested, maxDepth, background, subagentToolkit }`) —
|
||||
* this registry accepts a free-form human summary string instead, so it
|
||||
* carries the `AXES_FREE_STRING` sentinel rather than an enum array.
|
||||
* - `CAPABILITY_REQUIRED` / `EOS_REQUIRED` mirror the required top-level
|
||||
* fields for each entry type, including `enginesGsd` (ADR-1244 D1
|
||||
* "Versioned capability manifest" — the `engines.gsd` semver-range gate,
|
||||
* modelled on VS Code's `engines.vscode`).
|
||||
*
|
||||
* This module is pure — no `fs`/`process`/child-process access — so tests
|
||||
* can `require()` it directly and assert on structured return values.
|
||||
* `scripts/validate-registry.cjs` and `scripts/gen-registry.cjs` are the thin
|
||||
* CLI wrappers that perform I/O around these functions.
|
||||
*/
|
||||
|
||||
// ─── ADR-857 "Loop Extension Points (the 12)" ────────────────────────────────
|
||||
const LOOP_POINTS = Object.freeze([
|
||||
'discuss:pre',
|
||||
'discuss:post',
|
||||
'plan:pre',
|
||||
'plan:post',
|
||||
'execute:pre',
|
||||
'execute:wave:pre',
|
||||
'execute:wave:post',
|
||||
'execute:post',
|
||||
'verify:pre',
|
||||
'verify:post',
|
||||
'ship:pre',
|
||||
'ship:post',
|
||||
]);
|
||||
|
||||
// ─── ADR-857 Decision 4 — three hook kinds ───────────────────────────────────
|
||||
const HOOK_KINDS = Object.freeze(['step', 'contribution', 'gate']);
|
||||
|
||||
// ─── ADR-1239 "The six interface points" ─────────────────────────────────────
|
||||
const INTERFACE_POINTS = Object.freeze(['command', 'dispatch', 'model', 'hooks', 'state', 'artifact']);
|
||||
|
||||
// ─── ADR-1239 "Host-capability profiles (negotiation baselines)" ────────────
|
||||
const PROFILES = Object.freeze(['programmatic-cli', 'declarative-cli', 'ide']);
|
||||
|
||||
// Sentinel marking an AXES entry as a free-form descriptive string rather than
|
||||
// a closed enum array. `Array.isArray(AXES_FREE_STRING)` is false, so callers
|
||||
// can branch on `Array.isArray(AXES[key])` vs `AXES[key] === AXES_FREE_STRING`
|
||||
// without risking confusion with a real enum value.
|
||||
const AXES_FREE_STRING = Symbol('registry-schema.AXES_FREE_STRING');
|
||||
|
||||
// ─── ADR-1239 "the eight negotiated axes" ────────────────────────────────────
|
||||
const AXES = Object.freeze({
|
||||
embeddingMode: Object.freeze(['imperative', 'declarative']),
|
||||
commandSurface: Object.freeze(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only']),
|
||||
dispatch: AXES_FREE_STRING,
|
||||
modelMode: Object.freeze(['active', 'passive']),
|
||||
hookBus: Object.freeze(['host', 'engine', 'none']),
|
||||
stateIO: Object.freeze(['filesystem', 'sandboxed-storage', 'session-log-append']),
|
||||
transport: Object.freeze(['mcp', 'native-extension']),
|
||||
runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']),
|
||||
});
|
||||
|
||||
// ─── Required top-level fields ───────────────────────────────────────────────
|
||||
const CAPABILITY_REQUIRED = Object.freeze([
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'repo',
|
||||
'description',
|
||||
'author',
|
||||
'license',
|
||||
'enginesGsd',
|
||||
'install',
|
||||
'uninstall',
|
||||
'interactions',
|
||||
'discussion',
|
||||
]);
|
||||
|
||||
const EOS_REQUIRED = Object.freeze([
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'repo',
|
||||
'description',
|
||||
'author',
|
||||
'license',
|
||||
'enginesGsd',
|
||||
'install',
|
||||
'uninstall',
|
||||
'interactions',
|
||||
'discussion',
|
||||
'protocolVersion',
|
||||
]);
|
||||
|
||||
// Escape Markdown inline metacharacters in UNTRUSTED free text so a registry
|
||||
// entry cannot inject links/tables/code-spans into the generated catalog.
|
||||
// Neutralizes: link hijack ([ ] ( )), table breakout (|), code span (`),
|
||||
// and backslash. Newlines are collapsed to a single space (inline contexts).
|
||||
function mdInline(value) {
|
||||
return String(value).replace(/[\\`*_[\]()|~<>]/g, '\\$&').replace(/[\r\n]+/g, ' ');
|
||||
}
|
||||
// A fenced-code fence guaranteed longer than any backtick run in `value`, so a
|
||||
// value containing ``` cannot escape the block (CommonMark rule). Min length 3.
|
||||
function fenceFor(value) {
|
||||
const runs = String(value).match(/`+/g) || [];
|
||||
const longest = runs.reduce((m, r) => Math.max(m, r.length), 0);
|
||||
return '`'.repeat(Math.max(3, longest + 1));
|
||||
}
|
||||
|
||||
// A single `engines.gsd` range clause: optional comparison operator, optional
|
||||
// leading `v`, exactly three dot-separated numeric segments, optional
|
||||
// prerelease (`-...`) and build (`+...`) suffixes. Operator alternation order
|
||||
// matters — `>=`/`<=` must be tried before `>`/`<` or the longer operator
|
||||
// would never match.
|
||||
const GSD_RANGE_CLAUSE_RE = /^(>=|<=|>|<|=|\^|~)?v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
/**
|
||||
* Validate the SHAPE of an `engines.gsd`-style semver range string (ADR-1244
|
||||
* D1). Self-contained — no `semver` dependency, modelled on the constraint
|
||||
* parsing in `scripts/check-env.cjs` (`satisfiesConstraint`), but this
|
||||
* function validates that the range is well-formed rather than comparing it
|
||||
* against a concrete version.
|
||||
*
|
||||
* @param {string} range
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidGsdRange(range) {
|
||||
if (typeof range !== 'string') return false;
|
||||
const trimmed = range.trim();
|
||||
if (trimmed === '') return false;
|
||||
if (trimmed === '*') return true;
|
||||
const clauses = trimmed.split(/\s+/);
|
||||
return clauses.length > 0 && clauses.every((clause) => clause !== '' && GSD_RANGE_CLAUSE_RE.test(clause));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the `interactions` sub-object for a capability entry.
|
||||
*
|
||||
* @param {object} interactions
|
||||
* @param {(field: string, reason: string) => void} addError
|
||||
* @returns {void}
|
||||
*/
|
||||
function validateCapabilityInteractions(interactions, addError) {
|
||||
const allowedKeys = new Set([
|
||||
'loopExtensionPoints',
|
||||
'hookKinds',
|
||||
'configKeys',
|
||||
'requires',
|
||||
'runtimeCompat',
|
||||
'produces',
|
||||
'consumes',
|
||||
]);
|
||||
for (const key of Object.keys(interactions)) {
|
||||
if (!allowedKeys.has(key)) addError(`interactions.${key}`, 'unknown field');
|
||||
}
|
||||
|
||||
for (const field of ['loopExtensionPoints', 'hookKinds']) {
|
||||
if (interactions[field] === undefined) addError(`interactions.${field}`, 'missing required field');
|
||||
}
|
||||
|
||||
if (interactions.loopExtensionPoints !== undefined) {
|
||||
const v = interactions.loopExtensionPoints;
|
||||
if (!Array.isArray(v) || v.length === 0 || !v.every((x) => LOOP_POINTS.includes(x))) {
|
||||
addError('interactions.loopExtensionPoints', 'must be a non-empty array of valid loop extension points');
|
||||
}
|
||||
}
|
||||
|
||||
if (interactions.hookKinds !== undefined) {
|
||||
const v = interactions.hookKinds;
|
||||
if (!Array.isArray(v) || !v.every((x) => HOOK_KINDS.includes(x))) {
|
||||
addError('interactions.hookKinds', 'must be an array of valid hook kinds');
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
|
||||
if (interactions[field] === undefined) continue;
|
||||
const v = interactions[field];
|
||||
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
|
||||
addError(`interactions.${field}`, 'must be an array of strings');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the `interactions` sub-object for an eos entry.
|
||||
*
|
||||
* @param {object} interactions
|
||||
* @param {(field: string, reason: string) => void} addError
|
||||
* @returns {void}
|
||||
*/
|
||||
function validateEosInteractions(interactions, addError) {
|
||||
const allowedKeys = new Set(['interfacePoints', 'profile', 'axes']);
|
||||
for (const key of Object.keys(interactions)) {
|
||||
if (!allowedKeys.has(key)) addError(`interactions.${key}`, 'unknown field');
|
||||
}
|
||||
|
||||
for (const field of ['interfacePoints', 'profile', 'axes']) {
|
||||
if (interactions[field] === undefined) addError(`interactions.${field}`, 'missing required field');
|
||||
}
|
||||
|
||||
if (interactions.interfacePoints !== undefined) {
|
||||
const v = interactions.interfacePoints;
|
||||
if (!Array.isArray(v) || v.length === 0 || !v.every((x) => INTERFACE_POINTS.includes(x))) {
|
||||
addError('interactions.interfacePoints', 'must be a non-empty array of valid interface points');
|
||||
}
|
||||
}
|
||||
|
||||
if (interactions.profile !== undefined) {
|
||||
if (typeof interactions.profile !== 'string' || !PROFILES.includes(interactions.profile)) {
|
||||
addError('interactions.profile', 'must be one of the valid negotiation profiles');
|
||||
}
|
||||
}
|
||||
|
||||
if (interactions.axes !== undefined) {
|
||||
const axes = interactions.axes;
|
||||
if (typeof axes !== 'object' || axes === null || Array.isArray(axes)) {
|
||||
addError('interactions.axes', 'axes must be an object');
|
||||
} else {
|
||||
const expectedKeys = Object.keys(AXES);
|
||||
const actualKeys = Object.keys(axes);
|
||||
const actualKeySet = new Set(actualKeys);
|
||||
const keysMatch = expectedKeys.length === actualKeys.length && expectedKeys.every((k) => actualKeySet.has(k));
|
||||
if (!keysMatch) {
|
||||
addError('interactions.axes', 'axes key set must exactly match the eight negotiated axes');
|
||||
} else {
|
||||
for (const key of expectedKeys) {
|
||||
const allowedValues = AXES[key];
|
||||
const v = axes[key];
|
||||
if (allowedValues === AXES_FREE_STRING) {
|
||||
if (typeof v !== 'string' || v.trim() === '') {
|
||||
addError(`interactions.axes.${key}`, 'must be a non-empty string');
|
||||
} else if (v.length > 300) {
|
||||
addError(`interactions.axes.${key}`, 'exceeds max length 300');
|
||||
}
|
||||
} else if (typeof v !== 'string' || !allowedValues.includes(v)) {
|
||||
addError(`interactions.axes.${key}`, `must be one of the allowed values for ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an array of registry entries against the closed schema for
|
||||
* `opts.type` ('capability' | 'eos').
|
||||
*
|
||||
* @param {object[]} entries
|
||||
* @param {{type: 'capability'|'eos'}} opts
|
||||
* @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
|
||||
*/
|
||||
function validateEntries(entries, opts) {
|
||||
if (!Array.isArray(entries)) {
|
||||
return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'entries must be an array' }] };
|
||||
}
|
||||
|
||||
// Entry-count cap: a pathologically large array (e.g. from an automated or
|
||||
// malicious PR) is rejected wholesale rather than validated entry-by-entry.
|
||||
if (entries.length > 2000) {
|
||||
return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'too many entries (max 2000)' }] };
|
||||
}
|
||||
|
||||
const required = opts.type === 'eos' ? EOS_REQUIRED : CAPABILITY_REQUIRED;
|
||||
const requiredSet = new Set(required);
|
||||
const seenIds = new Set();
|
||||
const errors = [];
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const addError = (field, reason) => {
|
||||
const err = { index, field, reason };
|
||||
if (entry && typeof entry === 'object' && typeof entry.id === 'string') err.id = entry.id;
|
||||
errors.push(err);
|
||||
};
|
||||
|
||||
// Null/non-object element guard — a malformed array element (null,
|
||||
// undefined-via-hole, a primitive, or an array) cannot be destructured by
|
||||
// the field checks below, so reject it outright rather than throwing.
|
||||
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
addError('(entry)', 'entry must be a JSON object');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (!requiredSet.has(key)) addError(key, 'unknown field');
|
||||
}
|
||||
|
||||
const missing = new Set();
|
||||
for (const field of required) {
|
||||
if (entry[field] === undefined) {
|
||||
addError(field, 'missing required field');
|
||||
missing.add(field);
|
||||
}
|
||||
}
|
||||
|
||||
// Control-character rejection (defense in depth): `allowTabNewline` widens
|
||||
// the reject-set exception for the two shell-snippet fields (install/
|
||||
// uninstall), which legitimately contain tabs/newlines; every other free
|
||||
// text field disallows ALL C0 control characters plus DEL (incl. \n/\t).
|
||||
// Checked via char codes (not a literal control-char regex range) — same
|
||||
// approach as capability-validator.cjs's hooks[].matcher check, which
|
||||
// avoids tripping ESLint's no-control-regex rule.
|
||||
const hasDisallowedControlChar = (v, allowTabNewline) => {
|
||||
for (let c = 0; c < v.length; c += 1) {
|
||||
const code = v.charCodeAt(c);
|
||||
if (allowTabNewline && (code === 0x09 || code === 0x0a)) continue;
|
||||
if (code < 0x20 || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const checkNoControlChars = (field, allowTabNewline) => {
|
||||
if (missing.has(field)) return;
|
||||
const v = entry[field];
|
||||
if (typeof v !== 'string') return;
|
||||
if (hasDisallowedControlChar(v, allowTabNewline)) addError(field, 'must not contain control characters');
|
||||
};
|
||||
// Length cap: reject oversized fields (untrusted third-party input feeding
|
||||
// a committed Markdown catalog should not be allowed to blow up the doc).
|
||||
const checkMaxLength = (field, max) => {
|
||||
if (missing.has(field)) return;
|
||||
const v = entry[field];
|
||||
if (typeof v === 'string' && v.length > max) addError(field, `exceeds max length ${max}`);
|
||||
};
|
||||
|
||||
if (!missing.has('id')) {
|
||||
const id = entry.id;
|
||||
if (typeof id !== 'string' || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id)) {
|
||||
addError('id', 'id must be kebab-case');
|
||||
}
|
||||
if (seenIds.has(id)) {
|
||||
addError('id', `duplicate id: ${id}`);
|
||||
} else {
|
||||
seenIds.add(id);
|
||||
}
|
||||
}
|
||||
checkMaxLength('id', 100);
|
||||
|
||||
for (const field of ['name', 'description', 'author']) {
|
||||
if (missing.has(field)) continue;
|
||||
const v = entry[field];
|
||||
if (typeof v !== 'string' || v.trim() === '') addError(field, 'must be a non-empty string');
|
||||
checkNoControlChars(field, false);
|
||||
}
|
||||
checkMaxLength('name', 120);
|
||||
checkMaxLength('author', 120);
|
||||
checkMaxLength('description', 1000);
|
||||
|
||||
if (!missing.has('type') && entry.type !== opts.type) {
|
||||
addError('type', `type must be "${opts.type}"`);
|
||||
}
|
||||
|
||||
if (!missing.has('repo')) {
|
||||
if (typeof entry.repo !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(entry.repo)) {
|
||||
addError('repo', 'repo must be in "owner/repo" form');
|
||||
}
|
||||
}
|
||||
checkMaxLength('repo', 100);
|
||||
|
||||
if (!missing.has('license')) {
|
||||
const v = entry.license;
|
||||
if (typeof v !== 'string' || v.trim() === '' || !/^[A-Za-z0-9.+()\- ]+$/.test(v)) {
|
||||
addError('license', 'license must be a non-empty SPDX-like string');
|
||||
}
|
||||
}
|
||||
checkMaxLength('license', 120);
|
||||
|
||||
if (!missing.has('enginesGsd') && !isValidGsdRange(entry.enginesGsd)) {
|
||||
addError('enginesGsd', 'enginesGsd must be a valid semver range');
|
||||
}
|
||||
checkMaxLength('enginesGsd', 100);
|
||||
|
||||
for (const field of ['install', 'uninstall']) {
|
||||
if (missing.has(field)) continue;
|
||||
const v = entry[field];
|
||||
if (typeof v !== 'string' || v.trim() === '') addError(field, 'must be a non-empty string');
|
||||
checkNoControlChars(field, true);
|
||||
}
|
||||
checkMaxLength('install', 2000);
|
||||
checkMaxLength('uninstall', 2000);
|
||||
|
||||
if (!missing.has('discussion')) {
|
||||
const v = entry.discussion;
|
||||
if (typeof v !== 'string' || !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/discussions\/\d+$/.test(v)) {
|
||||
addError('discussion', 'discussion must be a GitHub discussions URL');
|
||||
}
|
||||
}
|
||||
checkMaxLength('discussion', 300);
|
||||
|
||||
if (!missing.has('interactions')) {
|
||||
const interactions = entry.interactions;
|
||||
if (typeof interactions !== 'object' || interactions === null || Array.isArray(interactions)) {
|
||||
addError('interactions', 'interactions must be an object');
|
||||
} else if (opts.type === 'eos') {
|
||||
validateEosInteractions(interactions, addError);
|
||||
} else {
|
||||
validateCapabilityInteractions(interactions, addError);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.type === 'eos' && !missing.has('protocolVersion')) {
|
||||
if (!Number.isInteger(entry.protocolVersion) || entry.protocolVersion < 1) {
|
||||
addError('protocolVersion', 'protocolVersion must be an integer >= 1');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the deterministic Markdown document for a registry.
|
||||
*
|
||||
* @param {object[]} entries
|
||||
* @param {{type: 'capability'|'eos', sourceFile?: string}} opts
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderMarkdown(entries, opts) {
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
if (a.id < b.id) return -1;
|
||||
if (a.id > b.id) return 1;
|
||||
return 0;
|
||||
});
|
||||
const isEos = opts.type === 'eos';
|
||||
const lines = [];
|
||||
|
||||
lines.push(
|
||||
`<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/${opts.sourceFile} — do not edit by hand; run \`npm run gen:registry\` -->`,
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(isEos ? '# GSD EoS Registry' : '# GSD Community Capability Registry');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
"> **Not an endorsement.** Inclusion means only that a maintainer merged a PR linking the author's repository — GSD has not reviewed, tested, or verified any listing. See the [registry README](./README.md).",
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(`_To add your ${isEos ? 'integration' : 'capability'}, see the [registry README](./README.md)._`);
|
||||
lines.push('');
|
||||
|
||||
if (sorted.length === 0) {
|
||||
lines.push('_No entries yet — be the first: see [README](./README.md)._');
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
lines.push('| Name | What it is | Latest release | GSD compat | Discussion |');
|
||||
lines.push('|---|---|---|---|---|');
|
||||
for (const entry of sorted) {
|
||||
// entry.repo/enginesGsd/discussion are regex-constrained (validateEntries)
|
||||
// and used as link DESTINATIONS / badge URLs here — never mdInline those,
|
||||
// it would corrupt the URL. entry.name/description are untrusted free-text
|
||||
// link TEXT / body copy and MUST be escaped.
|
||||
lines.push(
|
||||
`| [${mdInline(entry.name)}](https://github.com/${entry.repo}) | ${mdInline(entry.description)} | ` +
|
||||
` | ` +
|
||||
`\`${entry.enginesGsd}\` | [discuss](${entry.discussion}) |`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
sorted.forEach((entry, i) => {
|
||||
const interactions = entry.interactions || {};
|
||||
|
||||
lines.push(`## ${mdInline(entry.name)}`);
|
||||
lines.push(
|
||||
`- **Repository:** https://github.com/${entry.repo} — [latest release](https://github.com/${entry.repo}/releases/latest)`,
|
||||
);
|
||||
lines.push(`- **What it is:** ${mdInline(entry.description)}`);
|
||||
lines.push(`- **Author:** ${mdInline(entry.author)}`);
|
||||
|
||||
if (isEos) {
|
||||
const axesSummary = Object.keys(AXES)
|
||||
.map((key) => `${key}=${interactions.axes ? interactions.axes[key] : undefined}`)
|
||||
.join(', ');
|
||||
const summary =
|
||||
`Interface points: ${(interactions.interfacePoints || []).join(', ')}; ` +
|
||||
`profile: ${interactions.profile}; protocol v${entry.protocolVersion}; axes: ${axesSummary}`;
|
||||
// Single mdInline pass over the fully-assembled summary: none of the
|
||||
// literal separator text above contains Markdown metacharacters, so
|
||||
// this equally neutralizes every embedded free-text/vocab value
|
||||
// (notably interactions.axes.dispatch, a free-form untrusted string).
|
||||
lines.push(`- **Every interaction with GSD:** ${mdInline(summary)}`);
|
||||
} else {
|
||||
let summary =
|
||||
`Loop Extension Points: ${(interactions.loopExtensionPoints || []).join(', ')}; ` +
|
||||
`hook kinds: ${(interactions.hookKinds || []).join(', ')}`;
|
||||
for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
|
||||
const v = interactions[field];
|
||||
if (Array.isArray(v) && v.length > 0) summary += `; ${field}: ${v.join(', ')}`;
|
||||
}
|
||||
// configKeys/requires/runtimeCompat/produces/consumes are untrusted
|
||||
// free-form strings (schema only requires "array of strings") — same
|
||||
// single-pass mdInline rationale as the eos branch above.
|
||||
lines.push(`- **Every interaction with GSD:** ${mdInline(summary)}`);
|
||||
}
|
||||
|
||||
// Code-span content (install/uninstall) is NOT mdInline-escaped — it is a
|
||||
// verbatim shell snippet, not inline prose. Instead each block picks a
|
||||
// fence strictly longer than any backtick run inside its own content, so
|
||||
// an embedded ``` cannot prematurely close the fence (CommonMark rule).
|
||||
const installFence = fenceFor(entry.install);
|
||||
lines.push('- **Install:**');
|
||||
lines.push(`${installFence}sh`);
|
||||
lines.push(entry.install);
|
||||
lines.push(installFence);
|
||||
const uninstallFence = fenceFor(entry.uninstall);
|
||||
lines.push('- **Uninstall:**');
|
||||
lines.push(`${uninstallFence}sh`);
|
||||
lines.push(entry.uninstall);
|
||||
lines.push(uninstallFence);
|
||||
|
||||
lines.push(
|
||||
isEos
|
||||
? `- **GSD compatibility:** \`${entry.enginesGsd}\`, protocol v${entry.protocolVersion}`
|
||||
: `- **GSD compatibility:** \`${entry.enginesGsd}\``,
|
||||
);
|
||||
lines.push(`- **License:** ${mdInline(entry.license)}`);
|
||||
lines.push(`- **Discussion / ranking:** ${entry.discussion}`);
|
||||
|
||||
if (i < sorted.length - 1) lines.push('');
|
||||
});
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LOOP_POINTS,
|
||||
HOOK_KINDS,
|
||||
INTERFACE_POINTS,
|
||||
PROFILES,
|
||||
AXES,
|
||||
AXES_FREE_STRING,
|
||||
CAPABILITY_REQUIRED,
|
||||
EOS_REQUIRED,
|
||||
isValidGsdRange,
|
||||
validateEntries,
|
||||
renderMarkdown,
|
||||
};
|
||||
117
scripts/validate-registry.cjs
Normal file
117
scripts/validate-registry.cjs
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* scripts/validate-registry.cjs — CLI validator for the third-party
|
||||
* discoverability catalogs (issue #2182):
|
||||
*
|
||||
* - docs/registries/capabilities.json ("GSD Community Capability Registry")
|
||||
* - docs/registries/eos.json ("GSD EoS Registry", PR2 — optional
|
||||
* until that JSON file ships)
|
||||
*
|
||||
* Validates each source's JSON array against the closed schema in
|
||||
* scripts/registry-schema.cjs (validateEntries). Human-readable errors go to
|
||||
* stderr; `--json` additionally prints a structured verdict to stdout.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/validate-registry.cjs # human-readable report
|
||||
* node scripts/validate-registry.cjs --json # structured JSON verdict
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 every present source's entries are all valid
|
||||
* 1 one or more entries failed validation (or a source's JSON is malformed)
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
const { validateEntries } = require('./registry-schema.cjs');
|
||||
|
||||
// Resolved relative to process.cwd() (not __dirname) so the CLI validates
|
||||
// whichever project it is invoked from — this is what lets tests drive it as
|
||||
// a subprocess against isolated temp-fixture directories via `cwd`.
|
||||
const SOURCES = [
|
||||
{ file: 'capabilities.json', type: 'capability' },
|
||||
{ file: 'eos.json', type: 'eos' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Load + validate a single registry JSON file.
|
||||
*
|
||||
* @param {string} jsonPath absolute path to the registry JSON file
|
||||
* @param {'capability'|'eos'} type
|
||||
* @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
|
||||
*/
|
||||
function validateFile(jsonPath, type) {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(jsonPath, 'utf8');
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ index: -1, field: '<file>', reason: `could not read ${jsonPath}: ${err.message}` }],
|
||||
};
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ index: -1, field: '<file>', reason: `JSON parse error in ${jsonPath}: ${err.message}` }],
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(entries)) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ index: -1, field: '<file>', reason: `${jsonPath} must be a JSON array of entries` }],
|
||||
};
|
||||
}
|
||||
|
||||
return validateEntries(entries, { type });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const jsonMode = process.argv.includes('--json');
|
||||
const registriesDir = path.join(process.cwd(), 'docs', 'registries');
|
||||
|
||||
const results = [];
|
||||
let anyFailed = false;
|
||||
|
||||
for (const { file, type } of SOURCES) {
|
||||
const jsonPath = path.join(registriesDir, file);
|
||||
// eos.json is optional until PR2 ships it — skip silently when absent.
|
||||
if (type === 'eos' && !fs.existsSync(jsonPath)) continue;
|
||||
|
||||
const verdict = validateFile(jsonPath, type);
|
||||
results.push({ file, type, ok: verdict.ok, errors: verdict.errors });
|
||||
if (!verdict.ok) anyFailed = true;
|
||||
}
|
||||
|
||||
if (jsonMode) {
|
||||
process.stdout.write(JSON.stringify({ ok: !anyFailed, results }, null, 2) + '\n');
|
||||
} else if (anyFailed) {
|
||||
process.stderr.write('\nERROR validate-registry: one or more entries failed validation\n');
|
||||
for (const result of results) {
|
||||
if (result.ok) continue;
|
||||
process.stderr.write(`\n${result.file}:\n`);
|
||||
for (const e of result.errors) {
|
||||
const idPart = e.id ? ` (id: ${e.id})` : '';
|
||||
process.stderr.write(` entry[${e.index}]${idPart} field "${e.field}": ${e.reason}\n`);
|
||||
}
|
||||
}
|
||||
process.stderr.write('\n');
|
||||
} else {
|
||||
process.stdout.write('ok validate-registry: all entries valid\n');
|
||||
}
|
||||
|
||||
if (anyFailed) throw new ExitError(1, 'registry validation failed');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) runMain(main);
|
||||
|
||||
module.exports = { main, validateFile, SOURCES };
|
||||
144
tests/gen-registry.test.cjs
Normal file
144
tests/gen-registry.test.cjs
Normal file
@@ -0,0 +1,144 @@
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const SCRIPT_PATH = path.join(__dirname, '..', 'scripts', 'gen-registry.cjs');
|
||||
const { renderMarkdown } = require(path.join(__dirname, '..', 'scripts', 'registry-schema.cjs'));
|
||||
|
||||
// gen-registry.cjs resolves docs/registries/ from process.cwd() (mirrors
|
||||
// validate-registry.cjs), so tests drive it as a subprocess with `cwd`
|
||||
// pointed at an isolated temp fixture directory.
|
||||
|
||||
function validCapabilityEntry() {
|
||||
return {
|
||||
id: 'my-capability',
|
||||
name: 'My Capability',
|
||||
type: 'capability',
|
||||
repo: 'octocat/my-capability',
|
||||
description: 'Does a useful thing for GSD users.',
|
||||
author: 'Octocat',
|
||||
license: 'MIT',
|
||||
enginesGsd: '>=1.6.0 <3.0.0',
|
||||
install: 'gsd capability install https://github.com/octocat/my-capability.git#v1.0.0',
|
||||
uninstall: 'gsd capability remove my-capability',
|
||||
interactions: {
|
||||
loopExtensionPoints: ['execute:pre'],
|
||||
hookKinds: ['step'],
|
||||
configKeys: [],
|
||||
requires: [],
|
||||
runtimeCompat: ['all'],
|
||||
produces: [],
|
||||
consumes: [],
|
||||
},
|
||||
discussion: 'https://github.com/octocat/my-capability/discussions/1',
|
||||
};
|
||||
}
|
||||
|
||||
function withFixture(entries, fn) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gen-registry-'));
|
||||
try {
|
||||
const registriesDir = path.join(tmp, 'docs', 'registries');
|
||||
fs.mkdirSync(registriesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify(entries, null, 2) + '\n');
|
||||
fn(tmp, registriesDir);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
function runGen(cwd, args = []) {
|
||||
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { cwd, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
describe('gen-registry CLI (subprocess)', () => {
|
||||
test('--write then --check is clean (no drift) for a populated registry', () => {
|
||||
withFixture([validCapabilityEntry()], (tmp, registriesDir) => {
|
||||
const write = runGen(tmp, ['--write']);
|
||||
assert.equal(write.status, 0, `stderr: ${write.stderr}`);
|
||||
assert.ok(fs.existsSync(path.join(registriesDir, 'capability-registry.md')));
|
||||
|
||||
const check = runGen(tmp, ['--check']);
|
||||
assert.equal(check.status, 0, `stderr: ${check.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('hand-mutating the generated md then --check fails (drift detected)', () => {
|
||||
withFixture([validCapabilityEntry()], (tmp, registriesDir) => {
|
||||
const write = runGen(tmp, ['--write']);
|
||||
assert.equal(write.status, 0, `stderr: ${write.stderr}`);
|
||||
|
||||
const mdPath = path.join(registriesDir, 'capability-registry.md');
|
||||
fs.appendFileSync(mdPath, '\nhand-edited drift line\n');
|
||||
|
||||
const check = runGen(tmp, ['--check']);
|
||||
assert.notEqual(check.status, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('--write on an empty registry ([]) produces md containing the empty-state text', () => {
|
||||
withFixture([], (tmp, registriesDir) => {
|
||||
const write = runGen(tmp, ['--write']);
|
||||
assert.equal(write.status, 0, `stderr: ${write.stderr}`);
|
||||
|
||||
const mdPath = path.join(registriesDir, 'capability-registry.md');
|
||||
const content = fs.readFileSync(mdPath, 'utf8');
|
||||
assert.match(content, /No entries yet/);
|
||||
});
|
||||
});
|
||||
|
||||
test('default (no flag) prints rendered markdown to stdout', () => {
|
||||
withFixture([validCapabilityEntry()], (tmp) => {
|
||||
const result = runGen(tmp, []);
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.length > 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('gen-registry CLI (subprocess): F3 — missing capabilities.json is an error, not a silent pass', () => {
|
||||
test('--check exits non-zero when docs/registries/ exists but capabilities.json is absent', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gen-registry-nocaps-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmp, 'docs', 'registries'), { recursive: true });
|
||||
// Deliberately do NOT write capabilities.json — only eos.json is optional.
|
||||
const check = runGen(tmp, ['--check']);
|
||||
assert.notEqual(check.status, 0, `expected non-zero exit, got 0. stdout: ${check.stdout}`);
|
||||
assert.match(check.stderr, /capabilities\.json/);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('default mode (no flag) also hard-errors when capabilities.json is absent', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gen-registry-nocaps-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmp, 'docs', 'registries'), { recursive: true });
|
||||
const result = runGen(tmp, []);
|
||||
assert.notEqual(result.status, 0, `expected non-zero exit, got 0. stdout: ${result.stdout}`);
|
||||
assert.match(result.stderr, /capabilities\.json/);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gen-registry: renderMarkdown (direct, via registry-schema)', () => {
|
||||
test('renders empty-state text for an empty capability registry', () => {
|
||||
const rendered = renderMarkdown([], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
assert.match(rendered, /No entries yet/);
|
||||
});
|
||||
|
||||
test('renders the shields.io badge + discussion link for a populated capability registry', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
const rendered = renderMarkdown([entry], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
assert.match(rendered, /img\.shields\.io\/github\/v\/release/);
|
||||
assert.ok(rendered.includes(entry.discussion));
|
||||
});
|
||||
});
|
||||
629
tests/registry-schema.test.cjs
Normal file
629
tests/registry-schema.test.cjs
Normal file
@@ -0,0 +1,629 @@
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fc = require('fast-check');
|
||||
|
||||
const {
|
||||
LOOP_POINTS,
|
||||
HOOK_KINDS,
|
||||
INTERFACE_POINTS,
|
||||
PROFILES,
|
||||
AXES,
|
||||
AXES_FREE_STRING,
|
||||
CAPABILITY_REQUIRED,
|
||||
EOS_REQUIRED,
|
||||
isValidGsdRange,
|
||||
validateEntries,
|
||||
renderMarkdown,
|
||||
} = require(path.join(__dirname, '..', 'scripts', 'registry-schema.cjs'));
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
function validCapabilityEntry() {
|
||||
return {
|
||||
id: 'my-capability',
|
||||
name: 'My Capability',
|
||||
type: 'capability',
|
||||
repo: 'octocat/my-capability',
|
||||
description: 'Does a useful thing for GSD users.',
|
||||
author: 'Octocat',
|
||||
license: 'MIT',
|
||||
enginesGsd: '>=1.6.0 <3.0.0',
|
||||
install: 'gsd capability install https://github.com/octocat/my-capability.git#v1.0.0',
|
||||
uninstall: 'gsd capability remove my-capability',
|
||||
interactions: {
|
||||
loopExtensionPoints: ['execute:pre'],
|
||||
hookKinds: ['step'],
|
||||
configKeys: ['myCapability.enabled'],
|
||||
requires: [],
|
||||
runtimeCompat: ['all'],
|
||||
produces: [],
|
||||
consumes: [],
|
||||
},
|
||||
discussion: 'https://github.com/octocat/my-capability/discussions/1',
|
||||
};
|
||||
}
|
||||
|
||||
function validEosEntry() {
|
||||
return {
|
||||
id: 'my-host-plugin',
|
||||
name: 'My Host Plugin',
|
||||
type: 'eos',
|
||||
repo: 'octocat/my-host-plugin',
|
||||
description: 'Embeds GSD as an orchestration engine in My Host.',
|
||||
author: 'Octocat',
|
||||
license: 'MIT',
|
||||
enginesGsd: '>=1.6.0 <3.0.0',
|
||||
install: 'See the My Host plugin marketplace listing.',
|
||||
uninstall: 'Uninstall via the My Host plugin manager.',
|
||||
protocolVersion: 1,
|
||||
interactions: {
|
||||
interfacePoints: ['command', 'state'],
|
||||
profile: 'programmatic-cli',
|
||||
axes: {
|
||||
embeddingMode: 'imperative',
|
||||
commandSurface: 'slash-file',
|
||||
dispatch: 'Supports nested background dispatch up to depth 3.',
|
||||
modelMode: 'active',
|
||||
hookBus: 'host',
|
||||
stateIO: 'filesystem',
|
||||
transport: 'mcp',
|
||||
runtime: 'node',
|
||||
},
|
||||
},
|
||||
discussion: 'https://github.com/octocat/my-host-plugin/discussions/2',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Vocabulary constants ───────────────────────────────────────────────────
|
||||
|
||||
describe('registry-schema: closed vocabulary constants', () => {
|
||||
test('LOOP_POINTS is the 12 canonical loop points (ADR-857), in order', () => {
|
||||
assert.deepEqual(LOOP_POINTS, [
|
||||
'discuss:pre',
|
||||
'discuss:post',
|
||||
'plan:pre',
|
||||
'plan:post',
|
||||
'execute:pre',
|
||||
'execute:wave:pre',
|
||||
'execute:wave:post',
|
||||
'execute:post',
|
||||
'verify:pre',
|
||||
'verify:post',
|
||||
'ship:pre',
|
||||
'ship:post',
|
||||
]);
|
||||
});
|
||||
|
||||
test('HOOK_KINDS is step/contribution/gate (ADR-857 Decision 4)', () => {
|
||||
assert.deepEqual(HOOK_KINDS, ['step', 'contribution', 'gate']);
|
||||
});
|
||||
|
||||
test('INTERFACE_POINTS is the six ADR-1239 interface points', () => {
|
||||
assert.deepEqual(INTERFACE_POINTS, ['command', 'dispatch', 'model', 'hooks', 'state', 'artifact']);
|
||||
});
|
||||
|
||||
test('PROFILES is the three ADR-1239 negotiation profiles', () => {
|
||||
assert.deepEqual(PROFILES, ['programmatic-cli', 'declarative-cli', 'ide']);
|
||||
});
|
||||
|
||||
test('AXES has exactly the eight ADR-1239 negotiated axis keys', () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(AXES).sort(),
|
||||
['commandSurface', 'dispatch', 'embeddingMode', 'hookBus', 'modelMode', 'runtime', 'stateIO', 'transport'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('AXES.dispatch carries the free-string sentinel, not an enum array', () => {
|
||||
assert.equal(AXES.dispatch, AXES_FREE_STRING);
|
||||
assert.equal(Array.isArray(AXES.dispatch), false);
|
||||
});
|
||||
|
||||
test('every non-dispatch AXES entry is a non-empty enum array', () => {
|
||||
for (const [key, value] of Object.entries(AXES)) {
|
||||
if (key === 'dispatch') continue;
|
||||
assert.ok(Array.isArray(value), `AXES.${key} should be an array`);
|
||||
assert.ok(value.length > 0, `AXES.${key} should be non-empty`);
|
||||
}
|
||||
});
|
||||
|
||||
test('CAPABILITY_REQUIRED lists the 12 required capability entry fields', () => {
|
||||
assert.deepEqual(CAPABILITY_REQUIRED, [
|
||||
'id', 'name', 'type', 'repo', 'description', 'author', 'license',
|
||||
'enginesGsd', 'install', 'uninstall', 'interactions', 'discussion',
|
||||
]);
|
||||
});
|
||||
|
||||
test('EOS_REQUIRED lists the 13 required eos entry fields (adds protocolVersion)', () => {
|
||||
assert.deepEqual(EOS_REQUIRED, [
|
||||
'id', 'name', 'type', 'repo', 'description', 'author', 'license',
|
||||
'enginesGsd', 'install', 'uninstall', 'interactions', 'discussion', 'protocolVersion',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateEntries: capability ───────────────────────────────────────────
|
||||
|
||||
describe('validateEntries: capability — happy path', () => {
|
||||
test('a fully-valid capability entry passes', () => {
|
||||
const verdict = validateEntries([validCapabilityEntry()], { type: 'capability' });
|
||||
assert.equal(verdict.ok, true);
|
||||
assert.deepEqual(verdict.errors, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: capability — required fields', () => {
|
||||
for (const field of CAPABILITY_REQUIRED) {
|
||||
test(`missing required field "${field}" fails`, () => {
|
||||
const entry = validCapabilityEntry();
|
||||
delete entry[field];
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.length > 0, 'expected at least one error');
|
||||
assert.ok(
|
||||
verdict.errors.some((e) => e.field === field),
|
||||
`expected an error referencing field "${field}", got: ${JSON.stringify(verdict.errors)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('validateEntries: capability — field shape violations', () => {
|
||||
test('bad id (not kebab-case) fails', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.id = 'Not_Kebab_Case';
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'id'));
|
||||
});
|
||||
|
||||
test('bad repo (not owner/repo form) fails', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.repo = 'not-a-valid-repo';
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'repo'));
|
||||
});
|
||||
|
||||
test('bad enginesGsd (malformed range) fails', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.enginesGsd = 'not-a-semver-range';
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'enginesGsd'));
|
||||
});
|
||||
|
||||
test('bad discussion URL fails', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.discussion = 'https://example.com/not-a-discussion';
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'discussion'));
|
||||
});
|
||||
|
||||
test('bad license fails', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.license = 'Not A Valid License!!';
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'license'));
|
||||
});
|
||||
|
||||
test('unknown top-level key fails (strict schema)', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.extraUnknownField = 'nope';
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'extraUnknownField'));
|
||||
});
|
||||
|
||||
test('duplicate id across two entries fails', () => {
|
||||
const a = validCapabilityEntry();
|
||||
const b = validCapabilityEntry();
|
||||
b.name = 'A Different Name';
|
||||
b.repo = 'octocat/another-capability';
|
||||
b.discussion = 'https://github.com/octocat/another-capability/discussions/2';
|
||||
// b.id intentionally left the same as a.id
|
||||
const verdict = validateEntries([a, b], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'id' && /duplicate/i.test(e.reason)));
|
||||
});
|
||||
|
||||
test('empty loopExtensionPoints fails (AC3 — must be non-empty)', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.interactions.loopExtensionPoints = [];
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.loopExtensionPoints'));
|
||||
});
|
||||
|
||||
test('invalid loop point fails', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.interactions.loopExtensionPoints = ['not:a:real:point'];
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.loopExtensionPoints'));
|
||||
});
|
||||
|
||||
test('invalid hook kind fails', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.interactions.hookKinds = ['not-a-real-kind'];
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.hookKinds'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateEntries: eos ───────────────────────────────────────────────────
|
||||
|
||||
describe('validateEntries: eos — happy path', () => {
|
||||
test('a fully-valid eos entry passes', () => {
|
||||
const verdict = validateEntries([validEosEntry()], { type: 'eos' });
|
||||
assert.equal(verdict.ok, true);
|
||||
assert.deepEqual(verdict.errors, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: eos — field shape violations', () => {
|
||||
test('bad interfacePoint fails', () => {
|
||||
const entry = validEosEntry();
|
||||
entry.interactions.interfacePoints = ['not-a-real-point'];
|
||||
const verdict = validateEntries([entry], { type: 'eos' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.interfacePoints'));
|
||||
});
|
||||
|
||||
test('bad profile fails', () => {
|
||||
const entry = validEosEntry();
|
||||
entry.interactions.profile = 'not-a-real-profile';
|
||||
const verdict = validateEntries([entry], { type: 'eos' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.profile'));
|
||||
});
|
||||
|
||||
test('bad axis value fails', () => {
|
||||
const entry = validEosEntry();
|
||||
entry.interactions.axes.embeddingMode = 'not-a-real-value';
|
||||
const verdict = validateEntries([entry], { type: 'eos' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.axes.embeddingMode'));
|
||||
});
|
||||
|
||||
test('protocolVersion < 1 fails', () => {
|
||||
const entry = validEosEntry();
|
||||
entry.protocolVersion = 0;
|
||||
const verdict = validateEntries([entry], { type: 'eos' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'protocolVersion'));
|
||||
});
|
||||
|
||||
test('missing axis key fails', () => {
|
||||
const entry = validEosEntry();
|
||||
delete entry.interactions.axes.runtime;
|
||||
const verdict = validateEntries([entry], { type: 'eos' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.axes'));
|
||||
});
|
||||
|
||||
test('extra axis key fails', () => {
|
||||
const entry = validEosEntry();
|
||||
entry.interactions.axes.notARealAxis = 'x';
|
||||
const verdict = validateEntries([entry], { type: 'eos' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.axes'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── renderMarkdown ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
test('is deterministic across two calls regardless of input entry order', () => {
|
||||
const a = validCapabilityEntry();
|
||||
const b = { ...validCapabilityEntry(), id: 'zzz-capability', name: 'ZZZ Capability' };
|
||||
const first = renderMarkdown([a, b], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
const second = renderMarkdown([b, a], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
assert.equal(first, second);
|
||||
});
|
||||
|
||||
test('contains the shields.io release badge for a populated registry', () => {
|
||||
const rendered = renderMarkdown([validCapabilityEntry()], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
assert.match(rendered, /img\.shields\.io\/github\/v\/release/);
|
||||
});
|
||||
|
||||
test('contains the entry discussion URL for a populated registry', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
const rendered = renderMarkdown([entry], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
assert.ok(rendered.includes(entry.discussion), 'expected rendered output to include the discussion URL');
|
||||
});
|
||||
|
||||
test('renders the author for a populated registry', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
const rendered = renderMarkdown([entry], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
assert.match(rendered, /- \*\*Author:\*\* Octocat/);
|
||||
});
|
||||
|
||||
test('contains the empty-state text for zero entries', () => {
|
||||
const rendered = renderMarkdown([], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
assert.match(rendered, /No entries yet/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isValidGsdRange ────────────────────────────────────────────────────────
|
||||
|
||||
describe('isValidGsdRange', () => {
|
||||
test('fast-check property: well-formed operator+M.N.P ranges are valid', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom('', '>=', '>', '<=', '<', '=', '^', '~'),
|
||||
fc.integer({ min: 0, max: 999 }),
|
||||
fc.integer({ min: 0, max: 999 }),
|
||||
fc.integer({ min: 0, max: 999 }),
|
||||
(op, major, minor, patch) => {
|
||||
const range = `${op}${major}.${minor}.${patch}`;
|
||||
assert.equal(isValidGsdRange(range), true, range);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('fast-check property: a non-numeric major segment is always invalid', () => {
|
||||
// Replacing a numeric segment with letters can never be a well-formed range.
|
||||
// Letters-only (not arbitrary garbage) keeps the generator from accidentally
|
||||
// producing a valid semver-with-prerelease like `1.2.3-rc` — `>=1.2.3-rc.0.0`
|
||||
// IS a legitimate prerelease range the validator accepts, which would make an
|
||||
// "always invalid" assertion intermittently fail (a hidden flake).
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom('', '>=', '>', '<=', '<', '=', '^', '~'),
|
||||
fc
|
||||
.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), { minLength: 1, maxLength: 6 })
|
||||
.map((chars) => chars.join('')),
|
||||
(op, letters) => {
|
||||
assert.equal(isValidGsdRange(`${op}${letters}.0.0`), false, `${op}${letters}.0.0`);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('boundary: valid range strings', () => {
|
||||
for (const good of ['1.0.0', '>=1.0.0', '^1.0.0 <2.0.0', '*']) {
|
||||
assert.equal(isValidGsdRange(good), true, good);
|
||||
}
|
||||
});
|
||||
|
||||
test('boundary: invalid range strings', () => {
|
||||
for (const bad of ['1.0', '>=abc', '']) {
|
||||
assert.equal(isValidGsdRange(bad), false, bad);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── renderMarkdown: Markdown-injection escaping (adversarial-review hardening) ──
|
||||
|
||||
describe('renderMarkdown: mdInline escaping neutralizes untrusted free text', () => {
|
||||
test('description containing a table-breakout + link-hijack payload is escaped', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.description = 'Good stuff |  | text';
|
||||
const rendered = renderMarkdown([entry], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
assert.ok(rendered.includes('\\|'), 'expected an escaped pipe (\\|) in the rendered output');
|
||||
assert.ok(
|
||||
!rendered.includes(''),
|
||||
'expected the raw unescaped link-hijack payload to NOT appear verbatim',
|
||||
);
|
||||
assert.ok(rendered.includes('\\['), 'expected an escaped [ (\\[), proving the hijack bracket was neutralized');
|
||||
});
|
||||
|
||||
test('name containing a link-hijack payload is escaped (no raw ](url) survives)', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.name = 'Evil] (https://evil.example) [';
|
||||
const rendered = renderMarkdown([entry], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
assert.ok(
|
||||
!rendered.includes('](https://evil.example)'),
|
||||
'expected the ] to be escaped, breaking the hijacked link destination pairing',
|
||||
);
|
||||
});
|
||||
|
||||
test('install containing an embedded ``` run gets a longer fence, keeping injected content inside the block', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.install = 'echo a\n```\n## FAKE\n```sh\nbad';
|
||||
const rendered = renderMarkdown([entry], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
|
||||
const openIdx = rendered.indexOf('````sh');
|
||||
assert.ok(openIdx !== -1, 'expected a 4-backtick opening fence (longer than the embedded 3-backtick run)');
|
||||
|
||||
const afterOpen = rendered.slice(openIdx + '````sh'.length);
|
||||
const closeIdx = afterOpen.indexOf('````');
|
||||
assert.ok(closeIdx !== -1, 'expected a matching 4-backtick closing fence');
|
||||
|
||||
const blockBody = afterOpen.slice(0, closeIdx);
|
||||
assert.ok(
|
||||
blockBody.includes('## FAKE'),
|
||||
'expected the injected "## FAKE" heading to remain INSIDE the fenced block, not escape it',
|
||||
);
|
||||
});
|
||||
|
||||
test('name/description with a raw newline: validateEntries rejects it, and if rendered anyway the newline collapses', () => {
|
||||
const nameEntry = validCapabilityEntry();
|
||||
nameEntry.name = 'Evil\nName';
|
||||
assert.equal(validateEntries([nameEntry], { type: 'capability' }).ok, false);
|
||||
|
||||
const descEntry = validCapabilityEntry();
|
||||
descEntry.description = 'Evil\nDescription';
|
||||
assert.equal(validateEntries([descEntry], { type: 'capability' }).ok, false);
|
||||
|
||||
// Defense in depth: renderMarkdown does not itself call validateEntries, so
|
||||
// confirm mdInline still collapses an embedded newline to a single space —
|
||||
// no raw newline lands inside a rendered table row.
|
||||
const rendered = renderMarkdown([nameEntry], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
const matchingRows = rendered.split('\n').filter((line) => line.startsWith('| [Evil'));
|
||||
assert.equal(matchingRows.length, 1, 'expected the newline-containing name to collapse into a single table row');
|
||||
assert.ok(matchingRows[0].includes('Evil Name'), `expected collapsed "Evil Name", got: ${matchingRows[0]}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateEntries: null/non-object element guard (F2) ──────────────────────
|
||||
|
||||
describe('validateEntries: null/non-object element guard (F2)', () => {
|
||||
test('a null entry fails without throwing', () => {
|
||||
let verdict;
|
||||
assert.doesNotThrow(() => {
|
||||
verdict = validateEntries([null], { type: 'capability' });
|
||||
});
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === '(entry)'));
|
||||
});
|
||||
|
||||
test('an undefined entry fails without throwing', () => {
|
||||
let verdict;
|
||||
assert.doesNotThrow(() => {
|
||||
verdict = validateEntries([undefined], { type: 'capability' });
|
||||
});
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === '(entry)'));
|
||||
});
|
||||
|
||||
test('primitive and array elements fail without throwing', () => {
|
||||
const verdict = validateEntries(['a string', [1, 2, 3], 42], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.equal(verdict.errors.filter((e) => e.field === '(entry)').length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── renderMarkdown: eos registry (F4) ─────────────────────────────────────────
|
||||
|
||||
describe('renderMarkdown: eos registry (F4)', () => {
|
||||
test('renders the eos heading, the free-form dispatch text, protocol wording, and integration wording', () => {
|
||||
const rendered = renderMarkdown([validEosEntry()], { type: 'eos', sourceFile: 'eos.json' });
|
||||
assert.match(rendered, /# GSD EoS Registry/);
|
||||
assert.ok(rendered.includes('Supports nested background dispatch up to depth 3.'));
|
||||
assert.match(rendered, /protocol v1/);
|
||||
assert.match(rendered, /integration/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateEntries: interactions guards (F4) ─────────────────────────────────
|
||||
|
||||
describe('validateEntries: interactions guards (F4)', () => {
|
||||
test('capability interactions.someUnknownKey fails at the qualified field', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.interactions.someUnknownKey = 'x';
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.someUnknownKey'));
|
||||
});
|
||||
|
||||
test('eos interactions.someUnknownKey fails at the qualified field', () => {
|
||||
const entry = validEosEntry();
|
||||
entry.interactions.someUnknownKey = 'x';
|
||||
const verdict = validateEntries([entry], { type: 'eos' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.someUnknownKey'));
|
||||
});
|
||||
|
||||
test('interactions.configKeys as a non-array string fails', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.interactions.configKeys = 'nope';
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.configKeys'));
|
||||
});
|
||||
|
||||
test('interactions.configKeys with non-string elements fails', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.interactions.configKeys = [123];
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.configKeys'));
|
||||
});
|
||||
|
||||
test('eos interactions.axes as a non-object string fails', () => {
|
||||
const entry = validEosEntry();
|
||||
entry.interactions.axes = 'nope';
|
||||
const verdict = validateEntries([entry], { type: 'eos' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions.axes'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateEntries: new hardening checks (length caps, tightened regexes) ────
|
||||
|
||||
describe('validateEntries: description length cap (max 1000)', () => {
|
||||
test('999 chars (limit-1) passes the cap', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.description = 'x'.repeat(999);
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.ok(!verdict.errors.some((e) => e.field === 'description' && /exceeds max length/.test(e.reason)));
|
||||
});
|
||||
|
||||
test('1000 chars (limit) passes the cap', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.description = 'x'.repeat(1000);
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.ok(!verdict.errors.some((e) => e.field === 'description' && /exceeds max length/.test(e.reason)));
|
||||
});
|
||||
|
||||
test('1001 chars (limit+1) fails the cap', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.description = 'x'.repeat(1001);
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'description' && /exceeds max length 1000/.test(e.reason)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: entry-count cap (max 2000)', () => {
|
||||
function makeEntries(n) {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
...validCapabilityEntry(),
|
||||
id: `cap-${i}`,
|
||||
repo: `octocat/cap-${i}`,
|
||||
discussion: `https://github.com/octocat/cap-${i}/discussions/1`,
|
||||
}));
|
||||
}
|
||||
|
||||
test('1999 entries (limit-1) does not trip the cap', () => {
|
||||
const verdict = validateEntries(makeEntries(1999), { type: 'capability' });
|
||||
assert.ok(!verdict.errors.some((e) => e.field === '(root)'));
|
||||
});
|
||||
|
||||
test('2000 entries (limit) does not trip the cap', () => {
|
||||
const verdict = validateEntries(makeEntries(2000), { type: 'capability' });
|
||||
assert.ok(!verdict.errors.some((e) => e.field === '(root)'));
|
||||
});
|
||||
|
||||
test('2001 entries (limit+1) trips the cap with a single root error', () => {
|
||||
const verdict = validateEntries(makeEntries(2001), { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.equal(verdict.errors.length, 1);
|
||||
assert.equal(verdict.errors[0].field, '(root)');
|
||||
assert.match(verdict.errors[0].reason, /max 2000/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: tightened discussion/license regexes', () => {
|
||||
test('discussion URL containing an injection char ([) fails the tightened regex', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.discussion = 'https://github.com/a[b/c/discussions/1';
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'discussion'));
|
||||
});
|
||||
|
||||
test('license containing a newline fails the tightened regex', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.license = 'MIT\nEVIL';
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'license'));
|
||||
});
|
||||
|
||||
test('a compound SPDX license ("MIT OR Apache-2.0") still passes', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.license = 'MIT OR Apache-2.0';
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.ok(!verdict.errors.some((e) => e.field === 'license'));
|
||||
});
|
||||
});
|
||||
117
tests/validate-registry.test.cjs
Normal file
117
tests/validate-registry.test.cjs
Normal file
@@ -0,0 +1,117 @@
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const SCRIPT_PATH = path.join(__dirname, '..', 'scripts', 'validate-registry.cjs');
|
||||
|
||||
// validate-registry.cjs resolves docs/registries/ from process.cwd(), so
|
||||
// tests drive it as a subprocess with `cwd` pointed at an isolated temp
|
||||
// fixture directory — this covers main() end-to-end without touching the
|
||||
// real repo's docs/registries/capabilities.json.
|
||||
|
||||
function validCapabilityEntry() {
|
||||
return {
|
||||
id: 'my-capability',
|
||||
name: 'My Capability',
|
||||
type: 'capability',
|
||||
repo: 'octocat/my-capability',
|
||||
description: 'Does a useful thing for GSD users.',
|
||||
author: 'Octocat',
|
||||
license: 'MIT',
|
||||
enginesGsd: '>=1.6.0 <3.0.0',
|
||||
install: 'gsd capability install https://github.com/octocat/my-capability.git#v1.0.0',
|
||||
uninstall: 'gsd capability remove my-capability',
|
||||
interactions: {
|
||||
loopExtensionPoints: ['execute:pre'],
|
||||
hookKinds: ['step'],
|
||||
configKeys: [],
|
||||
requires: [],
|
||||
runtimeCompat: ['all'],
|
||||
produces: [],
|
||||
consumes: [],
|
||||
},
|
||||
discussion: 'https://github.com/octocat/my-capability/discussions/1',
|
||||
};
|
||||
}
|
||||
|
||||
function withFixture(entries, fn) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-validate-registry-'));
|
||||
try {
|
||||
const registriesDir = path.join(tmp, 'docs', 'registries');
|
||||
fs.mkdirSync(registriesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify(entries, null, 2) + '\n');
|
||||
fn(tmp);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
function runValidate(cwd, args = []) {
|
||||
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { cwd, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
describe('validate-registry CLI (subprocess)', () => {
|
||||
test('a good capabilities.json fixture exits 0', () => {
|
||||
withFixture([validCapabilityEntry()], (tmp) => {
|
||||
const result = runValidate(tmp);
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('a bad capabilities.json fixture (missing required field) exits non-zero', () => {
|
||||
const bad = validCapabilityEntry();
|
||||
delete bad.discussion;
|
||||
withFixture([bad], (tmp) => {
|
||||
const result = runValidate(tmp);
|
||||
assert.notEqual(result.status, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('a bad capabilities.json fixture (bad id) exits non-zero', () => {
|
||||
const bad = validCapabilityEntry();
|
||||
bad.id = 'Not_Kebab_Case';
|
||||
withFixture([bad], (tmp) => {
|
||||
const result = runValidate(tmp);
|
||||
assert.notEqual(result.status, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('--json prints a parseable verdict for a good fixture', () => {
|
||||
withFixture([validCapabilityEntry()], (tmp) => {
|
||||
const result = runValidate(tmp, ['--json']);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(typeof parsed.ok, 'boolean');
|
||||
assert.ok(Array.isArray(parsed.results));
|
||||
assert.ok(parsed.results.some((r) => r.file === 'capabilities.json'));
|
||||
});
|
||||
});
|
||||
|
||||
test('--json prints a parseable verdict for a bad fixture', () => {
|
||||
const bad = validCapabilityEntry();
|
||||
delete bad.license;
|
||||
withFixture([bad], (tmp) => {
|
||||
const result = runValidate(tmp, ['--json']);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(typeof parsed.ok, 'boolean');
|
||||
assert.equal(parsed.ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('missing capabilities.json entirely exits non-zero', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-validate-registry-empty-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmp, 'docs', 'registries'), { recursive: true });
|
||||
const result = runValidate(tmp);
|
||||
assert.notEqual(result.status, 0);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user