From 90771ddf026d6e0dbf9bf897b7c54b60b6a97da4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 31 Jul 2026 08:11:57 -0400 Subject: [PATCH] enh(#2904): add a `reviewer` entry type so third-party reviewer lanes are discoverable (#2912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- .changeset/merry-seals-climb.md | 5 + .../PULL_REQUEST_TEMPLATE/registry-entry.md | 6 +- CONTEXT.md | 5 +- docs/registries/README.md | 83 +- docs/registries/reviewer-registry.md | 9 + docs/registries/reviewers.json | 1 + scripts/gen-registry.cjs | 54 +- scripts/registry-schema.cjs | 419 +++++++--- scripts/validate-registry.cjs | 16 +- tests/gen-registry.test.cjs | 190 +++++ tests/registry-reviewer-parity.test.cjs | 153 ++++ tests/registry-schema.test.cjs | 769 ++++++++++++++++++ tests/validate-registry.test.cjs | 188 +++++ 13 files changed, 1768 insertions(+), 130 deletions(-) create mode 100644 .changeset/merry-seals-climb.md create mode 100644 docs/registries/reviewer-registry.md create mode 100644 docs/registries/reviewers.json create mode 100644 tests/registry-reviewer-parity.test.cjs diff --git a/.changeset/merry-seals-climb.md b/.changeset/merry-seals-climb.md new file mode 100644 index 000000000..3c686b902 --- /dev/null +++ b/.changeset/merry-seals-climb.md @@ -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) diff --git a/.github/PULL_REQUEST_TEMPLATE/registry-entry.md b/.github/PULL_REQUEST_TEMPLATE/registry-entry.md index 6a6b4afb0..e9baa3601 100644 --- a/.github/PULL_REQUEST_TEMPLATE/registry-entry.md +++ b/.github/PULL_REQUEST_TEMPLATE/registry-entry.md @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index f8cb30dd0..8d762f79d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 `. 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`. diff --git a/docs/registries/README.md b/docs/registries/README.md index 8c3dc2133..bdf650244 100644 --- a/docs/registries/README.md +++ b/docs/registries/README.md @@ -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 `. - **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 `. 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 `. | +| `interactions` | yes | Object — see below. | +| `discussion` | yes | URL of this entry's GitHub Discussion (`https://github.com///discussions/`). | + +`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 ", + "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/-` 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. diff --git a/docs/registries/reviewer-registry.md b/docs/registries/reviewer-registry.md new file mode 100644 index 000000000..b4ded1ce3 --- /dev/null +++ b/docs/registries/reviewer-registry.md @@ -0,0 +1,9 @@ + + +# 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)._ diff --git a/docs/registries/reviewers.json b/docs/registries/reviewers.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/docs/registries/reviewers.json @@ -0,0 +1 @@ +[] diff --git a/scripts/gen-registry.cjs b/scripts/gen-registry.cjs index 385c7f17b..93c10202e 100644 --- a/scripts/gen-registry.cjs +++ b/scripts/gen-registry.cjs @@ -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); diff --git a/scripts/registry-schema.cjs b/scripts/registry-schema.cjs index 89448eba7..b25cb611d 100644 --- a/scripts/registry-schema.cjs +++ b/scripts/registry-schema.cjs @@ -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( ``, ); 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, diff --git a/scripts/validate-registry.cjs b/scripts/validate-registry.cjs index 1e9671d1e..de3eac51c 100644 --- a/scripts/validate-registry.cjs +++ b/scripts/validate-registry.cjs @@ -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 }); diff --git a/tests/gen-registry.test.cjs b/tests/gen-registry.test.cjs index cb3ee7d71..7202feb82 100644 --- a/tests/gen-registry.test.cjs +++ b/tests/gen-registry.test.cjs @@ -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\./.test(result.stderr) && !/\.js:\d+:\d+/.test(result.stderr), + `expected no raw Node stack trace leaked to stderr, got: ${result.stderr}`, + ); + } finally { + cleanup(tmp); + } + }); +}); diff --git a/tests/registry-reviewer-parity.test.cjs b/tests/registry-reviewer-parity.test.cjs new file mode 100644 index 000000000..ab30573c6 --- /dev/null +++ b/tests/registry-reviewer-parity.test.cjs @@ -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//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//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})`, + ); + } + }); +}); diff --git a/tests/registry-schema.test.cjs b/tests/registry-schema.test.cjs index 5ff3bb677..7e8bd3956 100644 --- a/tests/registry-schema.test.cjs +++ b/tests/registry-schema.test.cjs @@ -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```