Merge pull request #2188 from open-gsd/feat/2182-capability-registry

feat(#2182): add Community Capability Registry discoverability catalog
This commit is contained in:
Tom Boucher
2026-07-11 16:38:31 -04:00
committed by GitHub
15 changed files with 2043 additions and 2 deletions

128
scripts/gen-registry.cjs Normal file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env node
'use strict';
/**
* scripts/gen-registry.cjs — generates docs/registries/capability-registry.md
* (and, once PR2 ships docs/registries/eos.json, docs/registries/eos-registry.md)
* from the corresponding source JSON, via registry-schema.cjs#renderMarkdown.
* Issue #2182.
*
* NOT to be confused with `scripts/gen-capability-registry.cjs`: that script
* generates the RUNTIME capability manifest consumed by the host at runtime
* (`gsd-core/bin/lib/capability-registry.cjs`, built from every
* `capabilities/<id>/capability.json` declaration). THIS script instead
* generates the human-facing DOCUMENTATION catalog pages
* (`docs/registries/*.md`) from the third-party discoverability registry
* source JSON (`docs/registries/{capabilities,eos}.json`). The two pipelines
* are independent — do not conflate them.
*
* Usage:
* node scripts/gen-registry.cjs # print rendered markdown(s) to stdout
* node scripts/gen-registry.cjs --write # write the *-registry.md file(s)
* node scripts/gen-registry.cjs --check # exit 1 if a committed *-registry.md is stale
*
* Root is resolved from process.cwd() (not __dirname) — mirrors
* scripts/validate-registry.cjs so both are drivable as subprocesses against
* isolated temp-fixture directories via `cwd`.
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const { renderMarkdown } = require('./registry-schema.cjs');
const SOURCES = [
{ type: 'capability', jsonFile: 'capabilities.json', mdFile: 'capability-registry.md' },
{ type: 'eos', jsonFile: 'eos.json', mdFile: 'eos-registry.md' },
];
/**
* The generator always writes LF; a Windows checkout (autocrlf) may present
* committed files with CRLF. Normalize before comparing so `--check` only
* fails on real content drift, not checkout-introduced line-ending noise.
*
* @param {string} content
* @returns {string}
*/
function normalizeLineEndings(content) {
return content.replace(/\r/g, '');
}
function getRegistriesDir() {
return path.join(process.cwd(), 'docs', 'registries');
}
/**
* Render the markdown for a single registry type from its committed source
* JSON.
*
* Only `eos.json` is optional (pre-PR2, before that source JSON ships) —
* an absent `eos.json` returns null and callers treat that as "nothing to
* do". `capabilities.json` is the primary registry source: a missing
* `capabilities.json` is ALWAYS an error (never a silent "up to date"
* pass), mirroring the type distinction in `scripts/validate-registry.cjs`
* (`type === 'eos' && !exists → continue`).
*
* @param {'capability'|'eos'} type
* @returns {string|null}
*/
function renderFor(type) {
const source = SOURCES.find((s) => s.type === type);
if (!source) throw new Error(`gen-registry: unknown registry type "${type}"`);
const jsonPath = path.join(getRegistriesDir(), source.jsonFile);
if (!fs.existsSync(jsonPath)) {
if (type === 'eos') return null;
throw new ExitError(
1,
`${source.jsonFile} does not exist at ${jsonPath}. Run:\n node scripts/gen-registry.cjs --write\n(after adding docs/registries/${source.jsonFile})`,
);
}
const entries = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
return renderMarkdown(entries, { type, sourceFile: source.jsonFile });
}
function main() {
const [, , flag] = process.argv;
const registriesDir = getRegistriesDir();
let anyDrift = false;
for (const { type, mdFile } of SOURCES) {
const rendered = renderFor(type);
if (rendered === null) continue; // source JSON absent (eos.json before PR2)
const mdPath = path.join(registriesDir, mdFile);
if (flag === '--check') {
if (!fs.existsSync(mdPath)) {
process.stderr.write(`${mdFile} does not exist. Run:\n node scripts/gen-registry.cjs --write\n`);
anyDrift = true;
continue;
}
const committed = fs.readFileSync(mdPath, 'utf8');
if (normalizeLineEndings(committed) !== normalizeLineEndings(rendered)) {
process.stderr.write(`${mdFile} is stale. Run:\n node scripts/gen-registry.cjs --write\n`);
anyDrift = true;
}
} else if (flag === '--write') {
fs.mkdirSync(registriesDir, { recursive: true });
fs.writeFileSync(mdPath, rendered);
process.stdout.write(`Wrote ${mdPath}\n`);
} else {
process.stdout.write(rendered + '\n');
}
}
if (flag === '--check') {
if (anyDrift) throw new ExitError(1, 'registry markdown is stale');
process.stdout.write('docs/registries/*.md are up to date.\n');
}
return 0;
}
if (require.main === module) runMain(main);
module.exports = { main, renderFor, SOURCES, normalizeLineEndings };

565
scripts/registry-schema.cjs Normal file
View File

@@ -0,0 +1,565 @@
'use strict';
/**
* scripts/registry-schema.cjs — pure schema/vocab constants + validation +
* markdown-generation logic for the two third-party discoverability catalogs
* (issue #2182):
*
* - `docs/registries/capabilities.json` → "GSD Community Capability Registry"
* - `docs/registries/eos.json` → "GSD EoS Registry" (PR2)
*
* The vocabulary constants below are ADDITIVE CONTRACTS that track the
* runtime/ADR closed vocabularies they describe — they are a documentation-
* registry-scoped mirror, not the runtime source of truth:
*
* - `LOOP_POINTS` mirrors ADR-857 "Loop Extension Points (the 12)"
* (docs/adr/857-capability-system.md §"Loop Extension Points (the 12)").
* The canonical runtime set lives in `src/loop-resolver.cts`
* (`CANONICAL_POINTS` / `CANONICAL_POINTS_FALLBACK`, derived from
* `loop-host-contract.cjs`) — changing that set requires updating this
* list too, since a registry entry's `loopExtensionPoints` describes
* which of those 12 points a third-party capability extends.
* - `HOOK_KINDS` mirrors ADR-857 Decision 4 "three hook kinds": `step`
* (runs as its own sequenced unit), `contribution` (injects into the
* core step's prompt/context), `gate` (checks and optionally blocks).
* - `INTERFACE_POINTS` mirrors ADR-1239 "The six interface points" (the
* Host-Integration Interface integration surface): command/workflow
* invocation, agent dispatch, model invocation, lifecycle hooks,
* state+config IO, artifact surface.
* - `PROFILES` mirrors ADR-1239 "Host-capability profiles (negotiation
* baselines)": `programmatic-cli`, `declarative-cli`, `ide`.
* - `AXES` mirrors ADR-1239 "the eight negotiated axes" (the negotiated
* capability schema exchanged at `initialize`): `embeddingMode`,
* `commandSurface`, `dispatch`, `modelMode`, `hookBus`, `stateIO`,
* `transport`, `runtime`. Seven of the eight are closed enums here;
* `dispatch` is ADR-1239's structured negotiated object
* (`{ namedDispatch, nested, maxDepth, background, subagentToolkit }`) —
* this registry accepts a free-form human summary string instead, so it
* carries the `AXES_FREE_STRING` sentinel rather than an enum array.
* - `CAPABILITY_REQUIRED` / `EOS_REQUIRED` mirror the required top-level
* fields for each entry type, including `enginesGsd` (ADR-1244 D1
* "Versioned capability manifest" — the `engines.gsd` semver-range gate,
* modelled on VS Code's `engines.vscode`).
*
* This module is pure — no `fs`/`process`/child-process access — so tests
* can `require()` it directly and assert on structured return values.
* `scripts/validate-registry.cjs` and `scripts/gen-registry.cjs` are the thin
* CLI wrappers that perform I/O around these functions.
*/
// ─── ADR-857 "Loop Extension Points (the 12)" ────────────────────────────────
const LOOP_POINTS = Object.freeze([
'discuss:pre',
'discuss:post',
'plan:pre',
'plan:post',
'execute:pre',
'execute:wave:pre',
'execute:wave:post',
'execute:post',
'verify:pre',
'verify:post',
'ship:pre',
'ship:post',
]);
// ─── ADR-857 Decision 4 — three hook kinds ───────────────────────────────────
const HOOK_KINDS = Object.freeze(['step', 'contribution', 'gate']);
// ─── ADR-1239 "The six interface points" ─────────────────────────────────────
const INTERFACE_POINTS = Object.freeze(['command', 'dispatch', 'model', 'hooks', 'state', 'artifact']);
// ─── ADR-1239 "Host-capability profiles (negotiation baselines)" ────────────
const PROFILES = Object.freeze(['programmatic-cli', 'declarative-cli', 'ide']);
// Sentinel marking an AXES entry as a free-form descriptive string rather than
// a closed enum array. `Array.isArray(AXES_FREE_STRING)` is false, so callers
// can branch on `Array.isArray(AXES[key])` vs `AXES[key] === AXES_FREE_STRING`
// without risking confusion with a real enum value.
const AXES_FREE_STRING = Symbol('registry-schema.AXES_FREE_STRING');
// ─── ADR-1239 "the eight negotiated axes" ────────────────────────────────────
const AXES = Object.freeze({
embeddingMode: Object.freeze(['imperative', 'declarative']),
commandSurface: Object.freeze(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only']),
dispatch: AXES_FREE_STRING,
modelMode: Object.freeze(['active', 'passive']),
hookBus: Object.freeze(['host', 'engine', 'none']),
stateIO: Object.freeze(['filesystem', 'sandboxed-storage', 'session-log-append']),
transport: Object.freeze(['mcp', 'native-extension']),
runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']),
});
// ─── Required top-level fields ───────────────────────────────────────────────
const CAPABILITY_REQUIRED = Object.freeze([
'id',
'name',
'type',
'repo',
'description',
'author',
'license',
'enginesGsd',
'install',
'uninstall',
'interactions',
'discussion',
]);
const EOS_REQUIRED = Object.freeze([
'id',
'name',
'type',
'repo',
'description',
'author',
'license',
'enginesGsd',
'install',
'uninstall',
'interactions',
'discussion',
'protocolVersion',
]);
// Escape Markdown inline metacharacters in UNTRUSTED free text so a registry
// entry cannot inject links/tables/code-spans into the generated catalog.
// Neutralizes: link hijack ([ ] ( )), table breakout (|), code span (`),
// and backslash. Newlines are collapsed to a single space (inline contexts).
function mdInline(value) {
return String(value).replace(/[\\`*_[\]()|~<>]/g, '\\$&').replace(/[\r\n]+/g, ' ');
}
// A fenced-code fence guaranteed longer than any backtick run in `value`, so a
// value containing ``` cannot escape the block (CommonMark rule). Min length 3.
function fenceFor(value) {
const runs = String(value).match(/`+/g) || [];
const longest = runs.reduce((m, r) => Math.max(m, r.length), 0);
return '`'.repeat(Math.max(3, longest + 1));
}
// A single `engines.gsd` range clause: optional comparison operator, optional
// leading `v`, exactly three dot-separated numeric segments, optional
// prerelease (`-...`) and build (`+...`) suffixes. Operator alternation order
// matters — `>=`/`<=` must be tried before `>`/`<` or the longer operator
// would never match.
const GSD_RANGE_CLAUSE_RE = /^(>=|<=|>|<|=|\^|~)?v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
/**
* Validate the SHAPE of an `engines.gsd`-style semver range string (ADR-1244
* D1). Self-contained — no `semver` dependency, modelled on the constraint
* parsing in `scripts/check-env.cjs` (`satisfiesConstraint`), but this
* function validates that the range is well-formed rather than comparing it
* against a concrete version.
*
* @param {string} range
* @returns {boolean}
*/
function isValidGsdRange(range) {
if (typeof range !== 'string') return false;
const trimmed = range.trim();
if (trimmed === '') return false;
if (trimmed === '*') return true;
const clauses = trimmed.split(/\s+/);
return clauses.length > 0 && clauses.every((clause) => clause !== '' && GSD_RANGE_CLAUSE_RE.test(clause));
}
/**
* Validate the `interactions` sub-object for a capability entry.
*
* @param {object} interactions
* @param {(field: string, reason: string) => void} addError
* @returns {void}
*/
function validateCapabilityInteractions(interactions, addError) {
const allowedKeys = new Set([
'loopExtensionPoints',
'hookKinds',
'configKeys',
'requires',
'runtimeCompat',
'produces',
'consumes',
]);
for (const key of Object.keys(interactions)) {
if (!allowedKeys.has(key)) addError(`interactions.${key}`, 'unknown field');
}
for (const field of ['loopExtensionPoints', 'hookKinds']) {
if (interactions[field] === undefined) addError(`interactions.${field}`, 'missing required field');
}
if (interactions.loopExtensionPoints !== undefined) {
const v = interactions.loopExtensionPoints;
if (!Array.isArray(v) || v.length === 0 || !v.every((x) => LOOP_POINTS.includes(x))) {
addError('interactions.loopExtensionPoints', 'must be a non-empty array of valid loop extension points');
}
}
if (interactions.hookKinds !== undefined) {
const v = interactions.hookKinds;
if (!Array.isArray(v) || !v.every((x) => HOOK_KINDS.includes(x))) {
addError('interactions.hookKinds', 'must be an array of valid hook kinds');
}
}
for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
if (interactions[field] === undefined) continue;
const v = interactions[field];
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
addError(`interactions.${field}`, 'must be an array of strings');
}
}
}
/**
* Validate the `interactions` sub-object for an eos entry.
*
* @param {object} interactions
* @param {(field: string, reason: string) => void} addError
* @returns {void}
*/
function validateEosInteractions(interactions, addError) {
const allowedKeys = new Set(['interfacePoints', 'profile', 'axes']);
for (const key of Object.keys(interactions)) {
if (!allowedKeys.has(key)) addError(`interactions.${key}`, 'unknown field');
}
for (const field of ['interfacePoints', 'profile', 'axes']) {
if (interactions[field] === undefined) addError(`interactions.${field}`, 'missing required field');
}
if (interactions.interfacePoints !== undefined) {
const v = interactions.interfacePoints;
if (!Array.isArray(v) || v.length === 0 || !v.every((x) => INTERFACE_POINTS.includes(x))) {
addError('interactions.interfacePoints', 'must be a non-empty array of valid interface points');
}
}
if (interactions.profile !== undefined) {
if (typeof interactions.profile !== 'string' || !PROFILES.includes(interactions.profile)) {
addError('interactions.profile', 'must be one of the valid negotiation profiles');
}
}
if (interactions.axes !== undefined) {
const axes = interactions.axes;
if (typeof axes !== 'object' || axes === null || Array.isArray(axes)) {
addError('interactions.axes', 'axes must be an object');
} else {
const expectedKeys = Object.keys(AXES);
const actualKeys = Object.keys(axes);
const actualKeySet = new Set(actualKeys);
const keysMatch = expectedKeys.length === actualKeys.length && expectedKeys.every((k) => actualKeySet.has(k));
if (!keysMatch) {
addError('interactions.axes', 'axes key set must exactly match the eight negotiated axes');
} else {
for (const key of expectedKeys) {
const allowedValues = AXES[key];
const v = axes[key];
if (allowedValues === AXES_FREE_STRING) {
if (typeof v !== 'string' || v.trim() === '') {
addError(`interactions.axes.${key}`, 'must be a non-empty string');
} else if (v.length > 300) {
addError(`interactions.axes.${key}`, 'exceeds max length 300');
}
} else if (typeof v !== 'string' || !allowedValues.includes(v)) {
addError(`interactions.axes.${key}`, `must be one of the allowed values for ${key}`);
}
}
}
}
}
}
/**
* Validate an array of registry entries against the closed schema for
* `opts.type` ('capability' | 'eos').
*
* @param {object[]} entries
* @param {{type: 'capability'|'eos'}} opts
* @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
*/
function validateEntries(entries, opts) {
if (!Array.isArray(entries)) {
return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'entries must be an array' }] };
}
// Entry-count cap: a pathologically large array (e.g. from an automated or
// malicious PR) is rejected wholesale rather than validated entry-by-entry.
if (entries.length > 2000) {
return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'too many entries (max 2000)' }] };
}
const required = opts.type === 'eos' ? EOS_REQUIRED : CAPABILITY_REQUIRED;
const requiredSet = new Set(required);
const seenIds = new Set();
const errors = [];
entries.forEach((entry, index) => {
const addError = (field, reason) => {
const err = { index, field, reason };
if (entry && typeof entry === 'object' && typeof entry.id === 'string') err.id = entry.id;
errors.push(err);
};
// Null/non-object element guard — a malformed array element (null,
// undefined-via-hole, a primitive, or an array) cannot be destructured by
// the field checks below, so reject it outright rather than throwing.
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
addError('(entry)', 'entry must be a JSON object');
return;
}
for (const key of Object.keys(entry)) {
if (!requiredSet.has(key)) addError(key, 'unknown field');
}
const missing = new Set();
for (const field of required) {
if (entry[field] === undefined) {
addError(field, 'missing required field');
missing.add(field);
}
}
// Control-character rejection (defense in depth): `allowTabNewline` widens
// the reject-set exception for the two shell-snippet fields (install/
// uninstall), which legitimately contain tabs/newlines; every other free
// text field disallows ALL C0 control characters plus DEL (incl. \n/\t).
// Checked via char codes (not a literal control-char regex range) — same
// approach as capability-validator.cjs's hooks[].matcher check, which
// avoids tripping ESLint's no-control-regex rule.
const hasDisallowedControlChar = (v, allowTabNewline) => {
for (let c = 0; c < v.length; c += 1) {
const code = v.charCodeAt(c);
if (allowTabNewline && (code === 0x09 || code === 0x0a)) continue;
if (code < 0x20 || code === 0x7f) return true;
}
return false;
};
const checkNoControlChars = (field, allowTabNewline) => {
if (missing.has(field)) return;
const v = entry[field];
if (typeof v !== 'string') return;
if (hasDisallowedControlChar(v, allowTabNewline)) addError(field, 'must not contain control characters');
};
// Length cap: reject oversized fields (untrusted third-party input feeding
// a committed Markdown catalog should not be allowed to blow up the doc).
const checkMaxLength = (field, max) => {
if (missing.has(field)) return;
const v = entry[field];
if (typeof v === 'string' && v.length > max) addError(field, `exceeds max length ${max}`);
};
if (!missing.has('id')) {
const id = entry.id;
if (typeof id !== 'string' || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id)) {
addError('id', 'id must be kebab-case');
}
if (seenIds.has(id)) {
addError('id', `duplicate id: ${id}`);
} else {
seenIds.add(id);
}
}
checkMaxLength('id', 100);
for (const field of ['name', 'description', 'author']) {
if (missing.has(field)) continue;
const v = entry[field];
if (typeof v !== 'string' || v.trim() === '') addError(field, 'must be a non-empty string');
checkNoControlChars(field, false);
}
checkMaxLength('name', 120);
checkMaxLength('author', 120);
checkMaxLength('description', 1000);
if (!missing.has('type') && entry.type !== opts.type) {
addError('type', `type must be "${opts.type}"`);
}
if (!missing.has('repo')) {
if (typeof entry.repo !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(entry.repo)) {
addError('repo', 'repo must be in "owner/repo" form');
}
}
checkMaxLength('repo', 100);
if (!missing.has('license')) {
const v = entry.license;
if (typeof v !== 'string' || v.trim() === '' || !/^[A-Za-z0-9.+()\- ]+$/.test(v)) {
addError('license', 'license must be a non-empty SPDX-like string');
}
}
checkMaxLength('license', 120);
if (!missing.has('enginesGsd') && !isValidGsdRange(entry.enginesGsd)) {
addError('enginesGsd', 'enginesGsd must be a valid semver range');
}
checkMaxLength('enginesGsd', 100);
for (const field of ['install', 'uninstall']) {
if (missing.has(field)) continue;
const v = entry[field];
if (typeof v !== 'string' || v.trim() === '') addError(field, 'must be a non-empty string');
checkNoControlChars(field, true);
}
checkMaxLength('install', 2000);
checkMaxLength('uninstall', 2000);
if (!missing.has('discussion')) {
const v = entry.discussion;
if (typeof v !== 'string' || !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/discussions\/\d+$/.test(v)) {
addError('discussion', 'discussion must be a GitHub discussions URL');
}
}
checkMaxLength('discussion', 300);
if (!missing.has('interactions')) {
const interactions = entry.interactions;
if (typeof interactions !== 'object' || interactions === null || Array.isArray(interactions)) {
addError('interactions', 'interactions must be an object');
} else if (opts.type === 'eos') {
validateEosInteractions(interactions, addError);
} else {
validateCapabilityInteractions(interactions, addError);
}
}
if (opts.type === 'eos' && !missing.has('protocolVersion')) {
if (!Number.isInteger(entry.protocolVersion) || entry.protocolVersion < 1) {
addError('protocolVersion', 'protocolVersion must be an integer >= 1');
}
}
});
return { ok: errors.length === 0, errors };
}
/**
* Render the deterministic Markdown document for a registry.
*
* @param {object[]} entries
* @param {{type: 'capability'|'eos', sourceFile?: string}} opts
* @returns {string}
*/
function renderMarkdown(entries, opts) {
const sorted = [...entries].sort((a, b) => {
if (a.id < b.id) return -1;
if (a.id > b.id) return 1;
return 0;
});
const isEos = opts.type === 'eos';
const lines = [];
lines.push(
`<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/${opts.sourceFile} — do not edit by hand; run \`npm run gen:registry\` -->`,
);
lines.push('');
lines.push(isEos ? '# GSD EoS Registry' : '# GSD Community Capability Registry');
lines.push('');
lines.push(
"> **Not an endorsement.** Inclusion means only that a maintainer merged a PR linking the author's repository — GSD has not reviewed, tested, or verified any listing. See the [registry README](./README.md).",
);
lines.push('');
lines.push(`_To add your ${isEos ? 'integration' : 'capability'}, see the [registry README](./README.md)._`);
lines.push('');
if (sorted.length === 0) {
lines.push('_No entries yet — be the first: see [README](./README.md)._');
return `${lines.join('\n')}\n`;
}
lines.push('| Name | What it is | Latest release | GSD compat | Discussion |');
lines.push('|---|---|---|---|---|');
for (const entry of sorted) {
// entry.repo/enginesGsd/discussion are regex-constrained (validateEntries)
// and used as link DESTINATIONS / badge URLs here — never mdInline those,
// it would corrupt the URL. entry.name/description are untrusted free-text
// link TEXT / body copy and MUST be escaped.
lines.push(
`| [${mdInline(entry.name)}](https://github.com/${entry.repo}) | ${mdInline(entry.description)} | ` +
`![release](https://img.shields.io/github/v/release/${entry.repo}?sort=semver&include_prereleases) | ` +
`\`${entry.enginesGsd}\` | [discuss](${entry.discussion}) |`,
);
}
lines.push('');
sorted.forEach((entry, i) => {
const interactions = entry.interactions || {};
lines.push(`## ${mdInline(entry.name)}`);
lines.push(
`- **Repository:** https://github.com/${entry.repo} — [latest release](https://github.com/${entry.repo}/releases/latest)`,
);
lines.push(`- **What it is:** ${mdInline(entry.description)}`);
lines.push(`- **Author:** ${mdInline(entry.author)}`);
if (isEos) {
const axesSummary = Object.keys(AXES)
.map((key) => `${key}=${interactions.axes ? interactions.axes[key] : undefined}`)
.join(', ');
const summary =
`Interface points: ${(interactions.interfacePoints || []).join(', ')}; ` +
`profile: ${interactions.profile}; protocol v${entry.protocolVersion}; axes: ${axesSummary}`;
// Single mdInline pass over the fully-assembled summary: none of the
// literal separator text above contains Markdown metacharacters, so
// this equally neutralizes every embedded free-text/vocab value
// (notably interactions.axes.dispatch, a free-form untrusted string).
lines.push(`- **Every interaction with GSD:** ${mdInline(summary)}`);
} else {
let summary =
`Loop Extension Points: ${(interactions.loopExtensionPoints || []).join(', ')}; ` +
`hook kinds: ${(interactions.hookKinds || []).join(', ')}`;
for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
const v = interactions[field];
if (Array.isArray(v) && v.length > 0) summary += `; ${field}: ${v.join(', ')}`;
}
// configKeys/requires/runtimeCompat/produces/consumes are untrusted
// free-form strings (schema only requires "array of strings") — same
// single-pass mdInline rationale as the eos branch above.
lines.push(`- **Every interaction with GSD:** ${mdInline(summary)}`);
}
// Code-span content (install/uninstall) is NOT mdInline-escaped — it is a
// verbatim shell snippet, not inline prose. Instead each block picks a
// fence strictly longer than any backtick run inside its own content, so
// an embedded ``` cannot prematurely close the fence (CommonMark rule).
const installFence = fenceFor(entry.install);
lines.push('- **Install:**');
lines.push(`${installFence}sh`);
lines.push(entry.install);
lines.push(installFence);
const uninstallFence = fenceFor(entry.uninstall);
lines.push('- **Uninstall:**');
lines.push(`${uninstallFence}sh`);
lines.push(entry.uninstall);
lines.push(uninstallFence);
lines.push(
isEos
? `- **GSD compatibility:** \`${entry.enginesGsd}\`, protocol v${entry.protocolVersion}`
: `- **GSD compatibility:** \`${entry.enginesGsd}\``,
);
lines.push(`- **License:** ${mdInline(entry.license)}`);
lines.push(`- **Discussion / ranking:** ${entry.discussion}`);
if (i < sorted.length - 1) lines.push('');
});
return `${lines.join('\n')}\n`;
}
module.exports = {
LOOP_POINTS,
HOOK_KINDS,
INTERFACE_POINTS,
PROFILES,
AXES,
AXES_FREE_STRING,
CAPABILITY_REQUIRED,
EOS_REQUIRED,
isValidGsdRange,
validateEntries,
renderMarkdown,
};

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env node
'use strict';
/**
* scripts/validate-registry.cjs — CLI validator for the third-party
* discoverability catalogs (issue #2182):
*
* - docs/registries/capabilities.json ("GSD Community Capability Registry")
* - docs/registries/eos.json ("GSD EoS Registry", PR2 — optional
* until that JSON file ships)
*
* Validates each source's JSON array against the closed schema in
* scripts/registry-schema.cjs (validateEntries). Human-readable errors go to
* stderr; `--json` additionally prints a structured verdict to stdout.
*
* Usage:
* node scripts/validate-registry.cjs # human-readable report
* node scripts/validate-registry.cjs --json # structured JSON verdict
*
* Exit codes:
* 0 every present source's entries are all valid
* 1 one or more entries failed validation (or a source's JSON is malformed)
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const { validateEntries } = require('./registry-schema.cjs');
// Resolved relative to process.cwd() (not __dirname) so the CLI validates
// whichever project it is invoked from — this is what lets tests drive it as
// a subprocess against isolated temp-fixture directories via `cwd`.
const SOURCES = [
{ file: 'capabilities.json', type: 'capability' },
{ file: 'eos.json', type: 'eos' },
];
/**
* Load + validate a single registry JSON file.
*
* @param {string} jsonPath absolute path to the registry JSON file
* @param {'capability'|'eos'} type
* @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
*/
function validateFile(jsonPath, type) {
let raw;
try {
raw = fs.readFileSync(jsonPath, 'utf8');
} catch (err) {
return {
ok: false,
errors: [{ index: -1, field: '<file>', reason: `could not read ${jsonPath}: ${err.message}` }],
};
}
let entries;
try {
entries = JSON.parse(raw);
} catch (err) {
return {
ok: false,
errors: [{ index: -1, field: '<file>', reason: `JSON parse error in ${jsonPath}: ${err.message}` }],
};
}
if (!Array.isArray(entries)) {
return {
ok: false,
errors: [{ index: -1, field: '<file>', reason: `${jsonPath} must be a JSON array of entries` }],
};
}
return validateEntries(entries, { type });
}
function main() {
const jsonMode = process.argv.includes('--json');
const registriesDir = path.join(process.cwd(), 'docs', 'registries');
const results = [];
let anyFailed = false;
for (const { file, type } of SOURCES) {
const jsonPath = path.join(registriesDir, file);
// eos.json is optional until PR2 ships it — skip silently when absent.
if (type === 'eos' && !fs.existsSync(jsonPath)) continue;
const verdict = validateFile(jsonPath, type);
results.push({ file, type, ok: verdict.ok, errors: verdict.errors });
if (!verdict.ok) anyFailed = true;
}
if (jsonMode) {
process.stdout.write(JSON.stringify({ ok: !anyFailed, results }, null, 2) + '\n');
} else if (anyFailed) {
process.stderr.write('\nERROR validate-registry: one or more entries failed validation\n');
for (const result of results) {
if (result.ok) continue;
process.stderr.write(`\n${result.file}:\n`);
for (const e of result.errors) {
const idPart = e.id ? ` (id: ${e.id})` : '';
process.stderr.write(` entry[${e.index}]${idPart} field "${e.field}": ${e.reason}\n`);
}
}
process.stderr.write('\n');
} else {
process.stdout.write('ok validate-registry: all entries valid\n');
}
if (anyFailed) throw new ExitError(1, 'registry validation failed');
return 0;
}
if (require.main === module) runMain(main);
module.exports = { main, validateFile, SOURCES };