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:
5
.changeset/gallant-rams-rally.md
Normal file
5
.changeset/gallant-rams-rally.md
Normal 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)
|
||||
9
docs/registries/capability-registry.md
Normal file
9
docs/registries/capability-registry.md
Normal 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)._
|
||||
@@ -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} | ` +
|
||||
` | ` +
|
||||
`\`${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 = {
|
||||
|
||||
@@ -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`);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user