* 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:
@@ -41,6 +41,63 @@ function validCapabilityEntry() {
|
||||
};
|
||||
}
|
||||
|
||||
function validEosEntry() {
|
||||
return {
|
||||
id: 'my-host-plugin',
|
||||
name: 'My Host Plugin',
|
||||
type: 'eos',
|
||||
repo: 'octocat/my-host-plugin',
|
||||
description: 'Embeds GSD as an orchestration engine in My Host.',
|
||||
author: 'Octocat',
|
||||
license: 'MIT',
|
||||
enginesGsd: '>=1.6.0 <3.0.0',
|
||||
install: 'See the My Host plugin marketplace listing.',
|
||||
uninstall: 'Uninstall via the My Host plugin manager.',
|
||||
protocolVersion: 1,
|
||||
interactions: {
|
||||
interfacePoints: ['command', 'state'],
|
||||
profile: 'programmatic-cli',
|
||||
axes: {
|
||||
embeddingMode: 'imperative',
|
||||
commandSurface: 'slash-file',
|
||||
dispatch: 'Supports nested background dispatch up to depth 3.',
|
||||
modelMode: 'active',
|
||||
hookBus: 'host',
|
||||
stateIO: 'filesystem',
|
||||
transport: 'mcp',
|
||||
runtime: 'node',
|
||||
},
|
||||
},
|
||||
discussion: 'https://github.com/octocat/my-host-plugin/discussions/2',
|
||||
};
|
||||
}
|
||||
|
||||
function validReviewerEntry() {
|
||||
return {
|
||||
id: 'my-reviewer',
|
||||
name: 'My Reviewer',
|
||||
type: 'reviewer',
|
||||
repo: 'octocat/my-reviewer',
|
||||
description: 'Reviews GSD PRs for a specific concern.',
|
||||
author: 'Octocat',
|
||||
license: 'MIT',
|
||||
enginesGsd: '>=1.6.0 <3.0.0',
|
||||
install: 'gsd capability install https://github.com/octocat/my-reviewer.git#v1.0.0',
|
||||
uninstall: 'gsd capability remove my-reviewer',
|
||||
interactions: {
|
||||
slug: 'my-reviewer',
|
||||
flags: ['--my-reviewer'],
|
||||
transport: 'spawn',
|
||||
evidenceClass: 'source-grounded',
|
||||
reviewsSection: 'My Reviewer',
|
||||
requiresBinaries: [],
|
||||
configKeys: [],
|
||||
runtimeCompat: ['all'],
|
||||
},
|
||||
discussion: 'https://github.com/octocat/my-reviewer/discussions/1',
|
||||
};
|
||||
}
|
||||
|
||||
function withFixture(entries, fn) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gen-registry-'));
|
||||
try {
|
||||
@@ -53,6 +110,22 @@ function withFixture(entries, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same as withFixture, but also writes docs/registries/reviewers.json — used
|
||||
// by the reviewer-catalog cases below (#2904), which need capabilities.json
|
||||
// AND reviewers.json present simultaneously.
|
||||
function withReviewerFixture(capabilityEntries, reviewerEntries, fn) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gen-registry-reviewer-'));
|
||||
try {
|
||||
const registriesDir = path.join(tmp, 'docs', 'registries');
|
||||
fs.mkdirSync(registriesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify(capabilityEntries, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(registriesDir, 'reviewers.json'), JSON.stringify(reviewerEntries, null, 2) + '\n');
|
||||
fn(tmp, registriesDir);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
function runGen(cwd, args = []) {
|
||||
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { cwd, encoding: 'utf8' });
|
||||
}
|
||||
@@ -142,3 +215,120 @@ describe('gen-registry: renderMarkdown (direct, via registry-schema)', () => {
|
||||
assert.ok(rendered.includes(entry.discussion));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── gen-registry CLI (subprocess): reviewer catalog (#2904) ───────────────
|
||||
//
|
||||
// scripts/gen-registry.cjs's SOURCES array does not yet include the reviewer
|
||||
// { type:'reviewer', jsonFile:'reviewers.json', mdFile:'reviewer-registry.md',
|
||||
// optional:true } entry — every case below is FAILING-FIRST against the
|
||||
// unmodified script. See
|
||||
// .gsd/phase/feat-2904-enh-registries-add-a-reviewer-entry-type/50-test-matrix.md.
|
||||
|
||||
describe('gen-registry CLI (subprocess): reviewer catalog', () => {
|
||||
test('--write emits all three catalogs when all sources exist', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gen-registry-all3-'));
|
||||
try {
|
||||
const registriesDir = path.join(tmp, 'docs', 'registries');
|
||||
fs.mkdirSync(registriesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify([validCapabilityEntry()], null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(registriesDir, 'eos.json'), JSON.stringify([validEosEntry()], null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(registriesDir, 'reviewers.json'), JSON.stringify([validReviewerEntry()], null, 2) + '\n');
|
||||
|
||||
const write = runGen(tmp, ['--write']);
|
||||
assert.equal(write.status, 0, `stderr: ${write.stderr}`);
|
||||
assert.ok(fs.existsSync(path.join(registriesDir, 'capability-registry.md')));
|
||||
assert.ok(fs.existsSync(path.join(registriesDir, 'eos-registry.md')));
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(registriesDir, 'reviewer-registry.md')),
|
||||
'expected reviewer-registry.md to be written',
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('an absent reviewers.json is skipped, not an error', () => {
|
||||
withFixture([validCapabilityEntry()], (tmp, registriesDir) => {
|
||||
const write = runGen(tmp, ['--write']);
|
||||
assert.equal(write.status, 0, `stderr: ${write.stderr}`);
|
||||
assert.ok(fs.existsSync(path.join(registriesDir, 'capability-registry.md')));
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(registriesDir, 'reviewer-registry.md')),
|
||||
'expected no reviewer-registry.md when reviewers.json is absent',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('--check fails when the reviewer catalog is missing', () => {
|
||||
withReviewerFixture([validCapabilityEntry()], [validReviewerEntry()], (tmp, registriesDir) => {
|
||||
// Write only the capability md by hand (simulating a repo that has
|
||||
// reviewers.json committed but never ran --write for it).
|
||||
fs.writeFileSync(
|
||||
path.join(registriesDir, 'capability-registry.md'),
|
||||
renderMarkdown([validCapabilityEntry()], { type: 'capability', sourceFile: 'capabilities.json' }),
|
||||
);
|
||||
const check = runGen(tmp, ['--check']);
|
||||
assert.notEqual(check.status, 0, `expected non-zero exit, got 0. stdout: ${check.stdout}`);
|
||||
assert.match(check.stderr, /reviewer-registry\.md does not exist/);
|
||||
});
|
||||
});
|
||||
|
||||
test('--check fails on reviewer catalog drift', () => {
|
||||
withReviewerFixture([validCapabilityEntry()], [validReviewerEntry()], (tmp, registriesDir) => {
|
||||
const write = runGen(tmp, ['--write']);
|
||||
assert.equal(write.status, 0, `stderr: ${write.stderr}`);
|
||||
|
||||
const mdPath = path.join(registriesDir, 'reviewer-registry.md');
|
||||
fs.appendFileSync(mdPath, '\nhand-edited drift line\n');
|
||||
|
||||
const check = runGen(tmp, ['--check']);
|
||||
assert.notEqual(check.status, 0);
|
||||
assert.match(check.stderr, /reviewer-registry\.md is stale/);
|
||||
});
|
||||
});
|
||||
|
||||
test('--check passes on a fresh reviewer catalog', () => {
|
||||
withReviewerFixture([validCapabilityEntry()], [validReviewerEntry()], (tmp) => {
|
||||
const write = runGen(tmp, ['--write']);
|
||||
assert.equal(write.status, 0, `stderr: ${write.stderr}`);
|
||||
|
||||
const check = runGen(tmp, ['--check']);
|
||||
assert.equal(check.status, 0, `stderr: ${check.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('CRLF in the committed reviewer catalog is not drift', () => {
|
||||
withReviewerFixture([validCapabilityEntry()], [validReviewerEntry()], (tmp, registriesDir) => {
|
||||
const write = runGen(tmp, ['--write']);
|
||||
assert.equal(write.status, 0, `stderr: ${write.stderr}`);
|
||||
|
||||
const mdPath = path.join(registriesDir, 'reviewer-registry.md');
|
||||
const original = fs.readFileSync(mdPath, 'utf8');
|
||||
// Normalize via \r?\n so the conversion is idempotent, ensuring the fixture is exactly
|
||||
// the CRLF variant even on a checkout that already delivered CRLF line endings.
|
||||
fs.writeFileSync(mdPath, original.replace(/\r?\n/g, '\r\n'));
|
||||
|
||||
const check = runGen(tmp, ['--check']);
|
||||
assert.equal(check.status, 0, `expected CRLF-only diff to not be drift. stderr: ${check.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed reviewers.json fails cleanly', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gen-registry-badjson-'));
|
||||
try {
|
||||
const registriesDir = path.join(tmp, 'docs', 'registries');
|
||||
fs.mkdirSync(registriesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify([validCapabilityEntry()], null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(registriesDir, 'reviewers.json'), '{ this is not valid JSON');
|
||||
|
||||
const result = runGen(tmp, ['--write']);
|
||||
assert.notEqual(result.status, 0, `expected non-zero exit, got 0. stdout: ${result.stdout}`);
|
||||
assert.ok(
|
||||
!/at Object\.<anonymous>/.test(result.stderr) && !/\.js:\d+:\d+/.test(result.stderr),
|
||||
`expected no raw Node stack trace leaked to stderr, got: ${result.stderr}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
153
tests/registry-reviewer-parity.test.cjs
Normal file
153
tests/registry-reviewer-parity.test.cjs
Normal file
@@ -0,0 +1,153 @@
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* tests/registry-reviewer-parity.test.cjs — regression coverage for issue
|
||||
* #2904 ("reviewer" registry entry type).
|
||||
*
|
||||
* `scripts/registry-schema.cjs`'s reviewer vocabulary
|
||||
* (`REVIEWER_LANE_TRANSPORTS`, `REVIEWER_EVIDENCE_CLASSES`,
|
||||
* `REVIEWER_SLUG_RE`, `REVIEWER_FLAG_RE`) and
|
||||
* `gsd-core/bin/lib/capability-validator.cjs`'s runtime reviewer-lane
|
||||
* vocabulary (`VALID_LANE_TRANSPORTS`, `VALID_EVIDENCE_CLASSES`,
|
||||
* `LANE_SLUG_RE`, `LANE_FLAG_RE`) are two independent, hand-written mirrors
|
||||
* of the same underlying grammar — the registry is a third-party
|
||||
* DISCOVERABILITY catalog (documentation-scoped), the capability-validator
|
||||
* is the RUNTIME manifest validator that actually gates what a shipped
|
||||
* `capabilities/<id>/capability.json`'s `reviewer` body may declare. Nothing
|
||||
* imports one from the other (capability-validator.cjs's own header comment,
|
||||
* `gsd-core/bin/lib/capability-validator.cjs:798-810`, explains why: the
|
||||
* canonical descriptor `LANE_SLUG_RE` mirrors lives in
|
||||
* `src/review-lane-descriptor.cts`, which compiles to gitignored build
|
||||
* output that this committed plain `.cjs` cannot depend on before
|
||||
* `npm run build:lib` has ever run) — so the two vocabularies can silently
|
||||
* drift apart with no error to read: a registry entry that faithfully
|
||||
* mirrors a real shipped lane (e.g. `lm_studio`, `llama_cpp`, `4o-mini` —
|
||||
* all real slugs that a naive kebab-only grammar would reject) would look
|
||||
* "strict but simply wrong" if the registry's copy of the slug grammar ever
|
||||
* diverged from `LANE_SLUG_RE`.
|
||||
*
|
||||
* `capability-validator.cjs:807-810` states the byte-identical requirement
|
||||
* explicitly: "A LEADING DIGIT IS PERMITTED. ... Keep the two grammars
|
||||
* byte-identical." This file is that parity guard for the reviewer registry
|
||||
* entry type, sibling in structure/intent to
|
||||
* `tests/registry-axes-parity.test.cjs` (which pins `AXES`/`OPTIONAL_AXES`
|
||||
* against `HOST_INTEGRATION_AXES`).
|
||||
*
|
||||
* Row 75 additionally reads every real, shipped `capabilities/<id>/capability.json`
|
||||
* and asserts each one's `reviewer.slug` (where present) validates against
|
||||
* `REVIEWER_SLUG_RE` — a reality check that the registry grammar isn't just
|
||||
* parity-pinned against `capability-validator.cjs` in the abstract, but
|
||||
* actually accepts every lane slug the repository ships today. This reads
|
||||
* JSON DATA files (not source), so it does not trip `local/no-source-grep`
|
||||
* and is not a source-grep-in-disguise — no `.cjs`/`.js`/`.ts` source file is
|
||||
* ever `readFileSync`'d and string-matched in this file.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const {
|
||||
REVIEWER_LANE_TRANSPORTS,
|
||||
REVIEWER_EVIDENCE_CLASSES,
|
||||
REVIEWER_SLUG_RE,
|
||||
REVIEWER_FLAG_RE,
|
||||
} = require(path.join(__dirname, '..', 'scripts', 'registry-schema.cjs'));
|
||||
|
||||
const {
|
||||
VALID_LANE_TRANSPORTS,
|
||||
VALID_EVIDENCE_CLASSES,
|
||||
LANE_SLUG_RE,
|
||||
LANE_FLAG_RE,
|
||||
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-validator.cjs'));
|
||||
|
||||
// ─── Parity: transport / evidence-class vocabularies ───────────────────────
|
||||
|
||||
describe('registry-reviewer-parity: vocab set-equality vs capability-validator.cjs', () => {
|
||||
test('registry transport vocab matches capability-validator', () => {
|
||||
const registrySet = new Set(REVIEWER_LANE_TRANSPORTS);
|
||||
assert.ok(registrySet.size > 0, 'expected REVIEWER_LANE_TRANSPORTS to be non-empty');
|
||||
assert.ok(VALID_LANE_TRANSPORTS.size > 0, 'expected VALID_LANE_TRANSPORTS to be non-empty');
|
||||
assert.deepEqual(
|
||||
[...registrySet].sort(),
|
||||
[...VALID_LANE_TRANSPORTS].sort(),
|
||||
'REVIEWER_LANE_TRANSPORTS must be set-equal to capability-validator.cjs VALID_LANE_TRANSPORTS',
|
||||
);
|
||||
});
|
||||
|
||||
test('registry evidence-class vocab matches capability-validator', () => {
|
||||
const registrySet = new Set(REVIEWER_EVIDENCE_CLASSES);
|
||||
assert.ok(registrySet.size > 0, 'expected REVIEWER_EVIDENCE_CLASSES to be non-empty');
|
||||
assert.ok(VALID_EVIDENCE_CLASSES.size > 0, 'expected VALID_EVIDENCE_CLASSES to be non-empty');
|
||||
assert.deepEqual(
|
||||
[...registrySet].sort(),
|
||||
[...VALID_EVIDENCE_CLASSES].sort(),
|
||||
'REVIEWER_EVIDENCE_CLASSES must be set-equal to capability-validator.cjs VALID_EVIDENCE_CLASSES',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Parity: slug / flag grammar — byte-identical regexes ──────────────────
|
||||
|
||||
describe('registry-reviewer-parity: grammar regexes are byte-identical to capability-validator.cjs', () => {
|
||||
test('registry slug grammar is byte-identical to LANE_SLUG_RE', () => {
|
||||
assert.equal(
|
||||
REVIEWER_SLUG_RE.source,
|
||||
LANE_SLUG_RE.source,
|
||||
'REVIEWER_SLUG_RE.source must equal LANE_SLUG_RE.source — "keep the two grammars byte-identical" (capability-validator.cjs:807-810)',
|
||||
);
|
||||
assert.equal(
|
||||
REVIEWER_SLUG_RE.flags,
|
||||
LANE_SLUG_RE.flags,
|
||||
'REVIEWER_SLUG_RE.flags must equal LANE_SLUG_RE.flags',
|
||||
);
|
||||
});
|
||||
|
||||
test('registry flag grammar is byte-identical to LANE_FLAG_RE', () => {
|
||||
assert.equal(
|
||||
REVIEWER_FLAG_RE.source,
|
||||
LANE_FLAG_RE.source,
|
||||
'REVIEWER_FLAG_RE.source must equal LANE_FLAG_RE.source',
|
||||
);
|
||||
assert.equal(
|
||||
REVIEWER_FLAG_RE.flags,
|
||||
LANE_FLAG_RE.flags,
|
||||
'REVIEWER_FLAG_RE.flags must equal LANE_FLAG_RE.flags',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Reality check: every shipped first-party lane slug validates ─────────
|
||||
|
||||
describe('registry-reviewer-parity: every first-party lane slug is accepted by the registry schema', () => {
|
||||
test('every first-party lane slug is accepted by the registry schema', () => {
|
||||
const capabilitiesDir = path.join(__dirname, '..', 'capabilities');
|
||||
const capabilityDirs = fs.readdirSync(capabilitiesDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
||||
|
||||
const collectedSlugs = [];
|
||||
for (const dirent of capabilityDirs) {
|
||||
const capabilityJsonPath = path.join(capabilitiesDir, dirent.name, 'capability.json');
|
||||
if (!fs.existsSync(capabilityJsonPath)) continue;
|
||||
const data = JSON.parse(fs.readFileSync(capabilityJsonPath, 'utf8'));
|
||||
if (data && typeof data === 'object' && data.reviewer && typeof data.reviewer.slug === 'string') {
|
||||
collectedSlugs.push({ id: dirent.name, slug: data.reviewer.slug });
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the collected set must be non-empty, or the loop below would
|
||||
// pass vacuously — a glob that silently matched nothing must fail loudly.
|
||||
assert.ok(
|
||||
collectedSlugs.length > 0,
|
||||
'expected at least one capabilities/*/capability.json with a reviewer.slug — found none',
|
||||
);
|
||||
|
||||
for (const { id, slug } of collectedSlugs) {
|
||||
assert.ok(
|
||||
REVIEWER_SLUG_RE.test(slug),
|
||||
`expected capabilities/${id}/capability.json reviewer.slug "${slug}" to match REVIEWER_SLUG_RE (${REVIEWER_SLUG_RE})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,12 @@ const {
|
||||
AXES_FREE_STRING,
|
||||
CAPABILITY_REQUIRED,
|
||||
EOS_REQUIRED,
|
||||
REVIEWER_REQUIRED,
|
||||
REVIEWER_LANE_TRANSPORTS,
|
||||
REVIEWER_EVIDENCE_CLASSES,
|
||||
REVIEWER_SECTION_MAX,
|
||||
INTERACTION_STRING_MAX,
|
||||
INTERACTION_ARRAY_MAX,
|
||||
isValidGsdRange,
|
||||
validateEntries,
|
||||
renderMarkdown,
|
||||
@@ -78,6 +84,32 @@ function validEosEntry() {
|
||||
};
|
||||
}
|
||||
|
||||
function validReviewerEntry() {
|
||||
return {
|
||||
id: 'my-reviewer',
|
||||
name: 'My Reviewer',
|
||||
type: 'reviewer',
|
||||
repo: 'octocat/my-reviewer',
|
||||
description: 'Reviews GSD PRs for a specific concern.',
|
||||
author: 'Octocat',
|
||||
license: 'MIT',
|
||||
enginesGsd: '>=1.6.0 <3.0.0',
|
||||
install: 'gsd capability install https://github.com/octocat/my-reviewer.git#v1.0.0',
|
||||
uninstall: 'gsd capability remove my-reviewer',
|
||||
interactions: {
|
||||
slug: 'my-reviewer',
|
||||
flags: ['--my-reviewer'],
|
||||
transport: 'spawn',
|
||||
evidenceClass: 'source-grounded',
|
||||
reviewsSection: 'My Reviewer',
|
||||
requiresBinaries: [],
|
||||
configKeys: [],
|
||||
runtimeCompat: ['all'],
|
||||
},
|
||||
discussion: 'https://github.com/octocat/my-reviewer/discussions/1',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Vocabulary constants ───────────────────────────────────────────────────
|
||||
|
||||
describe('registry-schema: closed vocabulary constants', () => {
|
||||
@@ -349,6 +381,18 @@ describe('renderMarkdown', () => {
|
||||
const rendered = renderMarkdown([], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
assert.match(rendered, /No entries yet/);
|
||||
});
|
||||
|
||||
test('an unknown registry type throws rather than silently rendering the capability page', () => {
|
||||
assert.throws(
|
||||
() => renderMarkdown([], { type: 'bogus-type', sourceFile: 'x.json' }),
|
||||
{ message: /bogus-type/ },
|
||||
);
|
||||
});
|
||||
|
||||
test('a recognized type still renders the capability page (guard is not unconditional)', () => {
|
||||
const rendered = renderMarkdown([], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
assert.equal(rendered.split('\n')[2], '# GSD Community Capability Registry');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isValidGsdRange ────────────────────────────────────────────────────────
|
||||
@@ -627,3 +671,728 @@ describe('validateEntries: tightened discussion/license regexes', () => {
|
||||
assert.ok(!verdict.errors.some((e) => e.field === 'license'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reviewer entry type (#2904) ────────────────────────────────────────────
|
||||
//
|
||||
// The describe blocks below cover the `reviewer` entry type: the
|
||||
// REVIEWER_REQUIRED / REVIEWER_LANE_TRANSPORTS / REVIEWER_EVIDENCE_CLASSES /
|
||||
// REVIEWER_SECTION_MAX vocabulary constants, `interactions` validation, and
|
||||
// renderMarkdown's `type: 'reviewer'` output. They were authored FAILING-FIRST
|
||||
// against the unmodified module, ahead of the implementation. See
|
||||
// .gsd/phase/feat-2904-enh-registries-add-a-reviewer-entry-type/50-test-matrix.md.
|
||||
|
||||
describe('registry-schema: reviewer vocabulary constants', () => {
|
||||
test('REVIEWER_REQUIRED lists the 12 required reviewer entry fields', () => {
|
||||
assert.deepEqual(REVIEWER_REQUIRED, [
|
||||
'id', 'name', 'type', 'repo', 'description', 'author', 'license',
|
||||
'enginesGsd', 'install', 'uninstall', 'interactions', 'discussion',
|
||||
]);
|
||||
});
|
||||
|
||||
test('reviewer vocab constants are non-empty frozen arrays', () => {
|
||||
assert.deepEqual(REVIEWER_LANE_TRANSPORTS, ['spawn', 'openai-http']);
|
||||
assert.deepEqual(REVIEWER_EVIDENCE_CLASSES, ['source-grounded', 'diff-only']);
|
||||
assert.ok(Object.isFrozen(REVIEWER_LANE_TRANSPORTS), 'expected REVIEWER_LANE_TRANSPORTS to be frozen');
|
||||
assert.ok(Object.isFrozen(REVIEWER_EVIDENCE_CLASSES), 'expected REVIEWER_EVIDENCE_CLASSES to be frozen');
|
||||
assert.equal(REVIEWER_SECTION_MAX, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: reviewer — happy path', () => {
|
||||
test('a fully-valid reviewer entry passes', () => {
|
||||
const verdict = validateEntries([validReviewerEntry()], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true);
|
||||
assert.deepEqual(verdict.errors, []);
|
||||
});
|
||||
|
||||
test('an empty reviewer array passes', () => {
|
||||
const verdict = validateEntries([], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true);
|
||||
assert.deepEqual(verdict.errors, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: reviewer — type dispatch', () => {
|
||||
test('an unknown opts.type is a root error, not a silent capability validation', () => {
|
||||
const verdict = validateEntries([validReviewerEntry()], { type: 'typo' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.deepEqual(verdict.errors, [{ index: -1, field: '(root)', reason: 'unknown registry type "typo"' }]);
|
||||
});
|
||||
|
||||
test('a capability-typed entry in the reviewer catalog fails', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.type = 'capability';
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find((e) => e.field === 'type');
|
||||
assert.ok(err, `expected a type error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, 'type must be "reviewer"');
|
||||
});
|
||||
|
||||
test('an eos-only top-level field is rejected on a reviewer entry', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.protocolVersion = 1;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find((e) => e.field === 'protocolVersion');
|
||||
assert.ok(err, `expected a protocolVersion error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, 'unknown field');
|
||||
});
|
||||
|
||||
test('a non-array under a valid type still reports the array error', () => {
|
||||
const verdict = validateEntries(null, { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.deepEqual(verdict.errors, [{ index: -1, field: '(root)', reason: 'entries must be an array' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: reviewer — interactions required-key sweep', () => {
|
||||
const REVIEWER_INTERACTIONS_KEYS = [
|
||||
'slug', 'flags', 'transport', 'evidenceClass', 'reviewsSection',
|
||||
'requiresBinaries', 'configKeys', 'runtimeCompat',
|
||||
];
|
||||
|
||||
for (const key of REVIEWER_INTERACTIONS_KEYS) {
|
||||
test(`interactions.${key} is individually required`, () => {
|
||||
const entry = validReviewerEntry();
|
||||
delete entry.interactions[key];
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find((e) => e.field === `interactions.${key}`);
|
||||
assert.ok(err, `expected interactions.${key} error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, 'missing required field');
|
||||
});
|
||||
}
|
||||
|
||||
test('multiple missing interactions keys each report once', () => {
|
||||
const entry = validReviewerEntry();
|
||||
delete entry.interactions.slug;
|
||||
delete entry.interactions.flags;
|
||||
delete entry.interactions.transport;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const missingErrors = verdict.errors.filter((e) => e.reason === 'missing required field');
|
||||
assert.equal(missingErrors.length, 3, `expected exactly 3 missing-field errors, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.deepEqual(
|
||||
missingErrors.map((e) => e.field).sort(),
|
||||
['interactions.flags', 'interactions.slug', 'interactions.transport'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an absent interactions object reports once, not nine times', () => {
|
||||
const entry = validReviewerEntry();
|
||||
delete entry.interactions;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.equal(verdict.errors.filter((e) => e.field === 'interactions').length, 1);
|
||||
assert.ok(verdict.errors.some((e) => e.field === 'interactions' && e.reason === 'missing required field'));
|
||||
assert.equal(
|
||||
verdict.errors.filter((e) => e.field.startsWith('interactions.')).length,
|
||||
0,
|
||||
`expected no interactions.* sub-errors when interactions itself is absent, got: ${JSON.stringify(verdict.errors)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a non-object interactions is rejected before key checks', () => {
|
||||
for (const bad of [null, [], 'x']) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions = bad;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false, `expected ${JSON.stringify(bad)} invalid`);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions');
|
||||
assert.ok(err, `expected interactions error for ${JSON.stringify(bad)}`);
|
||||
assert.equal(err.reason, 'interactions must be an object');
|
||||
}
|
||||
});
|
||||
|
||||
test('capability-only interactions keys are rejected on a reviewer', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.loopExtensionPoints = ['execute:pre'];
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.loopExtensionPoints');
|
||||
assert.ok(err, `expected an interactions.loopExtensionPoints error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, 'unknown field');
|
||||
});
|
||||
|
||||
test('manifest-body keys outside the 8 registry fields are rejected', () => {
|
||||
for (const key of ['probe', 'invoke']) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions[key] = {};
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false, `expected interactions.${key} to be rejected`);
|
||||
const err = verdict.errors.find((e) => e.field === `interactions.${key}`);
|
||||
assert.ok(err, `expected interactions.${key} error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, 'unknown field');
|
||||
}
|
||||
});
|
||||
|
||||
test('an unknown key does not suppress the missing-key sweep', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.bogus = 'x';
|
||||
delete entry.interactions.slug;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const bogusErr = verdict.errors.find((e) => e.field === 'interactions.bogus');
|
||||
assert.ok(bogusErr);
|
||||
assert.equal(bogusErr.reason, 'unknown field');
|
||||
const slugErr = verdict.errors.find((e) => e.field === 'interactions.slug');
|
||||
assert.ok(slugErr);
|
||||
assert.equal(slugErr.reason, 'missing required field');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: reviewer — slug grammar', () => {
|
||||
test('a kebab slug is valid', () => {
|
||||
for (const slug of ['gemini', 'a']) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.slug = slug;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true, `expected "${slug}" valid, got: ${JSON.stringify(verdict.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('an underscored lane slug is valid', () => {
|
||||
for (const slug of ['lm_studio', 'llama_cpp']) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.slug = slug;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true, `expected "${slug}" valid, got: ${JSON.stringify(verdict.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a leading-digit lane slug is valid', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.slug = '4o-mini';
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true, `expected "4o-mini" valid, got: ${JSON.stringify(verdict.errors)}`);
|
||||
});
|
||||
|
||||
test('a slug may not start with a separator', () => {
|
||||
for (const slug of ['-lead', '_lead']) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.slug = slug;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false, `expected "${slug}" invalid`);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.slug');
|
||||
assert.ok(err, `expected interactions.slug error for "${slug}"`);
|
||||
assert.equal(err.reason, 'must match the reviewer lane slug grammar');
|
||||
}
|
||||
});
|
||||
|
||||
test('a slug outside the lane grammar is rejected', () => {
|
||||
for (const slug of ['Upper', 'has space', 'dot.ted', '']) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.slug = slug;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false, `expected "${slug}" invalid`);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.slug');
|
||||
assert.ok(err, `expected interactions.slug error for "${slug}"`);
|
||||
assert.equal(err.reason, 'must match the reviewer lane slug grammar');
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-string slug is rejected without throwing', () => {
|
||||
for (const slug of [123, null]) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.slug = slug;
|
||||
let verdict;
|
||||
assert.doesNotThrow(() => {
|
||||
verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
});
|
||||
assert.equal(verdict.ok, false, `expected ${JSON.stringify(slug)} invalid`);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.slug');
|
||||
assert.ok(err);
|
||||
assert.equal(err.reason, 'must match the reviewer lane slug grammar');
|
||||
}
|
||||
});
|
||||
|
||||
test('fast-check property: any lane-grammar slug is accepted', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')),
|
||||
fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789_-'.split('')), { maxLength: 20 }),
|
||||
(first, rest) => {
|
||||
const slug = first + rest.join('');
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.slug = slug;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true, `expected "${slug}" valid, got: ${JSON.stringify(verdict.errors)}`);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('fast-check property: an out-of-grammar character always rejects', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')),
|
||||
fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789_-'.split('')), { maxLength: 10 }),
|
||||
fc.constantFrom(...'ABCDEFGHIJKLMNOPQRSTUVWXYZ .!@#$%^&*'.split('')),
|
||||
fc.nat(10),
|
||||
(first, rest, badChar, insertAt) => {
|
||||
const base = first + rest.join('');
|
||||
const pos = Math.min(insertAt, base.length);
|
||||
const slug = base.slice(0, pos) + badChar + base.slice(pos);
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.slug = slug;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false, `expected "${slug}" invalid`);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: reviewer — flags grammar', () => {
|
||||
test('a single well-formed flag is valid', () => {
|
||||
for (const flags of [['--gemini'], ['--a', '--b', '--c']]) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.flags = flags;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true, `expected ${JSON.stringify(flags)} valid, got: ${JSON.stringify(verdict.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty flags array is rejected', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.flags = [];
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.flags');
|
||||
assert.ok(err);
|
||||
assert.equal(err.reason, 'must be a non-empty array of lane CLI flags');
|
||||
});
|
||||
|
||||
test('duplicate flags are accepted — the registry is a directory, not the runtime validator', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.flags = ['--gemini', '--gemini'];
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true, `expected duplicate flags valid, got: ${JSON.stringify(verdict.errors)}`);
|
||||
});
|
||||
|
||||
test('a flag must carry the double-dash prefix', () => {
|
||||
for (const flags of [['gemini'], ['-g']]) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.flags = flags;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false, `expected ${JSON.stringify(flags)} invalid`);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.flags');
|
||||
assert.ok(err);
|
||||
assert.equal(err.reason, 'must be a non-empty array of lane CLI flags');
|
||||
}
|
||||
});
|
||||
|
||||
test('a flag outside the kebab flag grammar is rejected', () => {
|
||||
for (const flags of [['--Gemini'], ['--lm_studio']]) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.flags = flags;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false, `expected ${JSON.stringify(flags)} invalid`);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.flags');
|
||||
assert.ok(err);
|
||||
assert.equal(err.reason, 'must be a non-empty array of lane CLI flags');
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-array / non-string-element flags is rejected', () => {
|
||||
for (const flags of [[1], '--gemini']) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.flags = flags;
|
||||
let verdict;
|
||||
assert.doesNotThrow(() => {
|
||||
verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
});
|
||||
assert.equal(verdict.ok, false, `expected ${JSON.stringify(flags)} invalid`);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.flags');
|
||||
assert.ok(err);
|
||||
assert.equal(err.reason, 'must be a non-empty array of lane CLI flags');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: reviewer — transport / evidenceClass', () => {
|
||||
test('each allowed transport is accepted', () => {
|
||||
for (const transport of REVIEWER_LANE_TRANSPORTS) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.transport = transport;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true, `expected transport "${transport}" valid, got: ${JSON.stringify(verdict.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unknown transport is rejected', () => {
|
||||
for (const transport of ['SPAWN', 'http', '', 1, null, ['spawn']]) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.transport = transport;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false, `expected transport ${JSON.stringify(transport)} invalid`);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.transport');
|
||||
assert.ok(err);
|
||||
assert.equal(err.reason, 'must be one of the allowed lane transports');
|
||||
}
|
||||
});
|
||||
|
||||
test('each allowed evidence class is accepted', () => {
|
||||
for (const evidenceClass of REVIEWER_EVIDENCE_CLASSES) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.evidenceClass = evidenceClass;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true, `expected evidenceClass "${evidenceClass}" valid, got: ${JSON.stringify(verdict.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unknown evidence class is rejected', () => {
|
||||
for (const evidenceClass of ['diff', 'Source-Grounded', 1, null, ['diff-only']]) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.evidenceClass = evidenceClass;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false, `expected evidenceClass ${JSON.stringify(evidenceClass)} invalid`);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.evidenceClass');
|
||||
assert.ok(err);
|
||||
assert.equal(err.reason, 'must be one of the allowed evidence classes');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: reviewer — reviewsSection', () => {
|
||||
test('a plain reviewsSection is valid', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.reviewsSection = 'Gemini';
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true, `expected valid, got: ${JSON.stringify(verdict.errors)}`);
|
||||
});
|
||||
|
||||
test('reviewsSection at and just below the cap is valid', () => {
|
||||
for (const len of [REVIEWER_SECTION_MAX - 1, REVIEWER_SECTION_MAX]) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.reviewsSection = 'x'.repeat(len);
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true, `expected len ${len} valid, got: ${JSON.stringify(verdict.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('reviewsSection above the cap is rejected', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.reviewsSection = 'x'.repeat(REVIEWER_SECTION_MAX + 1);
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find(
|
||||
(e) => e.field === 'interactions.reviewsSection' && new RegExp(`exceeds max length ${REVIEWER_SECTION_MAX}`).test(e.reason),
|
||||
);
|
||||
assert.ok(err, `expected an exceeds-max-length error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
});
|
||||
|
||||
test('a blank reviewsSection is rejected', () => {
|
||||
for (const reviewsSection of ['', ' ', 123, null]) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.reviewsSection = reviewsSection;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false, `expected ${JSON.stringify(reviewsSection)} invalid`);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.reviewsSection');
|
||||
assert.ok(err);
|
||||
assert.equal(err.reason, 'must be a non-empty string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: reviewer — may-be-empty arrays', () => {
|
||||
test('the may-be-empty arrays accept []', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.requiresBinaries = [];
|
||||
entry.interactions.configKeys = [];
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true, `expected valid, got: ${JSON.stringify(verdict.errors)}`);
|
||||
});
|
||||
|
||||
test('runtimeCompat accepts the all wildcard', () => {
|
||||
for (const runtimeCompat of [['all'], []]) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.runtimeCompat = runtimeCompat;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, true, `expected ${JSON.stringify(runtimeCompat)} valid, got: ${JSON.stringify(verdict.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-string-array is rejected for each array field', () => {
|
||||
for (const field of ['requiresBinaries', 'configKeys', 'runtimeCompat']) {
|
||||
for (const bad of [[1], 'a', {}]) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions[field] = bad;
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false, `expected interactions.${field} = ${JSON.stringify(bad)} invalid`);
|
||||
const err = verdict.errors.find((e) => e.field === `interactions.${field}`);
|
||||
assert.ok(err, `expected interactions.${field} error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, 'must be an array of strings');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── renderMarkdown: reviewer registry ─────────────────────────────────────
|
||||
|
||||
describe('renderMarkdown: reviewer registry', () => {
|
||||
test('an empty reviewer catalog renders the reviewer heading, not the capability one', () => {
|
||||
const rendered = renderMarkdown([], { type: 'reviewer', sourceFile: 'reviewers.json' });
|
||||
assert.match(rendered, /# GSD Reviewer Lane Registry/);
|
||||
assert.ok(rendered.includes('_To add your reviewer lane, see the [registry README](./README.md)._'));
|
||||
assert.match(rendered, /No entries yet/);
|
||||
});
|
||||
|
||||
test('a reviewer entry renders its lane summary', () => {
|
||||
const entry = validReviewerEntry();
|
||||
const rendered = renderMarkdown([entry], { type: 'reviewer', sourceFile: 'reviewers.json' });
|
||||
const { slug, flags, transport, evidenceClass, reviewsSection } = entry.interactions;
|
||||
const expected =
|
||||
`Lane: ${slug}; flags: ${flags.join(', ')}; transport: ${transport}; evidence: ${evidenceClass}; ` +
|
||||
`REVIEWS.md section: ${reviewsSection}`;
|
||||
const line = rendered.split('\n').find((l) => l.startsWith('- **Every interaction with GSD:** '));
|
||||
assert.ok(line, `expected the summary bullet line, got: ${rendered}`);
|
||||
assert.ok(line.includes(expected), `expected summary to include "${expected}", got: ${line}`);
|
||||
});
|
||||
|
||||
test('empty optional arrays are omitted from the rendered summary', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.requiresBinaries = [];
|
||||
entry.interactions.configKeys = [];
|
||||
const renderedEmpty = renderMarkdown([entry], { type: 'reviewer', sourceFile: 'reviewers.json' });
|
||||
assert.ok(!renderedEmpty.includes('requiresBinaries:'));
|
||||
assert.ok(!renderedEmpty.includes('configKeys:'));
|
||||
|
||||
entry.interactions.requiresBinaries = ['ffmpeg'];
|
||||
entry.interactions.configKeys = ['review.foo'];
|
||||
const renderedPopulated = renderMarkdown([entry], { type: 'reviewer', sourceFile: 'reviewers.json' });
|
||||
assert.ok(renderedPopulated.includes('requiresBinaries: ffmpeg'));
|
||||
assert.ok(renderedPopulated.includes('configKeys: review.foo'));
|
||||
});
|
||||
|
||||
test('reviewer rendering is deterministic regardless of input order', () => {
|
||||
const a = validReviewerEntry();
|
||||
const b = { ...validReviewerEntry(), id: 'zzz-reviewer', name: 'ZZZ Reviewer' };
|
||||
const first = renderMarkdown([a, b], { type: 'reviewer', sourceFile: 'reviewers.json' });
|
||||
const second = renderMarkdown([b, a], { type: 'reviewer', sourceFile: 'reviewers.json' });
|
||||
assert.equal(first, second);
|
||||
});
|
||||
|
||||
test('untrusted reviewer free text cannot break out of the table', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.description = 'Good stuff |  | text';
|
||||
entry.interactions.reviewsSection = 'Gemini | ```evil``` <script> [x](y)';
|
||||
const rendered = renderMarkdown([entry], { type: 'reviewer', sourceFile: 'reviewers.json' });
|
||||
|
||||
assert.ok(rendered.includes('\\|'), 'expected an escaped pipe (\\|) in the rendered output');
|
||||
assert.ok(
|
||||
!rendered.includes(''),
|
||||
'expected the raw unescaped link-hijack payload to NOT appear verbatim',
|
||||
);
|
||||
assert.ok(rendered.includes('\\['), 'expected an escaped [ (\\[)');
|
||||
|
||||
const line = rendered.split('\n').find((l) => l.startsWith('- **Every interaction with GSD:** '));
|
||||
assert.ok(line, 'expected the summary bullet line');
|
||||
assert.ok(!line.includes('```evil```'), 'expected the raw backtick run in reviewsSection to be escaped');
|
||||
assert.ok(!line.includes('<script>'), 'expected angle brackets in reviewsSection to be escaped');
|
||||
assert.ok(!line.includes('](y)'), 'expected the reviewsSection link-hijack payload to be neutralized');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── renderMarkdown: capability/eos rendering unchanged by the third type ──
|
||||
|
||||
describe('renderMarkdown: capability and eos rendering are unchanged by the third type', () => {
|
||||
// Golden snapshots captured from the module BEFORE the reviewer type was
|
||||
// implemented (git rev 9f567a162, unmodified scripts/registry-schema.cjs).
|
||||
// The reviewer feature must not perturb a single byte of capability/eos
|
||||
// output — this is the regression pin for matrix row 55.
|
||||
const GOLDEN_CAPABILITY_MD = "<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/capabilities.json — do not edit by hand; run `npm run gen:registry` -->\n\n# GSD Community Capability Registry\n\n> **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).\n\n_To add your capability, see the [registry README](./README.md)._\n\n| Name | What it is | Latest release | GSD compat | Discussion |\n|---|---|---|---|---|\n| [My Capability](https://github.com/octocat/my-capability) | Does a useful thing for GSD users. |  | `>=1.6.0 <3.0.0` | [discuss](https://github.com/octocat/my-capability/discussions/1) |\n\n## My Capability\n- **Repository:** https://github.com/octocat/my-capability — [latest release](https://github.com/octocat/my-capability/releases/latest)\n- **What it is:** Does a useful thing for GSD users.\n- **Author:** Octocat\n- **Every interaction with GSD:** Loop Extension Points: execute:pre; hook kinds: step; configKeys: myCapability.enabled; runtimeCompat: all\n- **Install:**\n```sh\ngsd capability install https://github.com/octocat/my-capability.git#v1.0.0\n```\n- **Uninstall:**\n```sh\ngsd capability remove my-capability\n```\n- **GSD compatibility:** `>=1.6.0 <3.0.0`\n- **License:** MIT\n- **Discussion / ranking:** https://github.com/octocat/my-capability/discussions/1\n";
|
||||
const GOLDEN_EOS_MD = "<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/eos.json — do not edit by hand; run `npm run gen:registry` -->\n\n# GSD EoS Registry\n\n> **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).\n\n_To add your integration, see the [registry README](./README.md)._\n\n| Name | What it is | Latest release | GSD compat | Discussion |\n|---|---|---|---|---|\n| [My Host Plugin](https://github.com/octocat/my-host-plugin) | Embeds GSD as an orchestration engine in My Host. |  | `>=1.6.0 <3.0.0` | [discuss](https://github.com/octocat/my-host-plugin/discussions/2) |\n\n## My Host Plugin\n- **Repository:** https://github.com/octocat/my-host-plugin — [latest release](https://github.com/octocat/my-host-plugin/releases/latest)\n- **What it is:** Embeds GSD as an orchestration engine in My Host.\n- **Author:** Octocat\n- **Every interaction with GSD:** Interface points: command, state; profile: programmatic-cli; protocol v1; axes: embeddingMode=imperative, commandSurface=slash-file, dispatch=Supports nested background dispatch up to depth 3., modelMode=active, hookBus=host, stateIO=filesystem, transport=mcp, runtime=node\n- **Install:**\n```sh\nSee the My Host plugin marketplace listing.\n```\n- **Uninstall:**\n```sh\nUninstall via the My Host plugin manager.\n```\n- **GSD compatibility:** `>=1.6.0 <3.0.0`, protocol v1\n- **License:** MIT\n- **Discussion / ranking:** https://github.com/octocat/my-host-plugin/discussions/2\n";
|
||||
|
||||
test('capability rendering is byte-identical to the pre-reviewer-type golden output', () => {
|
||||
const rendered = renderMarkdown([validCapabilityEntry()], { type: 'capability', sourceFile: 'capabilities.json' });
|
||||
assert.equal(rendered, GOLDEN_CAPABILITY_MD);
|
||||
});
|
||||
|
||||
test('eos rendering is byte-identical to the pre-reviewer-type golden output', () => {
|
||||
const rendered = renderMarkdown([validEosEntry()], { type: 'eos', sourceFile: 'eos.json' });
|
||||
assert.equal(rendered, GOLDEN_EOS_MD);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateEntries: interactions array-of-strings hardening (control chars,
|
||||
// per-element length cap, array count cap) — capability and reviewer types ──
|
||||
|
||||
describe('validateEntries: interactions string-array fields — control characters', () => {
|
||||
test('capability interactions.configKeys element with a control char is rejected', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.interactions.configKeys = ['a\x00b'];
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.configKeys');
|
||||
assert.ok(err, `expected interactions.configKeys error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, 'must not contain control characters');
|
||||
});
|
||||
|
||||
test('reviewer interactions.requiresBinaries element with a control char is rejected', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.requiresBinaries = ['a\x00b'];
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.requiresBinaries');
|
||||
assert.ok(err, `expected interactions.requiresBinaries error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, 'must not contain control characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: interactions string-array fields — per-element length cap', () => {
|
||||
test('capability interactions.configKeys element at 199/200 chars (limit-1/limit) is valid', () => {
|
||||
for (const len of [INTERACTION_STRING_MAX - 1, INTERACTION_STRING_MAX]) {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.interactions.configKeys = ['x'.repeat(len)];
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(
|
||||
verdict.errors.some((e) => e.field === 'interactions.configKeys'),
|
||||
false,
|
||||
`expected len ${len} valid, got: ${JSON.stringify(verdict.errors)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('capability interactions.configKeys element at 201 chars (limit+1) is rejected', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.interactions.configKeys = ['x'.repeat(INTERACTION_STRING_MAX + 1)];
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.configKeys');
|
||||
assert.ok(err, `expected interactions.configKeys error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, `exceeds max length ${INTERACTION_STRING_MAX}`);
|
||||
});
|
||||
|
||||
test('reviewer interactions.requiresBinaries element at 199/200 chars (limit-1/limit) is valid', () => {
|
||||
for (const len of [INTERACTION_STRING_MAX - 1, INTERACTION_STRING_MAX]) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.requiresBinaries = ['x'.repeat(len)];
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(
|
||||
verdict.errors.some((e) => e.field === 'interactions.requiresBinaries'),
|
||||
false,
|
||||
`expected len ${len} valid, got: ${JSON.stringify(verdict.errors)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('reviewer interactions.requiresBinaries element at 201 chars (limit+1) is rejected', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.requiresBinaries = ['x'.repeat(INTERACTION_STRING_MAX + 1)];
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.requiresBinaries');
|
||||
assert.ok(err, `expected interactions.requiresBinaries error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, `exceeds max length ${INTERACTION_STRING_MAX}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: interactions string-array fields — array count cap', () => {
|
||||
test('capability interactions.configKeys at 49/50 elements (limit-1/limit) is valid', () => {
|
||||
for (const n of [INTERACTION_ARRAY_MAX - 1, INTERACTION_ARRAY_MAX]) {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.interactions.configKeys = Array.from({ length: n }, (_, i) => `k${i}`);
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(
|
||||
verdict.errors.some((e) => e.field === 'interactions.configKeys'),
|
||||
false,
|
||||
`expected ${n} elements valid, got: ${JSON.stringify(verdict.errors)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('capability interactions.configKeys at 51 elements (limit+1) is rejected', () => {
|
||||
const entry = validCapabilityEntry();
|
||||
entry.interactions.configKeys = Array.from({ length: INTERACTION_ARRAY_MAX + 1 }, (_, i) => `k${i}`);
|
||||
const verdict = validateEntries([entry], { type: 'capability' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.configKeys');
|
||||
assert.ok(err, `expected interactions.configKeys error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, `exceeds max entries ${INTERACTION_ARRAY_MAX}`);
|
||||
});
|
||||
|
||||
test('reviewer interactions.requiresBinaries at 49/50 elements (limit-1/limit) is valid', () => {
|
||||
for (const n of [INTERACTION_ARRAY_MAX - 1, INTERACTION_ARRAY_MAX]) {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.requiresBinaries = Array.from({ length: n }, (_, i) => `b${i}`);
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(
|
||||
verdict.errors.some((e) => e.field === 'interactions.requiresBinaries'),
|
||||
false,
|
||||
`expected ${n} elements valid, got: ${JSON.stringify(verdict.errors)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('reviewer interactions.requiresBinaries at 51 elements (limit+1) is rejected', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.requiresBinaries = Array.from({ length: INTERACTION_ARRAY_MAX + 1 }, (_, i) => `b${i}`);
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.requiresBinaries');
|
||||
assert.ok(err, `expected interactions.requiresBinaries error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, `exceeds max entries ${INTERACTION_ARRAY_MAX}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: reviewer interactions.reviewsSection — control characters', () => {
|
||||
test('reviewsSection containing ESC (\\x1b) is rejected', () => {
|
||||
const entry = validReviewerEntry();
|
||||
entry.interactions.reviewsSection = 'Sec\x1bRED';
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
const err = verdict.errors.find((e) => e.field === 'interactions.reviewsSection');
|
||||
assert.ok(err, `expected interactions.reviewsSection error, got: ${JSON.stringify(verdict.errors)}`);
|
||||
assert.equal(err.reason, 'must not contain control characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEntries: regression — hostile interactions entry can no longer validate', () => {
|
||||
test('the control-char/oversized-array reviewer entry from the security-review repro is rejected', () => {
|
||||
const entry = {
|
||||
id: 'x',
|
||||
name: 'X',
|
||||
type: 'reviewer',
|
||||
repo: 'o/r',
|
||||
description: 'd',
|
||||
author: 'a',
|
||||
license: 'MIT',
|
||||
enginesGsd: '>=1.6.0',
|
||||
install: 'i',
|
||||
uninstall: 'u',
|
||||
discussion: 'https://github.com/o/r/discussions/1',
|
||||
interactions: {
|
||||
slug: 'x',
|
||||
flags: ['--x'],
|
||||
transport: 'spawn',
|
||||
evidenceClass: 'diff-only',
|
||||
reviewsSection: 'Sec\x1b[31mRED\x1b[0m',
|
||||
requiresBinaries: ['bin\x00null', 'y'.repeat(5000)],
|
||||
configKeys: [],
|
||||
runtimeCompat: ['all'],
|
||||
},
|
||||
};
|
||||
const verdict = validateEntries([entry], { type: 'reviewer' });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(
|
||||
verdict.errors.some(
|
||||
(e) => e.field === 'interactions.reviewsSection' && e.reason === 'must not contain control characters',
|
||||
),
|
||||
`expected reviewsSection control-char rejection, got: ${JSON.stringify(verdict.errors)}`,
|
||||
);
|
||||
assert.ok(
|
||||
verdict.errors.some(
|
||||
(e) => e.field === 'interactions.requiresBinaries' && e.reason === 'must not contain control characters',
|
||||
),
|
||||
`expected requiresBinaries control-char rejection, got: ${JSON.stringify(verdict.errors)}`,
|
||||
);
|
||||
assert.ok(
|
||||
verdict.errors.some(
|
||||
(e) => e.field === 'interactions.requiresBinaries' && e.reason === `exceeds max length ${INTERACTION_STRING_MAX}`,
|
||||
),
|
||||
`expected requiresBinaries length rejection, got: ${JSON.stringify(verdict.errors)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,32 @@ function validCapabilityEntry() {
|
||||
};
|
||||
}
|
||||
|
||||
function validReviewerEntry() {
|
||||
return {
|
||||
id: 'my-reviewer',
|
||||
name: 'My Reviewer',
|
||||
type: 'reviewer',
|
||||
repo: 'octocat/my-reviewer',
|
||||
description: 'Reviews GSD PRs for a specific concern.',
|
||||
author: 'Octocat',
|
||||
license: 'MIT',
|
||||
enginesGsd: '>=1.6.0 <3.0.0',
|
||||
install: 'gsd capability install https://github.com/octocat/my-reviewer.git#v1.0.0',
|
||||
uninstall: 'gsd capability remove my-reviewer',
|
||||
interactions: {
|
||||
slug: 'my-reviewer',
|
||||
flags: ['--my-reviewer'],
|
||||
transport: 'spawn',
|
||||
evidenceClass: 'source-grounded',
|
||||
reviewsSection: 'My Reviewer',
|
||||
requiresBinaries: [],
|
||||
configKeys: [],
|
||||
runtimeCompat: ['all'],
|
||||
},
|
||||
discussion: 'https://github.com/octocat/my-reviewer/discussions/1',
|
||||
};
|
||||
}
|
||||
|
||||
function withFixture(entries, fn) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-validate-registry-'));
|
||||
try {
|
||||
@@ -53,6 +79,21 @@ function withFixture(entries, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
// Writes capabilities.json AND reviewers.json into an isolated fixture dir —
|
||||
// used by the reviewer-catalog cases below (#2904).
|
||||
function withReviewerFixture(capabilityEntries, reviewerEntries, fn) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-validate-registry-reviewer-'));
|
||||
try {
|
||||
const registriesDir = path.join(tmp, 'docs', 'registries');
|
||||
fs.mkdirSync(registriesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify(capabilityEntries, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(registriesDir, 'reviewers.json'), JSON.stringify(reviewerEntries, null, 2) + '\n');
|
||||
fn(tmp);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
function runValidate(cwd, args = []) {
|
||||
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { cwd, encoding: 'utf8' });
|
||||
}
|
||||
@@ -115,3 +156,150 @@ describe('validate-registry CLI (subprocess)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validate-registry CLI (subprocess): reviewer catalog (#2904) ─────────
|
||||
//
|
||||
// scripts/validate-registry.cjs's SOURCES array does not yet include
|
||||
// { file:'reviewers.json', type:'reviewer', optional:true } — every case
|
||||
// below is FAILING-FIRST against the unmodified script. See
|
||||
// .gsd/phase/feat-2904-enh-registries-add-a-reviewer-entry-type/50-test-matrix.md.
|
||||
|
||||
describe('validate-registry CLI (subprocess): reviewer catalog', () => {
|
||||
test('a valid reviewers.json passes', () => {
|
||||
withReviewerFixture([validCapabilityEntry()], [validReviewerEntry()], (tmp) => {
|
||||
const result = runValidate(tmp);
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('an invalid reviewer entry fails with a located error', () => {
|
||||
const bad = validReviewerEntry();
|
||||
delete bad.interactions.slug;
|
||||
withReviewerFixture([validCapabilityEntry()], [bad], (tmp) => {
|
||||
const result = runValidate(tmp);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /reviewers\.json/);
|
||||
assert.match(result.stderr, /interactions\.slug/);
|
||||
});
|
||||
});
|
||||
|
||||
test('an absent reviewers.json is skipped', () => {
|
||||
withFixture([validCapabilityEntry()], (tmp) => {
|
||||
const result = runValidate(tmp);
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('--json reports all three sources', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-validate-registry-all3-'));
|
||||
try {
|
||||
const registriesDir = path.join(tmp, 'docs', 'registries');
|
||||
fs.mkdirSync(registriesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify([validCapabilityEntry()], null, 2) + '\n');
|
||||
fs.writeFileSync(
|
||||
path.join(registriesDir, 'eos.json'),
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
id: 'my-host-plugin',
|
||||
name: 'My Host Plugin',
|
||||
type: 'eos',
|
||||
repo: 'octocat/my-host-plugin',
|
||||
description: 'Embeds GSD as an orchestration engine in My Host.',
|
||||
author: 'Octocat',
|
||||
license: 'MIT',
|
||||
enginesGsd: '>=1.6.0 <3.0.0',
|
||||
install: 'See the My Host plugin marketplace listing.',
|
||||
uninstall: 'Uninstall via the My Host plugin manager.',
|
||||
protocolVersion: 1,
|
||||
interactions: {
|
||||
interfacePoints: ['command', 'state'],
|
||||
profile: 'programmatic-cli',
|
||||
axes: {
|
||||
embeddingMode: 'imperative',
|
||||
commandSurface: 'slash-file',
|
||||
dispatch: 'Supports nested background dispatch up to depth 3.',
|
||||
modelMode: 'active',
|
||||
hookBus: 'host',
|
||||
stateIO: 'filesystem',
|
||||
transport: 'mcp',
|
||||
runtime: 'node',
|
||||
},
|
||||
},
|
||||
discussion: 'https://github.com/octocat/my-host-plugin/discussions/2',
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(registriesDir, 'reviewers.json'), JSON.stringify([validReviewerEntry()], null, 2) + '\n');
|
||||
|
||||
const result = runValidate(tmp, ['--json']);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(typeof parsed.ok, 'boolean');
|
||||
assert.equal(parsed.results.length, 3, `expected 3 result rows, got: ${JSON.stringify(parsed.results)}`);
|
||||
assert.ok(
|
||||
parsed.results.some((r) => r.file === 'reviewers.json' && r.type === 'reviewer'),
|
||||
`expected a reviewers.json/reviewer row, got: ${JSON.stringify(parsed.results)}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('--json with reviewers.json absent (capabilities.json + eos.json present) reports 2 rows and ok:true', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-validate-registry-noreviewer-'));
|
||||
try {
|
||||
const registriesDir = path.join(tmp, 'docs', 'registries');
|
||||
fs.mkdirSync(registriesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify([validCapabilityEntry()], null, 2) + '\n');
|
||||
fs.writeFileSync(
|
||||
path.join(registriesDir, 'eos.json'),
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
id: 'my-host-plugin',
|
||||
name: 'My Host Plugin',
|
||||
type: 'eos',
|
||||
repo: 'octocat/my-host-plugin',
|
||||
description: 'Embeds GSD as an orchestration engine in My Host.',
|
||||
author: 'Octocat',
|
||||
license: 'MIT',
|
||||
enginesGsd: '>=1.6.0 <3.0.0',
|
||||
install: 'See the My Host plugin marketplace listing.',
|
||||
uninstall: 'Uninstall via the My Host plugin manager.',
|
||||
protocolVersion: 1,
|
||||
interactions: {
|
||||
interfacePoints: ['command', 'state'],
|
||||
profile: 'programmatic-cli',
|
||||
axes: {
|
||||
embeddingMode: 'imperative',
|
||||
commandSurface: 'slash-file',
|
||||
dispatch: 'Supports nested background dispatch up to depth 3.',
|
||||
modelMode: 'active',
|
||||
hookBus: 'host',
|
||||
stateIO: 'filesystem',
|
||||
transport: 'mcp',
|
||||
runtime: 'node',
|
||||
},
|
||||
},
|
||||
discussion: 'https://github.com/octocat/my-host-plugin/discussions/2',
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
// Deliberately no reviewers.json.
|
||||
|
||||
const result = runValidate(tmp, ['--json']);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.results.length, 2, `expected 2 result rows, got: ${JSON.stringify(parsed.results)}`);
|
||||
assert.ok(!parsed.results.some((r) => r.file === 'reviewers.json'));
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user