enh(#2904): add a reviewer entry type so third-party reviewer lanes are discoverable (#2912)

* feat(#2904): add a `reviewer` entry type so third-party reviewer lanes are discoverable

ADR-2782 made a reviewer lane installable by a third party, but neither
discoverability catalog could hold one. The Community Capability Registry
requires a non-empty `loopExtensionPoints` and forbids a lane from declaring
any hook kind, so a `role: "reviewer"` entry is unsatisfiable by construction;
the EoS Registry is for ADR-1239 host integrations, which a lane is not.

Adds a third catalog — `docs/registries/reviewers.json` →
`docs/registries/reviewer-registry.md` — whose `interactions` describes the
lane: slug, flags, transport, evidenceClass, reviewsSection, requiresBinaries,
configKeys, runtimeCompat.

The lane vocabulary is a hand-written mirror of `capability-validator.cjs`
(the same pattern as `AXES` mirroring `HOST_INTEGRATION_AXES`), with parity
enforced by tests/registry-reviewer-parity.test.cjs. `slug` deliberately uses
the runtime `LANE_SLUG_RE` grammar rather than the registry's kebab-only `id`
rule, so real lanes (`lm_studio`, `4o-mini`) are not rejected.

Two binary type branches became three-way Map dispatch. Both now fail loudly
on an unrecognized type instead of silently treating it as a capability —
`renderMarkdown` in particular writes a committed catalog file, so a silent
wrong-title render was the worst failure mode available.

Also fixed while here: `gen-registry.cjs` parsed source JSON with no error
handling, so a malformed or non-array `capabilities.json` surfaced as a raw
SyntaxError/TypeError instead of an actionable CLI error.

Closes #2904

* fix(#2904): bound and sanitize untrusted registry `interactions` strings

Review findings from the pre-PR passes.

Security (isolated pass): `interactions` string fields reached the generated,
committed Markdown catalog with no control-character check and no length
bound. A `reviewsSection` carrying ESC and a `requiresBinaries` element
carrying NUL plus 5000 characters validated clean and landed verbatim in the
rendered page — `mdInline` escapes Markdown metacharacters and collapses CRLF,
but nothing else. The identical gap already existed on the capability type's
`configKeys`/`requires`/`runtimeCompat`/`produces`/`consumes`, so it is fixed
there too rather than inherited into a third type.

`hasDisallowedControlChar` is lifted to module scope so exactly one
implementation exists, and a shared `validateStringArrayField` enforces
control-character rejection, a 200-character element cap and a 50-element
array cap for both types.

Correctness (standards pass): `renderMarkdown`'s per-entry summary builder was
still an if/else-if chain whose final `else` was the capability branch — the
one per-type dispatch point this change had not converted, and the same silent
fallthrough it removes elsewhere. It now lives in `RENDER_META` alongside the
title, so a fourth type cannot silently inherit capability's rendering. All
three types' rendered output is byte-identical to before the refactor.

Also corrects a test comment that still claimed the reviewer suites were
failing-first against an unmodified module.

* chore(#2904): backfill changeset PR number (#2912)

(cherry picked from commit 90771ddf02)
This commit is contained in:
Tom Boucher
2026-07-31 08:11:57 -04:00
committed by sim
parent f72f70ad39
commit 538cb0fc1d
13 changed files with 1768 additions and 130 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 2912
---
**Third-party reviewer lanes can now be listed in a discoverability catalog.** ADR-2782 made a reviewer lane installable by a third party, but the two existing registries could not hold one — the Community Capability Registry requires a non-empty `loopExtensionPoints`, which a lane registers on none of, and the EoS Registry is for host integrations. A new Reviewer Lane Registry (`docs/registries/reviewers.json` → `docs/registries/reviewer-registry.md`) gives lanes a home, with an entry schema describing the lane itself: slug, flags, transport, evidence class, and REVIEWS.md section. (#2904)

View File

@@ -15,6 +15,7 @@ Full schema and process: [docs/registries/README.md](../../docs/registries/READM
- [ ] Capability Registry entry — adds/updates one object in `docs/registries/capabilities.json`
- [ ] EoS Registry entry — adds/updates one object in `docs/registries/eos.json`
- [ ] Reviewer Lane Registry entry — adds/updates one object in `docs/registries/reviewers.json`
## The entry
@@ -44,6 +45,7 @@ Full schema and process: [docs/registries/README.md](../../docs/registries/READM
- [ ] `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 plus, optionally, `effortSurface` (`argv` / `none`)
- [ ] **(Reviewer entries only)** `interactions.slug` matches the lane slug grammar `^[a-z0-9][a-z0-9_-]*$`, `interactions.flags` is a non-empty array matching `^--[a-z0-9][a-z0-9-]*$`, `interactions.transport` is `spawn` or `openai-http`, `interactions.evidenceClass` is `source-grounded` or `diff-only`, `interactions.reviewsSection` is a non-empty string (max 200 characters), and `interactions.requiresBinaries` / `configKeys` / `runtimeCompat` are present (empty arrays are fine where nothing applies)
## Ownership & non-endorsement
@@ -53,12 +55,12 @@ Full schema and process: [docs/registries/README.md](../../docs/registries/READM
## One entry, one PR
- [ ] This PR adds or updates exactly **one** entry, in exactly one of `capabilities.json` / `eos.json`
- [ ] This PR adds or updates exactly **one** entry, in exactly one of `capabilities.json` / `eos.json` / `reviewers.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 ran `npm run gen:registry` after editing the JSON source, and this PR includes the regenerated `docs/registries/capability-registry.md`, `docs/registries/eos-registry.md`, or `docs/registries/reviewer-registry.md`
- [ ] I did **not** hand-edit the generated `.md` file directly — all edits were made to the JSON source
## Documentation

View File

@@ -230,7 +230,10 @@ Runtime seam (`gsd-core/bin/lib/capability-loader.cjs`, ADR-1244 D2) that compos
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 plus an optional ninth (`effortSurface`), 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.
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 plus an optional ninth (`effortSurface`), 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. One of three third-party discoverability catalogs alongside the **Community Capability Registry** and the **Reviewer Lane Registry**.
### Reviewer Lane Registry
Human-facing discoverability catalog (`docs/registries/reviewer-registry.md`, generated from `docs/registries/reviewers.json`; issue #2904) listing third-party `role: "reviewer"` capabilities (ADR-2782) — reviewer lanes that add an external review lane to `/gsd-review`, installed with `gsd capability install <spec>`. A lane registers on zero Loop Extension Points and owns no artifacts, which is why it cannot be listed on the **Community Capability Registry**: the Capability entry schema's `loopExtensionPoints`/`hookKinds` are unsatisfiable by construction for a lane. Entries are registered by the same docs-PR process, schema conventions, and non-endorsement stance as the other two registries, but enumerate `slug`, `flags`, `transport`, `evidenceClass`, and `reviewsSection` in place of Loop Extension Points and hook kinds. See `docs/registries/README.md` for the full entry schema and disambiguation against the **Community Capability Registry** and **EoS Registry**.
### 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`.

View File

@@ -1,6 +1,6 @@
# GSD Registries: Community Capability Registry & EoS Registry
# GSD Registries: Community Capability Registry, EoS Registry & Reviewer Lane 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**.
Specification, entry schema, and submission process for GSD's three third-party discoverability catalogs — the **GSD Community Capability Registry**, the **GSD EoS Registry**, and the **GSD Reviewer Lane Registry**.
---
@@ -8,7 +8,7 @@ Specification, entry schema, and submission process for GSD's two third-party di
> 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`).
This stance applies identically to every entry in all three registries. It is reproduced verbatim at the top of each generated catalog (`capability-registry.md`, `eos-registry.md`, `reviewer-registry.md`).
## Narrow removal policy
@@ -25,18 +25,19 @@ A registry entry is **never** removed for quality, staleness of a working projec
## What gets listed
Two independent catalogs, sharing one schema shape, one non-endorsement stance, and one submission process:
Three 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.
- **Reviewer Lane Registry** (`docs/registries/reviewer-registry.md`, generated from `docs/registries/reviewers.json`) — third-party **reviewer lanes**: `role: "reviewer"` capabilities (ADR-2782) that add an external review lane to `/gsd-review`, installed with `gsd capability install <spec>`. A lane registers on zero Loop Extension Points and owns no artifacts, which is why it cannot be listed as a Feature Capability.
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.
All three registries are non-endorsing discoverability catalogs (issue #2182, plus #2904 for the Reviewer Lane Registry). None 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", "EoS Registry", and "Reviewer Lane 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.
Every entry is one JSON object in `docs/registries/capabilities.json`, `docs/registries/eos.json`, or `docs/registries/reviewers.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"`)
@@ -166,6 +167,66 @@ Example:
}
```
### Reviewer entries (`reviewers.json`, `type: "reviewer"`)
| 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 `"reviewer"`. |
| `repo` | yes | `owner/repo` on github.com — the author's own repository. |
| `description` | yes | One-paragraph plain-language description of the reviewer lane and what it reviews. |
| `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.8.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` (Reviewer):
| Field | Required | Meaning |
|---|---|---|
| `slug` | yes | Lane identity, matching the manifest's `reviewer.slug`. Must match the runtime lane grammar `^[a-z0-9][a-z0-9_-]*$` — underscores and a leading digit are permitted (`lm_studio`, `4o-mini`), unlike the kebab-only `id` field. |
| `flags` | yes, non-empty | The CLI flags that select the lane, e.g. `["--gemini"]`. Each must match `^--[a-z0-9][a-z0-9-]*$` — flags are kebab even when the slug is snake (`lm_studio` → `--lm-studio`). |
| `transport` | yes | `spawn` or `openai-http`. |
| `evidenceClass` | yes | `source-grounded` or `diff-only`. |
| `reviewsSection` | yes | The `REVIEWS.md` heading the lane renders under (max 200 characters). |
| `requiresBinaries` | yes | External binaries the lane needs (may be empty). |
| `configKeys` | yes | Federated config keys it owns (may be empty). |
| `runtimeCompat` | yes | Array of compatible runtimes; `["all"]` is allowed. |
Example:
```json
{
"id": "acme-review-lane",
"name": "Acme Review Lane",
"type": "reviewer",
"repo": "some-org/gsd-lane-acme",
"description": "Adds an Acme-hosted model as an external reviewer lane for /gsd-review, evaluating diffs against Acme's static-analysis findings.",
"author": "Some Org <hello@some-org.example>",
"license": "MIT",
"enginesGsd": ">=1.8.0",
"install": "gsd capability install https://github.com/some-org/gsd-lane-acme.git#v1.0.0",
"uninstall": "gsd capability remove acme-review-lane",
"interactions": {
"slug": "acme",
"flags": ["--acme"],
"transport": "openai-http",
"evidenceClass": "diff-only",
"reviewsSection": "## Acme Review",
"requiresBinaries": [],
"configKeys": ["acme.api_key"],
"runtimeCompat": ["all"]
},
"discussion": "https://github.com/open-gsd/gsd-core/discussions/1236"
}
```
A `role: "runtime"` capability that also carries a `reviewer` body (a host that is also a reviewer keeps one manifest, ADR-2782 D1) lists under whichever catalog matches its primary install shape — the Reviewer Lane Registry is for lanes that are not install targets in their own right.
---
## Submission process
@@ -173,14 +234,14 @@ Example:
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.
2. **Edit** `docs/registries/capabilities.json` (Capability Registry), `docs/registries/eos.json` (EoS Registry), or `docs/registries/reviewers.json` (Reviewer Lane 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`, `docs/registries/eos-registry.md`, or `docs/registries/reviewer-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.
**The generated `.md` files are GENERATED — never hand-edit them.** `docs/registries/capability-registry.md`, `docs/registries/eos-registry.md`, and `docs/registries/reviewer-registry.md` are produced by `scripts/gen-registry.cjs` from `capabilities.json` / `eos.json` / `reviewers.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.
---
@@ -202,11 +263,11 @@ There is no re-registration on new releases: register once, and your GitHub Rele
## Ranking + comments
Ranking and community feedback live in **GitHub Discussions**, not in the registry markdown. Each merged entry — from either registry — gets exactly one Discussion in the dedicated `EoS Registry` Discussions category:
Ranking and community feedback live in **GitHub Discussions**, not in the registry markdown. Each merged entry — from any of the three registries — gets exactly one Discussion in the dedicated `EoS 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 `EoS Registry` category under this repository's Discussions settings, using the **open-ended discussion** format. 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. Despite its name, the category carries threads for **both** registries — `discussion` is required on Capability entries exactly as it is on EoS entries.
**Operational setup (one-time, per repo):** a repo admin creates the `EoS Registry` category under this repository's Discussions settings, using the **open-ended discussion** format. 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. Despite its name, the category carries threads for **all three** catalogs — `discussion` is required on Capability and Reviewer entries exactly as it is on EoS entries.
**The open-ended format is required, and the choice is not cosmetic.** Because `discussion` is a required field, the thread must exist *before* the entry's PR is opened — and the person opening it is the entry's author, an outside contributor holding neither `maintain` nor `admin` permission on this repository. GitHub's **Announcement** format restricts starting new discussions to those two permission levels, so choosing it blocks every external submission at the first step, while still looking correctly configured to the admin who set it up. **Question/Answer** adds answer-marking, which pins one reply above the rest of a thread — a directory entry has no answer, and the pinning cuts across the upvote **Top** ordering described above. Open-ended is the format this process requires.

View File

@@ -0,0 +1,9 @@
<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/reviewers.json — do not edit by hand; run `npm run gen:registry` -->
# GSD Reviewer Lane 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 reviewer lane, see the [registry README](./README.md)._
_No entries yet — be the first: see [README](./README.md)._

View File

@@ -0,0 +1 @@
[]

View File

@@ -2,10 +2,14 @@
'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.
* scripts/gen-registry.cjs — generates docs/registries/capability-registry.md,
* docs/registries/eos-registry.md, and docs/registries/reviewer-registry.md
* from their corresponding source JSON, via registry-schema.cjs#renderMarkdown.
* Issue #2182 (capability/eos); issue #2904 (reviewer).
*
* eos.json and reviewers.json are both OPTIONAL sources (`SOURCES[].optional`)
* — an absent one is skipped silently. capabilities.json is the primary
* source and is never optional.
*
* NOT to be confused with `scripts/gen-capability-registry.cjs`: that script
* generates the RUNTIME capability manifest consumed by the host at runtime
@@ -34,7 +38,8 @@ 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' },
{ type: 'eos', jsonFile: 'eos.json', mdFile: 'eos-registry.md', optional: true },
{ type: 'reviewer', jsonFile: 'reviewers.json', mdFile: 'reviewer-registry.md', optional: true },
];
/**
@@ -57,14 +62,16 @@ function getRegistriesDir() {
* 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`).
* Optionality is a per-source data flag (`SOURCES[].optional`), not a
* hardcoded type literal: `eos.json` (pre-PR2) and `reviewers.json` (issue
* #2904) are both optional — an absent source JSON returns null and callers
* treat that as "nothing to do". `capabilities.json` is still the primary
* registry source and is never optional: a missing `capabilities.json` is
* ALWAYS an error (never a silent "up to date" pass), mirroring the same
* `optional` flag in `scripts/validate-registry.cjs`
* (`optional && !exists → continue`).
*
* @param {'capability'|'eos'} type
* @param {'capability'|'eos'|'reviewer'} type
* @returns {string|null}
*/
function renderFor(type) {
@@ -73,14 +80,31 @@ function renderFor(type) {
const jsonPath = path.join(getRegistriesDir(), source.jsonFile);
if (!fs.existsSync(jsonPath)) {
if (type === 'eos') return null;
if (source.optional) 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'));
// A malformed source JSON must surface as an actionable CLI error, not an
// unhandled SyntaxError with a raw Node stack trace — mirrors
// scripts/validate-registry.cjs#validateFile's try/catch around JSON.parse.
let entries;
try {
entries = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
} catch (err) {
throw new ExitError(1, `${source.jsonFile} is not valid JSON at ${jsonPath}: ${err.message}`);
}
// Mirrors validate-registry.cjs's explicit non-array rejection: a source
// JSON that parses to a non-array (object, string, etc.) would otherwise
// throw an opaque TypeError from `[...entries].sort()` in renderMarkdown,
// or silently mis-render for an iterable-but-wrong-shape value like a string.
if (!Array.isArray(entries)) {
throw new ExitError(1, `${source.jsonFile} must be a JSON array of entries`);
}
return renderMarkdown(entries, { type, sourceFile: source.jsonFile });
}
@@ -91,7 +115,7 @@ function main() {
for (const { type, mdFile } of SOURCES) {
const rendered = renderFor(type);
if (rendered === null) continue; // source JSON absent (eos.json before PR2)
if (rendered === null) continue; // source JSON absent and optional (eos.json before PR2 / reviewers.json)
const mdPath = path.join(registriesDir, mdFile);

View File

@@ -2,11 +2,12 @@
/**
* scripts/registry-schema.cjs — pure schema/vocab constants + validation +
* markdown-generation logic for the two third-party discoverability catalogs
* (issue #2182):
* markdown-generation logic for the three third-party discoverability catalogs
* (issue #2182, plus #2904):
*
* - `docs/registries/capabilities.json` → "GSD Community Capability Registry"
* - `docs/registries/eos.json` → "GSD EoS Registry" (PR2)
* - `docs/registries/reviewers.json` → "GSD Reviewer Lane Registry" (issue #2904)
*
* The vocabulary constants below are ADDITIVE CONTRACTS that track the
* runtime/ADR closed vocabularies they describe — they are a documentation-
@@ -41,10 +42,14 @@
* (every entry published before the amendment stays valid) or declare
* it as `argv` | `none`, mirroring `HOST_INTEGRATION_AXES.effortSurface`
* in `src/host-integration.cts`.
* - `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`).
* - `CAPABILITY_REQUIRED` / `EOS_REQUIRED` / `REVIEWER_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`).
* - `REVIEWER_LANE_TRANSPORTS` / `REVIEWER_EVIDENCE_CLASSES` /
* `REVIEWER_SLUG_RE` / `REVIEWER_FLAG_RE` / `REVIEWER_SECTION_MAX` mirror
* the ADR-2782 reviewer-lane vocabulary (`capability-validator.cjs`) for
* the `reviewer` entry type's `interactions` sub-object (issue #2904).
*
* This module is pure — no `fs`/`process`/child-process access — so tests
* can `require()` it directly and assert on structured return values.
@@ -107,37 +112,118 @@ const OPTIONAL_AXES = Object.freeze({
effortSurface: Object.freeze(['argv', 'none']),
});
// ─── Required top-level fields ───────────────────────────────────────────────
const CAPABILITY_REQUIRED = Object.freeze([
'id',
'name',
'type',
'repo',
'description',
'author',
'license',
'enginesGsd',
'install',
'uninstall',
'interactions',
'discussion',
]);
// ─── ADR-2782 reviewer-lane vocabulary (issue #2904) ─────────────────────────
// A THIRD catalog: third-party reviewer lanes (`role: "reviewer"`, ADR-2782
// D3). A lane registers on ZERO Loop Extension Points and is forbidden from
// declaring `steps`/`contributions`/`gates`/`skills`/`agents`/`hooks`
// (`FEATURE_FIELDS_FORBIDDEN_ON_REVIEWER`, capability-validator.cjs), so the
// Capability entry's two required `interactions` fields are unsatisfiable by
// construction for a lane — hence its own entry type rather than a relaxation
// of the Capability schema.
//
// These constants are ADDITIVE CONTRACTS mirroring the canonical runtime
// vocabulary in `gsd-core/bin/lib/capability-validator.cjs`, exactly the way
// `AXES` mirrors `HOST_INTEGRATION_AXES`. They are hand-written mirrors, NOT
// imports: this module is documented pure (no `fs`/`process`), and requiring a
// `gsd-core/bin/lib` runtime module from a docs-pipeline script would invert
// that. Parity is enforced instead by `tests/registry-reviewer-parity.test.cjs`.
//
// `REVIEWER_SLUG_RE` deliberately does NOT reuse the registry's kebab-case `id`
// grammar. `LANE_SLUG_RE` permits underscores AND a leading digit —
// `lm_studio`, `llama_cpp`, `4o-mini` are real shipped lane slugs — and
// capability-validator.cjs:807-810 requires the two grammars stay
// byte-identical. A kebab-only rule here would reject well-formed entries and
// leave authors with a schema satisfiable only by lying.
const REVIEWER_LANE_TRANSPORTS = Object.freeze(['spawn', 'openai-http']);
const REVIEWER_EVIDENCE_CLASSES = Object.freeze(['source-grounded', 'diff-only']);
const REVIEWER_SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/;
// Flags are kebab even when the slug is snake: `lm_studio` → `--lm-studio`.
const REVIEWER_FLAG_RE = /^--[a-z0-9][a-z0-9-]*$/;
// Cap for the one free-text reviewer interactions field, mirroring the 300-cap
// on the equivalently free-form `axes.dispatch`. A REVIEWS.md heading is short.
const REVIEWER_SECTION_MAX = 200;
const EOS_REQUIRED = Object.freeze([
'id',
'name',
'type',
'repo',
'description',
'author',
'license',
'enginesGsd',
'install',
'uninstall',
'interactions',
'discussion',
'protocolVersion',
// ─── Required top-level fields ───────────────────────────────────────────────
// The twelve fields every entry type requires. Each type's set is DERIVED from
// this one so a future shared field cannot be added to one type's list and
// silently forgotten in another (DEFECT.GENERATIVE-FIX). The three sets are
// distinct frozen arrays, not aliases, so a type may still diverge deliberately
// — as `eos` already does with `protocolVersion`.
const BASE_REQUIRED = Object.freeze([
'id', 'name', 'type', 'repo', 'description', 'author', 'license',
'enginesGsd', 'install', 'uninstall', 'interactions', 'discussion',
]);
const CAPABILITY_REQUIRED = Object.freeze([...BASE_REQUIRED]);
const EOS_REQUIRED = Object.freeze([...BASE_REQUIRED, 'protocolVersion']);
// A lane is installed with `gsd capability install`, owns a repo, a license and
// an `engines.gsd` range exactly as a Feature Capability does — so it requires
// the same twelve top-level fields. Only `interactions` differs.
const REVIEWER_REQUIRED = Object.freeze([...BASE_REQUIRED]);
// 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. Module-scope so both the top-level field
// checks inside `validateEntries` and the `interactions` sub-object
// validators (module-level functions, outside that closure) share the ONE
// implementation rather than each keeping their own copy.
function 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;
}
// Caps for `interactions` array-of-strings fields (configKeys, requires,
// runtimeCompat, produces, consumes, requiresBinaries, ...). These bound
// UNTRUSTED third-party strings that are rendered verbatim (after mdInline
// escaping) into a committed Markdown catalog — an unbounded count or length
// lets a malicious registry PR blow up the generated doc.
const INTERACTION_STRING_MAX = 200;
const INTERACTION_ARRAY_MAX = 50;
/**
* Validate an interactions field that is an array of free-form untrusted
* strings: shape, element count, per-element length, and control characters.
* `allowEmpty` distinguishes "may be empty" fields from non-empty-required
* ones — non-empty-required fields' blank-array message is expected to be
* handled by the caller (this helper does not special-case emptiness itself
* beyond letting an empty array with `allowEmpty: true` through).
*
* @param {object} interactions
* @param {string} field
* @param {(field: string, reason: string) => void} addError
* @param {{allowEmpty?: boolean}} [opts]
* @returns {void}
*/
function validateStringArrayField(interactions, field, addError, { allowEmpty = true } = {}) {
const v = interactions[field];
const qualifiedField = `interactions.${field}`;
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
addError(qualifiedField, 'must be an array of strings');
return;
}
if (!allowEmpty && v.length === 0) return;
if (v.length > INTERACTION_ARRAY_MAX) {
addError(qualifiedField, `exceeds max entries ${INTERACTION_ARRAY_MAX}`);
}
for (const x of v) {
if (x.length > INTERACTION_STRING_MAX) {
addError(qualifiedField, `exceeds max length ${INTERACTION_STRING_MAX}`);
} else if (hasDisallowedControlChar(x, false)) {
addError(qualifiedField, 'must not contain control characters');
}
}
}
// Escape Markdown inline metacharacters in UNTRUSTED free text so a registry
// entry cannot inject links/tables/code-spans into the generated catalog.
@@ -221,10 +307,7 @@ function validateCapabilityInteractions(interactions, addError) {
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');
}
validateStringArrayField(interactions, field, addError);
}
}
@@ -312,12 +395,93 @@ function validateEosInteractions(interactions, addError) {
}
}
/**
* Validate the `interactions` sub-object for a reviewer entry (ADR-2782 D3
* lane vocabulary — issue #2904).
*
* @param {object} interactions
* @param {(field: string, reason: string) => void} addError
* @returns {void}
*/
function validateReviewerInteractions(interactions, addError) {
const allowedKeys = new Set([
'slug',
'flags',
'transport',
'evidenceClass',
'reviewsSection',
'requiresBinaries',
'configKeys',
'runtimeCompat',
]);
for (const key of Object.keys(interactions)) {
if (!allowedKeys.has(key)) addError(`interactions.${key}`, 'unknown field');
}
for (const field of allowedKeys) {
if (interactions[field] === undefined) addError(`interactions.${field}`, 'missing required field');
}
if (interactions.slug !== undefined) {
const v = interactions.slug;
if (typeof v !== 'string' || !REVIEWER_SLUG_RE.test(v)) {
addError('interactions.slug', 'must match the reviewer lane slug grammar');
}
}
if (interactions.flags !== undefined) {
const v = interactions.flags;
if (!Array.isArray(v) || v.length === 0 || !v.every((x) => typeof x === 'string' && REVIEWER_FLAG_RE.test(x))) {
addError('interactions.flags', 'must be a non-empty array of lane CLI flags');
}
}
if (interactions.transport !== undefined) {
const v = interactions.transport;
if (typeof v !== 'string' || !REVIEWER_LANE_TRANSPORTS.includes(v)) {
addError('interactions.transport', 'must be one of the allowed lane transports');
}
}
if (interactions.evidenceClass !== undefined) {
const v = interactions.evidenceClass;
if (typeof v !== 'string' || !REVIEWER_EVIDENCE_CLASSES.includes(v)) {
addError('interactions.evidenceClass', 'must be one of the allowed evidence classes');
}
}
if (interactions.reviewsSection !== undefined) {
const v = interactions.reviewsSection;
if (typeof v !== 'string' || v.trim() === '') {
addError('interactions.reviewsSection', 'must be a non-empty string');
} else if (v.length > REVIEWER_SECTION_MAX) {
addError('interactions.reviewsSection', `exceeds max length ${REVIEWER_SECTION_MAX}`);
} else if (hasDisallowedControlChar(v, false)) {
addError('interactions.reviewsSection', 'must not contain control characters');
}
}
for (const field of ['requiresBinaries', 'configKeys', 'runtimeCompat']) {
if (interactions[field] === undefined) continue;
validateStringArrayField(interactions, field, addError);
}
}
// Per-type rules. A Map (not a plain object) so the lookup below is not a
// bracket-read on a caller-supplied key — that shape reads as a
// prototype-pollution sink to CodeQL, and a Map.get does not.
const TYPE_RULES = new Map([
['capability', { required: CAPABILITY_REQUIRED, validateInteractions: validateCapabilityInteractions }],
['eos', { required: EOS_REQUIRED, validateInteractions: validateEosInteractions }],
['reviewer', { required: REVIEWER_REQUIRED, validateInteractions: validateReviewerInteractions }],
]);
/**
* Validate an array of registry entries against the closed schema for
* `opts.type` ('capability' | 'eos').
* `opts.type` ('capability' | 'eos' | 'reviewer').
*
* @param {object[]} entries
* @param {{type: 'capability'|'eos'}} opts
* @param {{type: 'capability'|'eos'|'reviewer'}} opts
* @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
*/
function validateEntries(entries, opts) {
@@ -325,13 +489,22 @@ function validateEntries(entries, opts) {
return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'entries must be an array' }] };
}
// An unrecognized type is a hard error, not a silent fallthrough. Before the
// third type existed this was a binary ternary whose ELSE branch was
// `capability`, so a typo'd type validated against the wrong schema and
// reported plausible-looking per-entry errors.
const rules = TYPE_RULES.get(opts.type);
if (!rules) {
return { ok: false, errors: [{ index: -1, field: '(root)', reason: `unknown registry type "${opts.type}"` }] };
}
// 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 required = rules.required;
const requiredSet = new Set(required);
const seenIds = new Set();
const errors = [];
@@ -363,21 +536,9 @@ function validateEntries(entries, opts) {
}
}
// 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;
};
// Control-character rejection (defense in depth) — delegates to the
// module-scope `hasDisallowedControlChar` (shared with the `interactions`
// sub-object validators below) so there is exactly one implementation.
const checkNoControlChars = (field, allowTabNewline) => {
if (missing.has(field)) return;
const v = entry[field];
@@ -460,10 +621,8 @@ function validateEntries(entries, opts) {
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);
rules.validateInteractions(interactions, addError);
}
}
@@ -477,12 +636,92 @@ function validateEntries(entries, opts) {
return { ok: errors.length === 0, errors };
}
// Per-type page presentation AND per-type interaction summary both live in
// this ONE table (Map, for the same CodeQL reason as TYPE_RULES): title/
// addNoun drive the page header, buildSummary drives the per-entry "Every
// interaction with GSD" line. Folding both into a single lookup means a
// future fourth registry type MUST supply its own buildSummary or the
// `RENDER_META.get` miss below throws — it cannot silently inherit
// capability's (or any other type's) rendering the way the old if/else-if/
// else chain's final `else` branch used to.
const RENDER_META = new Map([
[
'capability',
{
title: 'GSD Community Capability Registry',
addNoun: 'capability',
buildSummary(entry, interactions) {
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.
return summary;
},
},
],
[
'eos',
{
title: 'GSD EoS Registry',
addNoun: 'integration',
buildSummary(entry, interactions) {
// Required AXES keys always render, in their fixed order; an OPTIONAL_AXES
// key (e.g. `effortSurface`) renders ONLY when the entry actually carries
// it — an entry that omits it must render byte-identical to before
// OPTIONAL_AXES existed (no `effortSurface=undefined` noise).
const presentOptionalKeys = Object.keys(OPTIONAL_AXES).filter(
(key) => interactions.axes && Object.hasOwn(interactions.axes, key),
);
const axesSummary = [...Object.keys(AXES), ...presentOptionalKeys]
.map((key) => `${key}=${interactions.axes ? interactions.axes[key] : undefined}`)
.join(', ');
return (
`Interface points: ${(interactions.interfacePoints || []).join(', ')}; ` +
`profile: ${interactions.profile}; protocol v${entry.protocolVersion}; axes: ${axesSummary}`
);
},
},
],
[
'reviewer',
{
title: 'GSD Reviewer Lane Registry',
addNoun: 'reviewer lane',
buildSummary(entry, interactions) {
let summary =
`Lane: ${interactions.slug}; ` +
`flags: ${(interactions.flags || []).join(', ')}; ` +
`transport: ${interactions.transport}; ` +
`evidence: ${interactions.evidenceClass}; ` +
`REVIEWS.md section: ${interactions.reviewsSection}`;
for (const field of ['requiresBinaries', 'configKeys', 'runtimeCompat']) {
const v = interactions[field];
if (Array.isArray(v) && v.length > 0) summary += `; ${field}: ${v.join(', ')}`;
}
// slug/flags/transport are vocab-constrained; reviewsSection and the
// three arrays are untrusted free text — same single-pass mdInline
// rationale as the eos/capability branches above: none of the literal
// separator text contains Markdown metacharacters, so one pass over the
// assembled summary neutralizes every embedded value.
return summary;
},
},
],
]);
/**
* Render the deterministic Markdown document for a registry.
*
* @param {object[]} entries
* @param {{type: 'capability'|'eos', sourceFile?: string}} opts
* @param {{type: 'capability'|'eos'|'reviewer', sourceFile?: string}} opts
* @returns {string}
* @throws {Error} when opts.type is not a known registry type
*/
function renderMarkdown(entries, opts) {
const sorted = [...entries].sort((a, b) => {
@@ -491,19 +730,27 @@ function renderMarkdown(entries, opts) {
return 0;
});
const isEos = opts.type === 'eos';
// An unrecognized type must fail loudly rather than silently render a
// "GSD Community Capability Registry" page — mirroring the validateEntries
// unknown-type guard above. This function writes a COMMITTED catalog file,
// so a silent wrong-title render is the worst failure mode available.
// Message shape mirrors gen-registry.cjs#renderFor's existing
// `gen-registry: unknown registry type "..."` throw.
const meta = RENDER_META.get(opts.type);
if (!meta) throw new Error(`registry-schema: unknown registry type "${opts.type}"`);
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(`# ${meta.title}`);
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(`_To add your ${meta.addNoun}, see the [registry README](./README.md)._`);
lines.push('');
if (sorted.length === 0) {
@@ -536,38 +783,12 @@ function renderMarkdown(entries, opts) {
lines.push(`- **What it is:** ${mdInline(entry.description)}`);
lines.push(`- **Author:** ${mdInline(entry.author)}`);
if (isEos) {
// Required AXES keys always render, in their fixed order; an OPTIONAL_AXES
// key (e.g. `effortSurface`) renders ONLY when the entry actually carries
// it — an entry that omits it must render byte-identical to before
// OPTIONAL_AXES existed (no `effortSurface=undefined` noise).
const presentOptionalKeys = Object.keys(OPTIONAL_AXES).filter(
(key) => interactions.axes && Object.hasOwn(interactions.axes, key),
);
const axesSummary = [...Object.keys(AXES), ...presentOptionalKeys]
.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)}`);
}
// Single mdInline pass over the fully-assembled per-type summary: none of
// the literal separator text in any RENDER_META buildSummary implementation
// contains Markdown metacharacters, so one pass over the assembled string
// equally neutralizes every embedded free-text/vocab value (notably eos's
// interactions.axes.dispatch, a free-form untrusted string).
lines.push(`- **Every interaction with GSD:** ${mdInline(meta.buildSummary(entry, interactions))}`);
// Code-span content (install/uninstall) is NOT mdInline-escaped — it is a
// verbatim shell snippet, not inline prose. Instead each block picks a
@@ -608,6 +829,14 @@ module.exports = {
AXES_FREE_STRING,
CAPABILITY_REQUIRED,
EOS_REQUIRED,
REVIEWER_REQUIRED,
REVIEWER_LANE_TRANSPORTS,
REVIEWER_EVIDENCE_CLASSES,
REVIEWER_SLUG_RE,
REVIEWER_FLAG_RE,
REVIEWER_SECTION_MAX,
INTERACTION_STRING_MAX,
INTERACTION_ARRAY_MAX,
isValidGsdRange,
validateEntries,
renderMarkdown,

View File

@@ -3,11 +3,13 @@
/**
* scripts/validate-registry.cjs — CLI validator for the third-party
* discoverability catalogs (issue #2182):
* discoverability catalogs (issue #2182, plus #2904):
*
* - docs/registries/capabilities.json ("GSD Community Capability Registry")
* - docs/registries/eos.json ("GSD EoS Registry", PR2 — optional
* until that JSON file ships)
* - docs/registries/reviewers.json ("GSD Reviewer Lane Registry",
* issue #2904 — 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
@@ -33,14 +35,15 @@ const { validateEntries } = require('./registry-schema.cjs');
// a subprocess against isolated temp-fixture directories via `cwd`.
const SOURCES = [
{ file: 'capabilities.json', type: 'capability' },
{ file: 'eos.json', type: 'eos' },
{ file: 'eos.json', type: 'eos', optional: true },
{ file: 'reviewers.json', type: 'reviewer', optional: true },
];
/**
* Load + validate a single registry JSON file.
*
* @param {string} jsonPath absolute path to the registry JSON file
* @param {'capability'|'eos'} type
* @param {'capability'|'eos'|'reviewer'} type
* @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
*/
function validateFile(jsonPath, type) {
@@ -81,10 +84,11 @@ function main() {
const results = [];
let anyFailed = false;
for (const { file, type } of SOURCES) {
for (const { file, type, optional } 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;
// eos.json (pre-PR2) and reviewers.json (issue #2904) are optional until
// their source JSON ships — skip silently when absent.
if (optional && !fs.existsSync(jsonPath)) continue;
const verdict = validateFile(jsonPath, type);
results.push({ file, type, ok: verdict.ok, errors: verdict.errors });

View File

@@ -41,6 +41,63 @@ function validCapabilityEntry() {
};
}
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',
};
}
function validReviewerEntry() {
return {
id: 'my-reviewer',
name: 'My Reviewer',
type: 'reviewer',
repo: 'octocat/my-reviewer',
description: 'Reviews GSD PRs for a specific concern.',
author: 'Octocat',
license: 'MIT',
enginesGsd: '>=1.6.0 <3.0.0',
install: 'gsd capability install https://github.com/octocat/my-reviewer.git#v1.0.0',
uninstall: 'gsd capability remove my-reviewer',
interactions: {
slug: 'my-reviewer',
flags: ['--my-reviewer'],
transport: 'spawn',
evidenceClass: 'source-grounded',
reviewsSection: 'My Reviewer',
requiresBinaries: [],
configKeys: [],
runtimeCompat: ['all'],
},
discussion: 'https://github.com/octocat/my-reviewer/discussions/1',
};
}
function withFixture(entries, fn) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gen-registry-'));
try {
@@ -53,6 +110,22 @@ function withFixture(entries, fn) {
}
}
// Same as withFixture, but also writes docs/registries/reviewers.json — used
// by the reviewer-catalog cases below (#2904), which need capabilities.json
// AND reviewers.json present simultaneously.
function withReviewerFixture(capabilityEntries, reviewerEntries, fn) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gen-registry-reviewer-'));
try {
const registriesDir = path.join(tmp, 'docs', 'registries');
fs.mkdirSync(registriesDir, { recursive: true });
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify(capabilityEntries, null, 2) + '\n');
fs.writeFileSync(path.join(registriesDir, 'reviewers.json'), JSON.stringify(reviewerEntries, null, 2) + '\n');
fn(tmp, registriesDir);
} finally {
cleanup(tmp);
}
}
function runGen(cwd, args = []) {
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { cwd, encoding: 'utf8' });
}
@@ -142,3 +215,120 @@ describe('gen-registry: renderMarkdown (direct, via registry-schema)', () => {
assert.ok(rendered.includes(entry.discussion));
});
});
// ─── gen-registry CLI (subprocess): reviewer catalog (#2904) ───────────────
//
// scripts/gen-registry.cjs's SOURCES array does not yet include the reviewer
// { type:'reviewer', jsonFile:'reviewers.json', mdFile:'reviewer-registry.md',
// optional:true } entry — every case below is FAILING-FIRST against the
// unmodified script. See
// .gsd/phase/feat-2904-enh-registries-add-a-reviewer-entry-type/50-test-matrix.md.
describe('gen-registry CLI (subprocess): reviewer catalog', () => {
test('--write emits all three catalogs when all sources exist', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gen-registry-all3-'));
try {
const registriesDir = path.join(tmp, 'docs', 'registries');
fs.mkdirSync(registriesDir, { recursive: true });
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify([validCapabilityEntry()], null, 2) + '\n');
fs.writeFileSync(path.join(registriesDir, 'eos.json'), JSON.stringify([validEosEntry()], null, 2) + '\n');
fs.writeFileSync(path.join(registriesDir, 'reviewers.json'), JSON.stringify([validReviewerEntry()], null, 2) + '\n');
const write = runGen(tmp, ['--write']);
assert.equal(write.status, 0, `stderr: ${write.stderr}`);
assert.ok(fs.existsSync(path.join(registriesDir, 'capability-registry.md')));
assert.ok(fs.existsSync(path.join(registriesDir, 'eos-registry.md')));
assert.ok(
fs.existsSync(path.join(registriesDir, 'reviewer-registry.md')),
'expected reviewer-registry.md to be written',
);
} finally {
cleanup(tmp);
}
});
test('an absent reviewers.json is skipped, not an error', () => {
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')));
assert.ok(
!fs.existsSync(path.join(registriesDir, 'reviewer-registry.md')),
'expected no reviewer-registry.md when reviewers.json is absent',
);
});
});
test('--check fails when the reviewer catalog is missing', () => {
withReviewerFixture([validCapabilityEntry()], [validReviewerEntry()], (tmp, registriesDir) => {
// Write only the capability md by hand (simulating a repo that has
// reviewers.json committed but never ran --write for it).
fs.writeFileSync(
path.join(registriesDir, 'capability-registry.md'),
renderMarkdown([validCapabilityEntry()], { type: 'capability', sourceFile: 'capabilities.json' }),
);
const check = runGen(tmp, ['--check']);
assert.notEqual(check.status, 0, `expected non-zero exit, got 0. stdout: ${check.stdout}`);
assert.match(check.stderr, /reviewer-registry\.md does not exist/);
});
});
test('--check fails on reviewer catalog drift', () => {
withReviewerFixture([validCapabilityEntry()], [validReviewerEntry()], (tmp, registriesDir) => {
const write = runGen(tmp, ['--write']);
assert.equal(write.status, 0, `stderr: ${write.stderr}`);
const mdPath = path.join(registriesDir, 'reviewer-registry.md');
fs.appendFileSync(mdPath, '\nhand-edited drift line\n');
const check = runGen(tmp, ['--check']);
assert.notEqual(check.status, 0);
assert.match(check.stderr, /reviewer-registry\.md is stale/);
});
});
test('--check passes on a fresh reviewer catalog', () => {
withReviewerFixture([validCapabilityEntry()], [validReviewerEntry()], (tmp) => {
const write = runGen(tmp, ['--write']);
assert.equal(write.status, 0, `stderr: ${write.stderr}`);
const check = runGen(tmp, ['--check']);
assert.equal(check.status, 0, `stderr: ${check.stderr}`);
});
});
test('CRLF in the committed reviewer catalog is not drift', () => {
withReviewerFixture([validCapabilityEntry()], [validReviewerEntry()], (tmp, registriesDir) => {
const write = runGen(tmp, ['--write']);
assert.equal(write.status, 0, `stderr: ${write.stderr}`);
const mdPath = path.join(registriesDir, 'reviewer-registry.md');
const original = fs.readFileSync(mdPath, 'utf8');
// Normalize via \r?\n so the conversion is idempotent, ensuring the fixture is exactly
// the CRLF variant even on a checkout that already delivered CRLF line endings.
fs.writeFileSync(mdPath, original.replace(/\r?\n/g, '\r\n'));
const check = runGen(tmp, ['--check']);
assert.equal(check.status, 0, `expected CRLF-only diff to not be drift. stderr: ${check.stderr}`);
});
});
test('malformed reviewers.json fails cleanly', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gen-registry-badjson-'));
try {
const registriesDir = path.join(tmp, 'docs', 'registries');
fs.mkdirSync(registriesDir, { recursive: true });
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify([validCapabilityEntry()], null, 2) + '\n');
fs.writeFileSync(path.join(registriesDir, 'reviewers.json'), '{ this is not valid JSON');
const result = runGen(tmp, ['--write']);
assert.notEqual(result.status, 0, `expected non-zero exit, got 0. stdout: ${result.stdout}`);
assert.ok(
!/at Object\.<anonymous>/.test(result.stderr) && !/\.js:\d+:\d+/.test(result.stderr),
`expected no raw Node stack trace leaked to stderr, got: ${result.stderr}`,
);
} finally {
cleanup(tmp);
}
});
});

View File

@@ -0,0 +1,153 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* tests/registry-reviewer-parity.test.cjs — regression coverage for issue
* #2904 ("reviewer" registry entry type).
*
* `scripts/registry-schema.cjs`'s reviewer vocabulary
* (`REVIEWER_LANE_TRANSPORTS`, `REVIEWER_EVIDENCE_CLASSES`,
* `REVIEWER_SLUG_RE`, `REVIEWER_FLAG_RE`) and
* `gsd-core/bin/lib/capability-validator.cjs`'s runtime reviewer-lane
* vocabulary (`VALID_LANE_TRANSPORTS`, `VALID_EVIDENCE_CLASSES`,
* `LANE_SLUG_RE`, `LANE_FLAG_RE`) are two independent, hand-written mirrors
* of the same underlying grammar — the registry is a third-party
* DISCOVERABILITY catalog (documentation-scoped), the capability-validator
* is the RUNTIME manifest validator that actually gates what a shipped
* `capabilities/<id>/capability.json`'s `reviewer` body may declare. Nothing
* imports one from the other (capability-validator.cjs's own header comment,
* `gsd-core/bin/lib/capability-validator.cjs:798-810`, explains why: the
* canonical descriptor `LANE_SLUG_RE` mirrors lives in
* `src/review-lane-descriptor.cts`, which compiles to gitignored build
* output that this committed plain `.cjs` cannot depend on before
* `npm run build:lib` has ever run) — so the two vocabularies can silently
* drift apart with no error to read: a registry entry that faithfully
* mirrors a real shipped lane (e.g. `lm_studio`, `llama_cpp`, `4o-mini` —
* all real slugs that a naive kebab-only grammar would reject) would look
* "strict but simply wrong" if the registry's copy of the slug grammar ever
* diverged from `LANE_SLUG_RE`.
*
* `capability-validator.cjs:807-810` states the byte-identical requirement
* explicitly: "A LEADING DIGIT IS PERMITTED. ... Keep the two grammars
* byte-identical." This file is that parity guard for the reviewer registry
* entry type, sibling in structure/intent to
* `tests/registry-axes-parity.test.cjs` (which pins `AXES`/`OPTIONAL_AXES`
* against `HOST_INTEGRATION_AXES`).
*
* Row 75 additionally reads every real, shipped `capabilities/<id>/capability.json`
* and asserts each one's `reviewer.slug` (where present) validates against
* `REVIEWER_SLUG_RE` — a reality check that the registry grammar isn't just
* parity-pinned against `capability-validator.cjs` in the abstract, but
* actually accepts every lane slug the repository ships today. This reads
* JSON DATA files (not source), so it does not trip `local/no-source-grep`
* and is not a source-grep-in-disguise — no `.cjs`/`.js`/`.ts` source file is
* ever `readFileSync`'d and string-matched in this file.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
REVIEWER_LANE_TRANSPORTS,
REVIEWER_EVIDENCE_CLASSES,
REVIEWER_SLUG_RE,
REVIEWER_FLAG_RE,
} = require(path.join(__dirname, '..', 'scripts', 'registry-schema.cjs'));
const {
VALID_LANE_TRANSPORTS,
VALID_EVIDENCE_CLASSES,
LANE_SLUG_RE,
LANE_FLAG_RE,
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-validator.cjs'));
// ─── Parity: transport / evidence-class vocabularies ───────────────────────
describe('registry-reviewer-parity: vocab set-equality vs capability-validator.cjs', () => {
test('registry transport vocab matches capability-validator', () => {
const registrySet = new Set(REVIEWER_LANE_TRANSPORTS);
assert.ok(registrySet.size > 0, 'expected REVIEWER_LANE_TRANSPORTS to be non-empty');
assert.ok(VALID_LANE_TRANSPORTS.size > 0, 'expected VALID_LANE_TRANSPORTS to be non-empty');
assert.deepEqual(
[...registrySet].sort(),
[...VALID_LANE_TRANSPORTS].sort(),
'REVIEWER_LANE_TRANSPORTS must be set-equal to capability-validator.cjs VALID_LANE_TRANSPORTS',
);
});
test('registry evidence-class vocab matches capability-validator', () => {
const registrySet = new Set(REVIEWER_EVIDENCE_CLASSES);
assert.ok(registrySet.size > 0, 'expected REVIEWER_EVIDENCE_CLASSES to be non-empty');
assert.ok(VALID_EVIDENCE_CLASSES.size > 0, 'expected VALID_EVIDENCE_CLASSES to be non-empty');
assert.deepEqual(
[...registrySet].sort(),
[...VALID_EVIDENCE_CLASSES].sort(),
'REVIEWER_EVIDENCE_CLASSES must be set-equal to capability-validator.cjs VALID_EVIDENCE_CLASSES',
);
});
});
// ─── Parity: slug / flag grammar — byte-identical regexes ──────────────────
describe('registry-reviewer-parity: grammar regexes are byte-identical to capability-validator.cjs', () => {
test('registry slug grammar is byte-identical to LANE_SLUG_RE', () => {
assert.equal(
REVIEWER_SLUG_RE.source,
LANE_SLUG_RE.source,
'REVIEWER_SLUG_RE.source must equal LANE_SLUG_RE.source — "keep the two grammars byte-identical" (capability-validator.cjs:807-810)',
);
assert.equal(
REVIEWER_SLUG_RE.flags,
LANE_SLUG_RE.flags,
'REVIEWER_SLUG_RE.flags must equal LANE_SLUG_RE.flags',
);
});
test('registry flag grammar is byte-identical to LANE_FLAG_RE', () => {
assert.equal(
REVIEWER_FLAG_RE.source,
LANE_FLAG_RE.source,
'REVIEWER_FLAG_RE.source must equal LANE_FLAG_RE.source',
);
assert.equal(
REVIEWER_FLAG_RE.flags,
LANE_FLAG_RE.flags,
'REVIEWER_FLAG_RE.flags must equal LANE_FLAG_RE.flags',
);
});
});
// ─── Reality check: every shipped first-party lane slug validates ─────────
describe('registry-reviewer-parity: every first-party lane slug is accepted by the registry schema', () => {
test('every first-party lane slug is accepted by the registry schema', () => {
const capabilitiesDir = path.join(__dirname, '..', 'capabilities');
const capabilityDirs = fs.readdirSync(capabilitiesDir, { withFileTypes: true }).filter((d) => d.isDirectory());
const collectedSlugs = [];
for (const dirent of capabilityDirs) {
const capabilityJsonPath = path.join(capabilitiesDir, dirent.name, 'capability.json');
if (!fs.existsSync(capabilityJsonPath)) continue;
const data = JSON.parse(fs.readFileSync(capabilityJsonPath, 'utf8'));
if (data && typeof data === 'object' && data.reviewer && typeof data.reviewer.slug === 'string') {
collectedSlugs.push({ id: dirent.name, slug: data.reviewer.slug });
}
}
// Sanity: the collected set must be non-empty, or the loop below would
// pass vacuously — a glob that silently matched nothing must fail loudly.
assert.ok(
collectedSlugs.length > 0,
'expected at least one capabilities/*/capability.json with a reviewer.slug — found none',
);
for (const { id, slug } of collectedSlugs) {
assert.ok(
REVIEWER_SLUG_RE.test(slug),
`expected capabilities/${id}/capability.json reviewer.slug "${slug}" to match REVIEWER_SLUG_RE (${REVIEWER_SLUG_RE})`,
);
}
});
});

View File

@@ -15,6 +15,12 @@ const {
AXES_FREE_STRING,
CAPABILITY_REQUIRED,
EOS_REQUIRED,
REVIEWER_REQUIRED,
REVIEWER_LANE_TRANSPORTS,
REVIEWER_EVIDENCE_CLASSES,
REVIEWER_SECTION_MAX,
INTERACTION_STRING_MAX,
INTERACTION_ARRAY_MAX,
isValidGsdRange,
validateEntries,
renderMarkdown,
@@ -78,6 +84,32 @@ function validEosEntry() {
};
}
function validReviewerEntry() {
return {
id: 'my-reviewer',
name: 'My Reviewer',
type: 'reviewer',
repo: 'octocat/my-reviewer',
description: 'Reviews GSD PRs for a specific concern.',
author: 'Octocat',
license: 'MIT',
enginesGsd: '>=1.6.0 <3.0.0',
install: 'gsd capability install https://github.com/octocat/my-reviewer.git#v1.0.0',
uninstall: 'gsd capability remove my-reviewer',
interactions: {
slug: 'my-reviewer',
flags: ['--my-reviewer'],
transport: 'spawn',
evidenceClass: 'source-grounded',
reviewsSection: 'My Reviewer',
requiresBinaries: [],
configKeys: [],
runtimeCompat: ['all'],
},
discussion: 'https://github.com/octocat/my-reviewer/discussions/1',
};
}
// ─── Vocabulary constants ───────────────────────────────────────────────────
describe('registry-schema: closed vocabulary constants', () => {
@@ -349,6 +381,18 @@ describe('renderMarkdown', () => {
const rendered = renderMarkdown([], { type: 'capability', sourceFile: 'capabilities.json' });
assert.match(rendered, /No entries yet/);
});
test('an unknown registry type throws rather than silently rendering the capability page', () => {
assert.throws(
() => renderMarkdown([], { type: 'bogus-type', sourceFile: 'x.json' }),
{ message: /bogus-type/ },
);
});
test('a recognized type still renders the capability page (guard is not unconditional)', () => {
const rendered = renderMarkdown([], { type: 'capability', sourceFile: 'capabilities.json' });
assert.equal(rendered.split('\n')[2], '# GSD Community Capability Registry');
});
});
// ─── isValidGsdRange ────────────────────────────────────────────────────────
@@ -627,3 +671,728 @@ describe('validateEntries: tightened discussion/license regexes', () => {
assert.ok(!verdict.errors.some((e) => e.field === 'license'));
});
});
// ─── reviewer entry type (#2904) ────────────────────────────────────────────
//
// The describe blocks below cover the `reviewer` entry type: the
// REVIEWER_REQUIRED / REVIEWER_LANE_TRANSPORTS / REVIEWER_EVIDENCE_CLASSES /
// REVIEWER_SECTION_MAX vocabulary constants, `interactions` validation, and
// renderMarkdown's `type: 'reviewer'` output. They were authored FAILING-FIRST
// against the unmodified module, ahead of the implementation. See
// .gsd/phase/feat-2904-enh-registries-add-a-reviewer-entry-type/50-test-matrix.md.
describe('registry-schema: reviewer vocabulary constants', () => {
test('REVIEWER_REQUIRED lists the 12 required reviewer entry fields', () => {
assert.deepEqual(REVIEWER_REQUIRED, [
'id', 'name', 'type', 'repo', 'description', 'author', 'license',
'enginesGsd', 'install', 'uninstall', 'interactions', 'discussion',
]);
});
test('reviewer vocab constants are non-empty frozen arrays', () => {
assert.deepEqual(REVIEWER_LANE_TRANSPORTS, ['spawn', 'openai-http']);
assert.deepEqual(REVIEWER_EVIDENCE_CLASSES, ['source-grounded', 'diff-only']);
assert.ok(Object.isFrozen(REVIEWER_LANE_TRANSPORTS), 'expected REVIEWER_LANE_TRANSPORTS to be frozen');
assert.ok(Object.isFrozen(REVIEWER_EVIDENCE_CLASSES), 'expected REVIEWER_EVIDENCE_CLASSES to be frozen');
assert.equal(REVIEWER_SECTION_MAX, 200);
});
});
describe('validateEntries: reviewer — happy path', () => {
test('a fully-valid reviewer entry passes', () => {
const verdict = validateEntries([validReviewerEntry()], { type: 'reviewer' });
assert.equal(verdict.ok, true);
assert.deepEqual(verdict.errors, []);
});
test('an empty reviewer array passes', () => {
const verdict = validateEntries([], { type: 'reviewer' });
assert.equal(verdict.ok, true);
assert.deepEqual(verdict.errors, []);
});
});
describe('validateEntries: reviewer — type dispatch', () => {
test('an unknown opts.type is a root error, not a silent capability validation', () => {
const verdict = validateEntries([validReviewerEntry()], { type: 'typo' });
assert.equal(verdict.ok, false);
assert.deepEqual(verdict.errors, [{ index: -1, field: '(root)', reason: 'unknown registry type "typo"' }]);
});
test('a capability-typed entry in the reviewer catalog fails', () => {
const entry = validReviewerEntry();
entry.type = 'capability';
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'type');
assert.ok(err, `expected a type error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, 'type must be "reviewer"');
});
test('an eos-only top-level field is rejected on a reviewer entry', () => {
const entry = validReviewerEntry();
entry.protocolVersion = 1;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'protocolVersion');
assert.ok(err, `expected a protocolVersion error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, 'unknown field');
});
test('a non-array under a valid type still reports the array error', () => {
const verdict = validateEntries(null, { type: 'reviewer' });
assert.equal(verdict.ok, false);
assert.deepEqual(verdict.errors, [{ index: -1, field: '(root)', reason: 'entries must be an array' }]);
});
});
describe('validateEntries: reviewer — interactions required-key sweep', () => {
const REVIEWER_INTERACTIONS_KEYS = [
'slug', 'flags', 'transport', 'evidenceClass', 'reviewsSection',
'requiresBinaries', 'configKeys', 'runtimeCompat',
];
for (const key of REVIEWER_INTERACTIONS_KEYS) {
test(`interactions.${key} is individually required`, () => {
const entry = validReviewerEntry();
delete entry.interactions[key];
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === `interactions.${key}`);
assert.ok(err, `expected interactions.${key} error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, 'missing required field');
});
}
test('multiple missing interactions keys each report once', () => {
const entry = validReviewerEntry();
delete entry.interactions.slug;
delete entry.interactions.flags;
delete entry.interactions.transport;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
const missingErrors = verdict.errors.filter((e) => e.reason === 'missing required field');
assert.equal(missingErrors.length, 3, `expected exactly 3 missing-field errors, got: ${JSON.stringify(verdict.errors)}`);
assert.deepEqual(
missingErrors.map((e) => e.field).sort(),
['interactions.flags', 'interactions.slug', 'interactions.transport'],
);
});
test('an absent interactions object reports once, not nine times', () => {
const entry = validReviewerEntry();
delete entry.interactions;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
assert.equal(verdict.errors.filter((e) => e.field === 'interactions').length, 1);
assert.ok(verdict.errors.some((e) => e.field === 'interactions' && e.reason === 'missing required field'));
assert.equal(
verdict.errors.filter((e) => e.field.startsWith('interactions.')).length,
0,
`expected no interactions.* sub-errors when interactions itself is absent, got: ${JSON.stringify(verdict.errors)}`,
);
});
test('a non-object interactions is rejected before key checks', () => {
for (const bad of [null, [], 'x']) {
const entry = validReviewerEntry();
entry.interactions = bad;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false, `expected ${JSON.stringify(bad)} invalid`);
const err = verdict.errors.find((e) => e.field === 'interactions');
assert.ok(err, `expected interactions error for ${JSON.stringify(bad)}`);
assert.equal(err.reason, 'interactions must be an object');
}
});
test('capability-only interactions keys are rejected on a reviewer', () => {
const entry = validReviewerEntry();
entry.interactions.loopExtensionPoints = ['execute:pre'];
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.loopExtensionPoints');
assert.ok(err, `expected an interactions.loopExtensionPoints error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, 'unknown field');
});
test('manifest-body keys outside the 8 registry fields are rejected', () => {
for (const key of ['probe', 'invoke']) {
const entry = validReviewerEntry();
entry.interactions[key] = {};
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false, `expected interactions.${key} to be rejected`);
const err = verdict.errors.find((e) => e.field === `interactions.${key}`);
assert.ok(err, `expected interactions.${key} error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, 'unknown field');
}
});
test('an unknown key does not suppress the missing-key sweep', () => {
const entry = validReviewerEntry();
entry.interactions.bogus = 'x';
delete entry.interactions.slug;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
const bogusErr = verdict.errors.find((e) => e.field === 'interactions.bogus');
assert.ok(bogusErr);
assert.equal(bogusErr.reason, 'unknown field');
const slugErr = verdict.errors.find((e) => e.field === 'interactions.slug');
assert.ok(slugErr);
assert.equal(slugErr.reason, 'missing required field');
});
});
describe('validateEntries: reviewer — slug grammar', () => {
test('a kebab slug is valid', () => {
for (const slug of ['gemini', 'a']) {
const entry = validReviewerEntry();
entry.interactions.slug = slug;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, true, `expected "${slug}" valid, got: ${JSON.stringify(verdict.errors)}`);
}
});
test('an underscored lane slug is valid', () => {
for (const slug of ['lm_studio', 'llama_cpp']) {
const entry = validReviewerEntry();
entry.interactions.slug = slug;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, true, `expected "${slug}" valid, got: ${JSON.stringify(verdict.errors)}`);
}
});
test('a leading-digit lane slug is valid', () => {
const entry = validReviewerEntry();
entry.interactions.slug = '4o-mini';
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, true, `expected "4o-mini" valid, got: ${JSON.stringify(verdict.errors)}`);
});
test('a slug may not start with a separator', () => {
for (const slug of ['-lead', '_lead']) {
const entry = validReviewerEntry();
entry.interactions.slug = slug;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false, `expected "${slug}" invalid`);
const err = verdict.errors.find((e) => e.field === 'interactions.slug');
assert.ok(err, `expected interactions.slug error for "${slug}"`);
assert.equal(err.reason, 'must match the reviewer lane slug grammar');
}
});
test('a slug outside the lane grammar is rejected', () => {
for (const slug of ['Upper', 'has space', 'dot.ted', '']) {
const entry = validReviewerEntry();
entry.interactions.slug = slug;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false, `expected "${slug}" invalid`);
const err = verdict.errors.find((e) => e.field === 'interactions.slug');
assert.ok(err, `expected interactions.slug error for "${slug}"`);
assert.equal(err.reason, 'must match the reviewer lane slug grammar');
}
});
test('a non-string slug is rejected without throwing', () => {
for (const slug of [123, null]) {
const entry = validReviewerEntry();
entry.interactions.slug = slug;
let verdict;
assert.doesNotThrow(() => {
verdict = validateEntries([entry], { type: 'reviewer' });
});
assert.equal(verdict.ok, false, `expected ${JSON.stringify(slug)} invalid`);
const err = verdict.errors.find((e) => e.field === 'interactions.slug');
assert.ok(err);
assert.equal(err.reason, 'must match the reviewer lane slug grammar');
}
});
test('fast-check property: any lane-grammar slug is accepted', () => {
fc.assert(
fc.property(
fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')),
fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789_-'.split('')), { maxLength: 20 }),
(first, rest) => {
const slug = first + rest.join('');
const entry = validReviewerEntry();
entry.interactions.slug = slug;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, true, `expected "${slug}" valid, got: ${JSON.stringify(verdict.errors)}`);
},
),
);
});
test('fast-check property: an out-of-grammar character always rejects', () => {
fc.assert(
fc.property(
fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')),
fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789_-'.split('')), { maxLength: 10 }),
fc.constantFrom(...'ABCDEFGHIJKLMNOPQRSTUVWXYZ .!@#$%^&*'.split('')),
fc.nat(10),
(first, rest, badChar, insertAt) => {
const base = first + rest.join('');
const pos = Math.min(insertAt, base.length);
const slug = base.slice(0, pos) + badChar + base.slice(pos);
const entry = validReviewerEntry();
entry.interactions.slug = slug;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false, `expected "${slug}" invalid`);
},
),
);
});
});
describe('validateEntries: reviewer — flags grammar', () => {
test('a single well-formed flag is valid', () => {
for (const flags of [['--gemini'], ['--a', '--b', '--c']]) {
const entry = validReviewerEntry();
entry.interactions.flags = flags;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, true, `expected ${JSON.stringify(flags)} valid, got: ${JSON.stringify(verdict.errors)}`);
}
});
test('an empty flags array is rejected', () => {
const entry = validReviewerEntry();
entry.interactions.flags = [];
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.flags');
assert.ok(err);
assert.equal(err.reason, 'must be a non-empty array of lane CLI flags');
});
test('duplicate flags are accepted — the registry is a directory, not the runtime validator', () => {
const entry = validReviewerEntry();
entry.interactions.flags = ['--gemini', '--gemini'];
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, true, `expected duplicate flags valid, got: ${JSON.stringify(verdict.errors)}`);
});
test('a flag must carry the double-dash prefix', () => {
for (const flags of [['gemini'], ['-g']]) {
const entry = validReviewerEntry();
entry.interactions.flags = flags;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false, `expected ${JSON.stringify(flags)} invalid`);
const err = verdict.errors.find((e) => e.field === 'interactions.flags');
assert.ok(err);
assert.equal(err.reason, 'must be a non-empty array of lane CLI flags');
}
});
test('a flag outside the kebab flag grammar is rejected', () => {
for (const flags of [['--Gemini'], ['--lm_studio']]) {
const entry = validReviewerEntry();
entry.interactions.flags = flags;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false, `expected ${JSON.stringify(flags)} invalid`);
const err = verdict.errors.find((e) => e.field === 'interactions.flags');
assert.ok(err);
assert.equal(err.reason, 'must be a non-empty array of lane CLI flags');
}
});
test('a non-array / non-string-element flags is rejected', () => {
for (const flags of [[1], '--gemini']) {
const entry = validReviewerEntry();
entry.interactions.flags = flags;
let verdict;
assert.doesNotThrow(() => {
verdict = validateEntries([entry], { type: 'reviewer' });
});
assert.equal(verdict.ok, false, `expected ${JSON.stringify(flags)} invalid`);
const err = verdict.errors.find((e) => e.field === 'interactions.flags');
assert.ok(err);
assert.equal(err.reason, 'must be a non-empty array of lane CLI flags');
}
});
});
describe('validateEntries: reviewer — transport / evidenceClass', () => {
test('each allowed transport is accepted', () => {
for (const transport of REVIEWER_LANE_TRANSPORTS) {
const entry = validReviewerEntry();
entry.interactions.transport = transport;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, true, `expected transport "${transport}" valid, got: ${JSON.stringify(verdict.errors)}`);
}
});
test('an unknown transport is rejected', () => {
for (const transport of ['SPAWN', 'http', '', 1, null, ['spawn']]) {
const entry = validReviewerEntry();
entry.interactions.transport = transport;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false, `expected transport ${JSON.stringify(transport)} invalid`);
const err = verdict.errors.find((e) => e.field === 'interactions.transport');
assert.ok(err);
assert.equal(err.reason, 'must be one of the allowed lane transports');
}
});
test('each allowed evidence class is accepted', () => {
for (const evidenceClass of REVIEWER_EVIDENCE_CLASSES) {
const entry = validReviewerEntry();
entry.interactions.evidenceClass = evidenceClass;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, true, `expected evidenceClass "${evidenceClass}" valid, got: ${JSON.stringify(verdict.errors)}`);
}
});
test('an unknown evidence class is rejected', () => {
for (const evidenceClass of ['diff', 'Source-Grounded', 1, null, ['diff-only']]) {
const entry = validReviewerEntry();
entry.interactions.evidenceClass = evidenceClass;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false, `expected evidenceClass ${JSON.stringify(evidenceClass)} invalid`);
const err = verdict.errors.find((e) => e.field === 'interactions.evidenceClass');
assert.ok(err);
assert.equal(err.reason, 'must be one of the allowed evidence classes');
}
});
});
describe('validateEntries: reviewer — reviewsSection', () => {
test('a plain reviewsSection is valid', () => {
const entry = validReviewerEntry();
entry.interactions.reviewsSection = 'Gemini';
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, true, `expected valid, got: ${JSON.stringify(verdict.errors)}`);
});
test('reviewsSection at and just below the cap is valid', () => {
for (const len of [REVIEWER_SECTION_MAX - 1, REVIEWER_SECTION_MAX]) {
const entry = validReviewerEntry();
entry.interactions.reviewsSection = 'x'.repeat(len);
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, true, `expected len ${len} valid, got: ${JSON.stringify(verdict.errors)}`);
}
});
test('reviewsSection above the cap is rejected', () => {
const entry = validReviewerEntry();
entry.interactions.reviewsSection = 'x'.repeat(REVIEWER_SECTION_MAX + 1);
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find(
(e) => e.field === 'interactions.reviewsSection' && new RegExp(`exceeds max length ${REVIEWER_SECTION_MAX}`).test(e.reason),
);
assert.ok(err, `expected an exceeds-max-length error, got: ${JSON.stringify(verdict.errors)}`);
});
test('a blank reviewsSection is rejected', () => {
for (const reviewsSection of ['', ' ', 123, null]) {
const entry = validReviewerEntry();
entry.interactions.reviewsSection = reviewsSection;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false, `expected ${JSON.stringify(reviewsSection)} invalid`);
const err = verdict.errors.find((e) => e.field === 'interactions.reviewsSection');
assert.ok(err);
assert.equal(err.reason, 'must be a non-empty string');
}
});
});
describe('validateEntries: reviewer — may-be-empty arrays', () => {
test('the may-be-empty arrays accept []', () => {
const entry = validReviewerEntry();
entry.interactions.requiresBinaries = [];
entry.interactions.configKeys = [];
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, true, `expected valid, got: ${JSON.stringify(verdict.errors)}`);
});
test('runtimeCompat accepts the all wildcard', () => {
for (const runtimeCompat of [['all'], []]) {
const entry = validReviewerEntry();
entry.interactions.runtimeCompat = runtimeCompat;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, true, `expected ${JSON.stringify(runtimeCompat)} valid, got: ${JSON.stringify(verdict.errors)}`);
}
});
test('a non-string-array is rejected for each array field', () => {
for (const field of ['requiresBinaries', 'configKeys', 'runtimeCompat']) {
for (const bad of [[1], 'a', {}]) {
const entry = validReviewerEntry();
entry.interactions[field] = bad;
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false, `expected interactions.${field} = ${JSON.stringify(bad)} invalid`);
const err = verdict.errors.find((e) => e.field === `interactions.${field}`);
assert.ok(err, `expected interactions.${field} error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, 'must be an array of strings');
}
}
});
});
// ─── renderMarkdown: reviewer registry ─────────────────────────────────────
describe('renderMarkdown: reviewer registry', () => {
test('an empty reviewer catalog renders the reviewer heading, not the capability one', () => {
const rendered = renderMarkdown([], { type: 'reviewer', sourceFile: 'reviewers.json' });
assert.match(rendered, /# GSD Reviewer Lane Registry/);
assert.ok(rendered.includes('_To add your reviewer lane, see the [registry README](./README.md)._'));
assert.match(rendered, /No entries yet/);
});
test('a reviewer entry renders its lane summary', () => {
const entry = validReviewerEntry();
const rendered = renderMarkdown([entry], { type: 'reviewer', sourceFile: 'reviewers.json' });
const { slug, flags, transport, evidenceClass, reviewsSection } = entry.interactions;
const expected =
`Lane: ${slug}; flags: ${flags.join(', ')}; transport: ${transport}; evidence: ${evidenceClass}; ` +
`REVIEWS.md section: ${reviewsSection}`;
const line = rendered.split('\n').find((l) => l.startsWith('- **Every interaction with GSD:** '));
assert.ok(line, `expected the summary bullet line, got: ${rendered}`);
assert.ok(line.includes(expected), `expected summary to include "${expected}", got: ${line}`);
});
test('empty optional arrays are omitted from the rendered summary', () => {
const entry = validReviewerEntry();
entry.interactions.requiresBinaries = [];
entry.interactions.configKeys = [];
const renderedEmpty = renderMarkdown([entry], { type: 'reviewer', sourceFile: 'reviewers.json' });
assert.ok(!renderedEmpty.includes('requiresBinaries:'));
assert.ok(!renderedEmpty.includes('configKeys:'));
entry.interactions.requiresBinaries = ['ffmpeg'];
entry.interactions.configKeys = ['review.foo'];
const renderedPopulated = renderMarkdown([entry], { type: 'reviewer', sourceFile: 'reviewers.json' });
assert.ok(renderedPopulated.includes('requiresBinaries: ffmpeg'));
assert.ok(renderedPopulated.includes('configKeys: review.foo'));
});
test('reviewer rendering is deterministic regardless of input order', () => {
const a = validReviewerEntry();
const b = { ...validReviewerEntry(), id: 'zzz-reviewer', name: 'ZZZ Reviewer' };
const first = renderMarkdown([a, b], { type: 'reviewer', sourceFile: 'reviewers.json' });
const second = renderMarkdown([b, a], { type: 'reviewer', sourceFile: 'reviewers.json' });
assert.equal(first, second);
});
test('untrusted reviewer free text cannot break out of the table', () => {
const entry = validReviewerEntry();
entry.description = 'Good stuff | ![x](https://evil/track.png) | text';
entry.interactions.reviewsSection = 'Gemini | ```evil``` <script> [x](y)';
const rendered = renderMarkdown([entry], { type: 'reviewer', sourceFile: 'reviewers.json' });
assert.ok(rendered.includes('\\|'), 'expected an escaped pipe (\\|) in the rendered output');
assert.ok(
!rendered.includes('![x](https://evil/track.png)'),
'expected the raw unescaped link-hijack payload to NOT appear verbatim',
);
assert.ok(rendered.includes('\\['), 'expected an escaped [ (\\[)');
const line = rendered.split('\n').find((l) => l.startsWith('- **Every interaction with GSD:** '));
assert.ok(line, 'expected the summary bullet line');
assert.ok(!line.includes('```evil```'), 'expected the raw backtick run in reviewsSection to be escaped');
assert.ok(!line.includes('<script>'), 'expected angle brackets in reviewsSection to be escaped');
assert.ok(!line.includes('](y)'), 'expected the reviewsSection link-hijack payload to be neutralized');
});
});
// ─── renderMarkdown: capability/eos rendering unchanged by the third type ──
describe('renderMarkdown: capability and eos rendering are unchanged by the third type', () => {
// Golden snapshots captured from the module BEFORE the reviewer type was
// implemented (git rev 9f567a162, unmodified scripts/registry-schema.cjs).
// The reviewer feature must not perturb a single byte of capability/eos
// output — this is the regression pin for matrix row 55.
const GOLDEN_CAPABILITY_MD = "<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/capabilities.json — do not edit by hand; run `npm run gen:registry` -->\n\n# GSD Community Capability Registry\n\n> **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).\n\n_To add your capability, see the [registry README](./README.md)._\n\n| Name | What it is | Latest release | GSD compat | Discussion |\n|---|---|---|---|---|\n| [My Capability](https://github.com/octocat/my-capability) | Does a useful thing for GSD users. | ![release](https://img.shields.io/github/v/release/octocat/my-capability?sort=semver&include_prereleases) | `>=1.6.0 <3.0.0` | [discuss](https://github.com/octocat/my-capability/discussions/1) |\n\n## My Capability\n- **Repository:** https://github.com/octocat/my-capability — [latest release](https://github.com/octocat/my-capability/releases/latest)\n- **What it is:** Does a useful thing for GSD users.\n- **Author:** Octocat\n- **Every interaction with GSD:** Loop Extension Points: execute:pre; hook kinds: step; configKeys: myCapability.enabled; runtimeCompat: all\n- **Install:**\n```sh\ngsd capability install https://github.com/octocat/my-capability.git#v1.0.0\n```\n- **Uninstall:**\n```sh\ngsd capability remove my-capability\n```\n- **GSD compatibility:** `>=1.6.0 <3.0.0`\n- **License:** MIT\n- **Discussion / ranking:** https://github.com/octocat/my-capability/discussions/1\n";
const GOLDEN_EOS_MD = "<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/eos.json — do not edit by hand; run `npm run gen:registry` -->\n\n# GSD EoS Registry\n\n> **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).\n\n_To add your integration, see the [registry README](./README.md)._\n\n| Name | What it is | Latest release | GSD compat | Discussion |\n|---|---|---|---|---|\n| [My Host Plugin](https://github.com/octocat/my-host-plugin) | Embeds GSD as an orchestration engine in My Host. | ![release](https://img.shields.io/github/v/release/octocat/my-host-plugin?sort=semver&include_prereleases) | `>=1.6.0 <3.0.0` | [discuss](https://github.com/octocat/my-host-plugin/discussions/2) |\n\n## My Host Plugin\n- **Repository:** https://github.com/octocat/my-host-plugin — [latest release](https://github.com/octocat/my-host-plugin/releases/latest)\n- **What it is:** Embeds GSD as an orchestration engine in My Host.\n- **Author:** Octocat\n- **Every interaction with GSD:** Interface points: command, state; profile: programmatic-cli; protocol v1; 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\n- **Install:**\n```sh\nSee the My Host plugin marketplace listing.\n```\n- **Uninstall:**\n```sh\nUninstall via the My Host plugin manager.\n```\n- **GSD compatibility:** `>=1.6.0 <3.0.0`, protocol v1\n- **License:** MIT\n- **Discussion / ranking:** https://github.com/octocat/my-host-plugin/discussions/2\n";
test('capability rendering is byte-identical to the pre-reviewer-type golden output', () => {
const rendered = renderMarkdown([validCapabilityEntry()], { type: 'capability', sourceFile: 'capabilities.json' });
assert.equal(rendered, GOLDEN_CAPABILITY_MD);
});
test('eos rendering is byte-identical to the pre-reviewer-type golden output', () => {
const rendered = renderMarkdown([validEosEntry()], { type: 'eos', sourceFile: 'eos.json' });
assert.equal(rendered, GOLDEN_EOS_MD);
});
});
// ─── validateEntries: interactions array-of-strings hardening (control chars,
// per-element length cap, array count cap) — capability and reviewer types ──
describe('validateEntries: interactions string-array fields — control characters', () => {
test('capability interactions.configKeys element with a control char is rejected', () => {
const entry = validCapabilityEntry();
entry.interactions.configKeys = ['a\x00b'];
const verdict = validateEntries([entry], { type: 'capability' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.configKeys');
assert.ok(err, `expected interactions.configKeys error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, 'must not contain control characters');
});
test('reviewer interactions.requiresBinaries element with a control char is rejected', () => {
const entry = validReviewerEntry();
entry.interactions.requiresBinaries = ['a\x00b'];
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.requiresBinaries');
assert.ok(err, `expected interactions.requiresBinaries error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, 'must not contain control characters');
});
});
describe('validateEntries: interactions string-array fields — per-element length cap', () => {
test('capability interactions.configKeys element at 199/200 chars (limit-1/limit) is valid', () => {
for (const len of [INTERACTION_STRING_MAX - 1, INTERACTION_STRING_MAX]) {
const entry = validCapabilityEntry();
entry.interactions.configKeys = ['x'.repeat(len)];
const verdict = validateEntries([entry], { type: 'capability' });
assert.equal(
verdict.errors.some((e) => e.field === 'interactions.configKeys'),
false,
`expected len ${len} valid, got: ${JSON.stringify(verdict.errors)}`,
);
}
});
test('capability interactions.configKeys element at 201 chars (limit+1) is rejected', () => {
const entry = validCapabilityEntry();
entry.interactions.configKeys = ['x'.repeat(INTERACTION_STRING_MAX + 1)];
const verdict = validateEntries([entry], { type: 'capability' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.configKeys');
assert.ok(err, `expected interactions.configKeys error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, `exceeds max length ${INTERACTION_STRING_MAX}`);
});
test('reviewer interactions.requiresBinaries element at 199/200 chars (limit-1/limit) is valid', () => {
for (const len of [INTERACTION_STRING_MAX - 1, INTERACTION_STRING_MAX]) {
const entry = validReviewerEntry();
entry.interactions.requiresBinaries = ['x'.repeat(len)];
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(
verdict.errors.some((e) => e.field === 'interactions.requiresBinaries'),
false,
`expected len ${len} valid, got: ${JSON.stringify(verdict.errors)}`,
);
}
});
test('reviewer interactions.requiresBinaries element at 201 chars (limit+1) is rejected', () => {
const entry = validReviewerEntry();
entry.interactions.requiresBinaries = ['x'.repeat(INTERACTION_STRING_MAX + 1)];
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.requiresBinaries');
assert.ok(err, `expected interactions.requiresBinaries error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, `exceeds max length ${INTERACTION_STRING_MAX}`);
});
});
describe('validateEntries: interactions string-array fields — array count cap', () => {
test('capability interactions.configKeys at 49/50 elements (limit-1/limit) is valid', () => {
for (const n of [INTERACTION_ARRAY_MAX - 1, INTERACTION_ARRAY_MAX]) {
const entry = validCapabilityEntry();
entry.interactions.configKeys = Array.from({ length: n }, (_, i) => `k${i}`);
const verdict = validateEntries([entry], { type: 'capability' });
assert.equal(
verdict.errors.some((e) => e.field === 'interactions.configKeys'),
false,
`expected ${n} elements valid, got: ${JSON.stringify(verdict.errors)}`,
);
}
});
test('capability interactions.configKeys at 51 elements (limit+1) is rejected', () => {
const entry = validCapabilityEntry();
entry.interactions.configKeys = Array.from({ length: INTERACTION_ARRAY_MAX + 1 }, (_, i) => `k${i}`);
const verdict = validateEntries([entry], { type: 'capability' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.configKeys');
assert.ok(err, `expected interactions.configKeys error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, `exceeds max entries ${INTERACTION_ARRAY_MAX}`);
});
test('reviewer interactions.requiresBinaries at 49/50 elements (limit-1/limit) is valid', () => {
for (const n of [INTERACTION_ARRAY_MAX - 1, INTERACTION_ARRAY_MAX]) {
const entry = validReviewerEntry();
entry.interactions.requiresBinaries = Array.from({ length: n }, (_, i) => `b${i}`);
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(
verdict.errors.some((e) => e.field === 'interactions.requiresBinaries'),
false,
`expected ${n} elements valid, got: ${JSON.stringify(verdict.errors)}`,
);
}
});
test('reviewer interactions.requiresBinaries at 51 elements (limit+1) is rejected', () => {
const entry = validReviewerEntry();
entry.interactions.requiresBinaries = Array.from({ length: INTERACTION_ARRAY_MAX + 1 }, (_, i) => `b${i}`);
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.requiresBinaries');
assert.ok(err, `expected interactions.requiresBinaries error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, `exceeds max entries ${INTERACTION_ARRAY_MAX}`);
});
});
describe('validateEntries: reviewer interactions.reviewsSection — control characters', () => {
test('reviewsSection containing ESC (\\x1b) is rejected', () => {
const entry = validReviewerEntry();
entry.interactions.reviewsSection = 'Sec\x1bRED';
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.reviewsSection');
assert.ok(err, `expected interactions.reviewsSection error, got: ${JSON.stringify(verdict.errors)}`);
assert.equal(err.reason, 'must not contain control characters');
});
});
describe('validateEntries: regression — hostile interactions entry can no longer validate', () => {
test('the control-char/oversized-array reviewer entry from the security-review repro is rejected', () => {
const entry = {
id: 'x',
name: 'X',
type: 'reviewer',
repo: 'o/r',
description: 'd',
author: 'a',
license: 'MIT',
enginesGsd: '>=1.6.0',
install: 'i',
uninstall: 'u',
discussion: 'https://github.com/o/r/discussions/1',
interactions: {
slug: 'x',
flags: ['--x'],
transport: 'spawn',
evidenceClass: 'diff-only',
reviewsSection: 'Sec\x1b[31mRED\x1b[0m',
requiresBinaries: ['bin\x00null', 'y'.repeat(5000)],
configKeys: [],
runtimeCompat: ['all'],
},
};
const verdict = validateEntries([entry], { type: 'reviewer' });
assert.equal(verdict.ok, false);
assert.ok(
verdict.errors.some(
(e) => e.field === 'interactions.reviewsSection' && e.reason === 'must not contain control characters',
),
`expected reviewsSection control-char rejection, got: ${JSON.stringify(verdict.errors)}`,
);
assert.ok(
verdict.errors.some(
(e) => e.field === 'interactions.requiresBinaries' && e.reason === 'must not contain control characters',
),
`expected requiresBinaries control-char rejection, got: ${JSON.stringify(verdict.errors)}`,
);
assert.ok(
verdict.errors.some(
(e) => e.field === 'interactions.requiresBinaries' && e.reason === `exceeds max length ${INTERACTION_STRING_MAX}`,
),
`expected requiresBinaries length rejection, got: ${JSON.stringify(verdict.errors)}`,
);
});
});

View File

@@ -41,6 +41,32 @@ function validCapabilityEntry() {
};
}
function validReviewerEntry() {
return {
id: 'my-reviewer',
name: 'My Reviewer',
type: 'reviewer',
repo: 'octocat/my-reviewer',
description: 'Reviews GSD PRs for a specific concern.',
author: 'Octocat',
license: 'MIT',
enginesGsd: '>=1.6.0 <3.0.0',
install: 'gsd capability install https://github.com/octocat/my-reviewer.git#v1.0.0',
uninstall: 'gsd capability remove my-reviewer',
interactions: {
slug: 'my-reviewer',
flags: ['--my-reviewer'],
transport: 'spawn',
evidenceClass: 'source-grounded',
reviewsSection: 'My Reviewer',
requiresBinaries: [],
configKeys: [],
runtimeCompat: ['all'],
},
discussion: 'https://github.com/octocat/my-reviewer/discussions/1',
};
}
function withFixture(entries, fn) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-validate-registry-'));
try {
@@ -53,6 +79,21 @@ function withFixture(entries, fn) {
}
}
// Writes capabilities.json AND reviewers.json into an isolated fixture dir —
// used by the reviewer-catalog cases below (#2904).
function withReviewerFixture(capabilityEntries, reviewerEntries, fn) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-validate-registry-reviewer-'));
try {
const registriesDir = path.join(tmp, 'docs', 'registries');
fs.mkdirSync(registriesDir, { recursive: true });
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify(capabilityEntries, null, 2) + '\n');
fs.writeFileSync(path.join(registriesDir, 'reviewers.json'), JSON.stringify(reviewerEntries, null, 2) + '\n');
fn(tmp);
} finally {
cleanup(tmp);
}
}
function runValidate(cwd, args = []) {
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { cwd, encoding: 'utf8' });
}
@@ -115,3 +156,150 @@ describe('validate-registry CLI (subprocess)', () => {
}
});
});
// ─── validate-registry CLI (subprocess): reviewer catalog (#2904) ─────────
//
// scripts/validate-registry.cjs's SOURCES array does not yet include
// { file:'reviewers.json', type:'reviewer', optional:true } — every case
// below is FAILING-FIRST against the unmodified script. See
// .gsd/phase/feat-2904-enh-registries-add-a-reviewer-entry-type/50-test-matrix.md.
describe('validate-registry CLI (subprocess): reviewer catalog', () => {
test('a valid reviewers.json passes', () => {
withReviewerFixture([validCapabilityEntry()], [validReviewerEntry()], (tmp) => {
const result = runValidate(tmp);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
});
});
test('an invalid reviewer entry fails with a located error', () => {
const bad = validReviewerEntry();
delete bad.interactions.slug;
withReviewerFixture([validCapabilityEntry()], [bad], (tmp) => {
const result = runValidate(tmp);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /reviewers\.json/);
assert.match(result.stderr, /interactions\.slug/);
});
});
test('an absent reviewers.json is skipped', () => {
withFixture([validCapabilityEntry()], (tmp) => {
const result = runValidate(tmp);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
});
});
test('--json reports all three sources', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-validate-registry-all3-'));
try {
const registriesDir = path.join(tmp, 'docs', 'registries');
fs.mkdirSync(registriesDir, { recursive: true });
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify([validCapabilityEntry()], null, 2) + '\n');
fs.writeFileSync(
path.join(registriesDir, 'eos.json'),
JSON.stringify(
[
{
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',
},
],
null,
2,
) + '\n',
);
fs.writeFileSync(path.join(registriesDir, 'reviewers.json'), JSON.stringify([validReviewerEntry()], null, 2) + '\n');
const result = runValidate(tmp, ['--json']);
const parsed = JSON.parse(result.stdout);
assert.equal(typeof parsed.ok, 'boolean');
assert.equal(parsed.results.length, 3, `expected 3 result rows, got: ${JSON.stringify(parsed.results)}`);
assert.ok(
parsed.results.some((r) => r.file === 'reviewers.json' && r.type === 'reviewer'),
`expected a reviewers.json/reviewer row, got: ${JSON.stringify(parsed.results)}`,
);
} finally {
cleanup(tmp);
}
});
test('--json with reviewers.json absent (capabilities.json + eos.json present) reports 2 rows and ok:true', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-validate-registry-noreviewer-'));
try {
const registriesDir = path.join(tmp, 'docs', 'registries');
fs.mkdirSync(registriesDir, { recursive: true });
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify([validCapabilityEntry()], null, 2) + '\n');
fs.writeFileSync(
path.join(registriesDir, 'eos.json'),
JSON.stringify(
[
{
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',
},
],
null,
2,
) + '\n',
);
// Deliberately no reviewers.json.
const result = runValidate(tmp, ['--json']);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.ok, true);
assert.equal(parsed.results.length, 2, `expected 2 result rows, got: ${JSON.stringify(parsed.results)}`);
assert.ok(!parsed.results.some((r) => r.file === 'reviewers.json'));
} finally {
cleanup(tmp);
}
});
});