Files
msd-core/tests/validate-registry.test.cjs
Tom Boucher cbd180c5cd test(#3147): bound the lint/changeset/docs cluster onto the process seam (#3181)
* test(#3147): bound the lint/changeset/docs cluster onto the process seam

Migrates 69 unbounded sync spawn sites across 24 files. Allowlist 73 to 49.

Two shared helpers move: tests/helpers/graphify.cjs (6 importing suites) and
tests/fixtures/index.cjs, whose three quoted-argument shell strings became
single argv elements rather than whitespace splits.

changeset-lint's throw-native git() helper routes to gitOrThrow; migrating it
to bare runGit would have silently swallowed a failure that is loud today.
ingest-docs goes the other way -- its catch never rethrew, it degraded failure
into data every call site asserts on, so throwIfFailed would have thrown where
the original returned. The design doc said otherwise and was corrected.

tsconfig-noemit runs a real tsc --noEmit and takes a bespoke 180000ms per the
ensure-runtime-build precedent, not the 30000ms build-hooks norm -- that norm
is for a file copy, and sizing against a label rather than the work is the
same error in the opposite direction.

* test(#3147): add toLegacyResult and settle review findings

The seam exposed a throwing adapter (throwIfFailed) but no non-throwing one,
so eight files independently re-derived the same unwrap back to the legacy
{status, stdout, stderr} shape. That is the third time this epic produced N
copies of one mechanism -- seven throw wrappers in Wave 1, fifty-two timeout
constants in Wave 2, eight result adapters here. The pattern is that whenever
the seam does not expose a mechanism, every suite re-derives it.

toLegacyResult now sits beside throwIfFailed, with its own tests.

Two sites are deliberately NOT converted: changeset-cli's runRender and
runRenderIn return {status, report, stderr} from parsed JSON and never a raw
stdout, so they are a different shape family. lint-legacy-dir-name keeps its
local GUARD_TIMEOUT_MS: 30000 matches the build norm numerically but bounds a
lint probe, not hooks bundling, and importing it would encode a coincidence
as a relationship.

---------

Co-authored-by: sim <sim@local>
2026-08-07 15:45:51 -04:00

309 lines
11 KiB
JavaScript

'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const SCRIPT_PATH = path.join(__dirname, '..', 'scripts', 'validate-registry.cjs');
// validate-registry.cjs resolves docs/registries/ from process.cwd(), so
// tests drive it as a subprocess with `cwd` pointed at an isolated temp
// fixture directory — this covers main() end-to-end without touching the
// real repo's docs/registries/capabilities.json.
function validCapabilityEntry() {
return {
id: 'my-capability',
name: 'My Capability',
type: 'capability',
repo: 'octocat/my-capability',
description: 'Does a useful thing for GSD users.',
author: 'Octocat',
license: 'MIT',
enginesGsd: '>=1.6.0 <3.0.0',
install: 'gsd capability install https://github.com/octocat/my-capability.git#v1.0.0',
uninstall: 'gsd capability remove my-capability',
interactions: {
loopExtensionPoints: ['execute:pre'],
hookKinds: ['step'],
configKeys: [],
requires: [],
runtimeCompat: ['all'],
produces: [],
consumes: [],
},
discussion: 'https://github.com/octocat/my-capability/discussions/1',
};
}
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 {
const registriesDir = path.join(tmp, 'docs', 'registries');
fs.mkdirSync(registriesDir, { recursive: true });
fs.writeFileSync(path.join(registriesDir, 'capabilities.json'), JSON.stringify(entries, null, 2) + '\n');
fn(tmp);
} finally {
cleanup(tmp);
}
}
// 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 = []) {
const r = runNode([SCRIPT_PATH, ...args], { cwd, timeoutMs: PROBE_TIMEOUT_MS });
return toLegacyResult(r);
}
describe('validate-registry CLI (subprocess)', () => {
test('a good capabilities.json fixture exits 0', () => {
withFixture([validCapabilityEntry()], (tmp) => {
const result = runValidate(tmp);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
});
});
test('a bad capabilities.json fixture (missing required field) exits non-zero', () => {
const bad = validCapabilityEntry();
delete bad.discussion;
withFixture([bad], (tmp) => {
const result = runValidate(tmp);
assert.notEqual(result.status, 0);
});
});
test('a bad capabilities.json fixture (bad id) exits non-zero', () => {
const bad = validCapabilityEntry();
bad.id = 'Not_Kebab_Case';
withFixture([bad], (tmp) => {
const result = runValidate(tmp);
assert.notEqual(result.status, 0);
});
});
test('--json prints a parseable verdict for a good fixture', () => {
withFixture([validCapabilityEntry()], (tmp) => {
const result = runValidate(tmp, ['--json']);
const parsed = JSON.parse(result.stdout);
assert.equal(typeof parsed.ok, 'boolean');
assert.ok(Array.isArray(parsed.results));
assert.ok(parsed.results.some((r) => r.file === 'capabilities.json'));
});
});
test('--json prints a parseable verdict for a bad fixture', () => {
const bad = validCapabilityEntry();
delete bad.license;
withFixture([bad], (tmp) => {
const result = runValidate(tmp, ['--json']);
const parsed = JSON.parse(result.stdout);
assert.equal(typeof parsed.ok, 'boolean');
assert.equal(parsed.ok, false);
});
});
test('missing capabilities.json entirely exits non-zero', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-validate-registry-empty-'));
try {
fs.mkdirSync(path.join(tmp, 'docs', 'registries'), { recursive: true });
const result = runValidate(tmp);
assert.notEqual(result.status, 0);
} finally {
cleanup(tmp);
}
});
});
// ─── 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);
}
});
});