From bcf1376727781b636e26c18e28956268f6c95cbf Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 11 Jul 2026 14:26:23 -0400 Subject: [PATCH] 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 --- .changeset/gallant-rams-rally.md | 5 + docs/registries/capability-registry.md | 9 + scripts/registry-schema.cjs | 326 +++++++++++++++++++++++-- tests/registry-schema.test.cjs | 16 +- 4 files changed, 338 insertions(+), 18 deletions(-) create mode 100644 .changeset/gallant-rams-rally.md create mode 100644 docs/registries/capability-registry.md diff --git a/.changeset/gallant-rams-rally.md b/.changeset/gallant-rams-rally.md new file mode 100644 index 000000000..7df9cbdf9 --- /dev/null +++ b/.changeset/gallant-rams-rally.md @@ -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) diff --git a/docs/registries/capability-registry.md b/docs/registries/capability-registry.md new file mode 100644 index 000000000..d4c0d005c --- /dev/null +++ b/docs/registries/capability-registry.md @@ -0,0 +1,9 @@ + + +# 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)._ diff --git a/scripts/registry-schema.cjs b/scripts/registry-schema.cjs index c0cfb1301..c2e6da6c7 100644 --- a/scripts/registry-schema.cjs +++ b/scripts/registry-schema.cjs @@ -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( + ``, + ); + 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 = { diff --git a/tests/registry-schema.test.cjs b/tests/registry-schema.test.cjs index b0452f06b..5e12111e2 100644 --- a/tests/registry-schema.test.cjs +++ b/tests/registry-schema.test.cjs @@ -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`); }, ), );