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

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

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

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

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

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

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

Closes #2904

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

Review findings from the pre-PR passes.

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

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

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

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

* chore(#2904): backfill changeset PR number (#2912)
This commit is contained in:
Tom Boucher
2026-07-31 08:11:57 -04:00
committed by GitHub
parent 557d46984e
commit 90771ddf02
13 changed files with 1768 additions and 130 deletions

View File

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

View File

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

View File

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