* 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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user