feat(#2182): implement community capability registry validation + generation

Implements the three pure functions in registry-schema.cjs (isValidGsdRange,
validateEntries, renderMarkdown) that were stubbed in the previous commit, turning
the red suite green: strict per-type schema validation (capability + eos), a
self-contained engines.gsd range validator (no semver dep), and deterministic
Markdown generation with shields.io release badges + per-entry Discussion links.
Regenerates docs/registries/capability-registry.md and adds the Added changeset.

Also hardens the isValidGsdRange fast-check property (letters-only major) so it
cannot intermittently generate a valid prerelease range and flake.

Closes #2182

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-11 14:26:23 -04:00
parent 60e3c4988a
commit bcf1376727
4 changed files with 338 additions and 18 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 0
---
**Discover third-party GSD Capabilities in a new Community Capability Registry.** — A non-endorsing discoverability catalog where authors register a Capability via a documentation PR; each entry carries a live latest-release badge and a per-entry GitHub Discussion for community ranking and comments. (#0)

View File

@@ -0,0 +1,9 @@
<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/capabilities.json — do not edit by hand; run `npm run gen:registry` -->
# GSD Community Capability Registry
> **Not an endorsement.** Inclusion means only that a maintainer merged a PR linking the author's repository — GSD has not reviewed, tested, or verified any listing. See the [registry README](./README.md).
_To add your capability, see the [registry README](./README.md)._
_No entries yet — be the first: see [README](./README.md)._

View File

@@ -122,6 +122,13 @@ const EOS_REQUIRED = Object.freeze([
'protocolVersion',
]);
// 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
@@ -129,37 +136,328 @@ const EOS_REQUIRED = Object.freeze([
* function validates that the range is well-formed rather than comparing it
* against a concrete version.
*
* @param {string} _range
* @param {string} range
* @returns {boolean}
*/
function isValidGsdRange(_range) {
return true;
// TODO(commit-B): real implementation per DESIGN.md
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 (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
* @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) {
return { ok: true, errors: [] };
// TODO(commit-B): real implementation per DESIGN.md
function validateEntries(entries, opts) {
if (!Array.isArray(entries)) {
return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'entries must be an array' }] };
}
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 (typeof entry.id === 'string') err.id = entry.id;
errors.push(err);
};
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);
}
}
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);
}
}
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');
}
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');
}
}
if (!missing.has('license')) {
const v = entry.license;
if (typeof v !== 'string' || v.trim() === '' || !/^[A-Za-z0-9.+()\-\s]+$/.test(v)) {
addError('license', 'license must be a non-empty SPDX-like string');
}
}
if (!missing.has('enginesGsd') && !isValidGsdRange(entry.enginesGsd)) {
addError('enginesGsd', 'enginesGsd must be a valid semver range');
}
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');
}
if (!missing.has('discussion')) {
const v = entry.discussion;
if (typeof v !== 'string' || !/^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/discussions\/\d+$/.test(v)) {
addError('discussion', 'discussion must be a GitHub discussions URL');
}
}
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
* @param {object[]} entries
* @param {{type: 'capability'|'eos', sourceFile?: string}} opts
* @returns {string}
*/
function renderMarkdown(_entries, _opts) {
return '';
// TODO(commit-B): real implementation per DESIGN.md
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) {
lines.push(
`| [${entry.name}](https://github.com/${entry.repo}) | ${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(`## ${entry.name}`);
lines.push(
`- **Repository:** https://github.com/${entry.repo} — [latest release](https://github.com/${entry.repo}/releases/latest)`,
);
lines.push(`- **What it is:** ${entry.description}`);
if (isEos) {
const axesSummary = Object.keys(AXES)
.map((key) => `${key}=${interactions.axes ? interactions.axes[key] : undefined}`)
.join(', ');
lines.push(
`- **Every interaction with GSD:** Interface points: ${(interactions.interfacePoints || []).join(', ')}; ` +
`profile: ${interactions.profile}; protocol v${entry.protocolVersion}; axes: ${axesSummary}`,
);
} 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(', ')}`;
}
lines.push(`- **Every interaction with GSD:** ${summary}`);
}
lines.push('- **Install:**');
lines.push('```sh');
lines.push(entry.install);
lines.push('```');
lines.push('- **Uninstall:**');
lines.push('```sh');
lines.push(entry.uninstall);
lines.push('```');
lines.push(
isEos
? `- **GSD compatibility:** \`${entry.enginesGsd}\`, protocol v${entry.protocolVersion}`
: `- **GSD compatibility:** \`${entry.enginesGsd}\``,
);
lines.push(`- **License:** ${entry.license}`);
lines.push(`- **Discussion / ranking:** ${entry.discussion}`);
if (i < sorted.length - 1) lines.push('');
});
return `${lines.join('\n')}\n`;
}
module.exports = {

View File

@@ -363,12 +363,20 @@ describe('isValidGsdRange', () => {
);
});
test('fast-check property: strings with letters where digits are expected are invalid', () => {
test('fast-check property: a non-numeric major segment is always invalid', () => {
// Replacing a numeric segment with letters can never be a well-formed range.
// Letters-only (not arbitrary garbage) keeps the generator from accidentally
// producing a valid semver-with-prerelease like `1.2.3-rc` — `>=1.2.3-rc.0.0`
// IS a legitimate prerelease range the validator accepts, which would make an
// "always invalid" assertion intermittently fail (a hidden flake).
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 8 }).filter((s) => /[A-Za-z]/.test(s) && !/^\s*$/.test(s)),
(garbage) => {
assert.equal(isValidGsdRange(`>=${garbage}.0.0`), false, garbage);
fc.constantFrom('', '>=', '>', '<=', '<', '=', '^', '~'),
fc
.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), { minLength: 1, maxLength: 6 })
.map((chars) => chars.join('')),
(op, letters) => {
assert.equal(isValidGsdRange(`${op}${letters}.0.0`), false, `${op}${letters}.0.0`);
},
),
);