Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
844 lines
37 KiB
JavaScript
844 lines
37 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* scripts/registry-schema.cjs — pure schema/vocab constants + validation +
|
|
* markdown-generation logic for the three third-party discoverability catalogs
|
|
* (issue #2182, plus #2904):
|
|
*
|
|
* - `docs/registries/capabilities.json` → "MSD Community Capability Registry"
|
|
* - `docs/registries/eos.json` → "MSD EoS Registry" (PR2)
|
|
* - `docs/registries/reviewers.json` → "MSD 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-
|
|
* registry-scoped mirror, not the runtime source of truth:
|
|
*
|
|
* - `LOOP_POINTS` mirrors ADR-857 "Loop Extension Points (the 12)"
|
|
* (docs/adr/857-capability-system.md §"Loop Extension Points (the 12)").
|
|
* The canonical runtime set lives in `src/loop-resolver.cts`
|
|
* (`CANONICAL_POINTS` / `CANONICAL_POINTS_FALLBACK`, derived from
|
|
* `loop-host-contract.cjs`) — changing that set requires updating this
|
|
* list too, since a registry entry's `loopExtensionPoints` describes
|
|
* which of those 12 points a third-party capability extends.
|
|
* - `HOOK_KINDS` mirrors ADR-857 Decision 4 "three hook kinds": `step`
|
|
* (runs as its own sequenced unit), `contribution` (injects into the
|
|
* core step's prompt/context), `gate` (checks and optionally blocks).
|
|
* - `INTERFACE_POINTS` mirrors ADR-1239 "The six interface points" (the
|
|
* Host-Integration Interface integration surface): command/workflow
|
|
* invocation, agent dispatch, model invocation, lifecycle hooks,
|
|
* state+config IO, artifact surface.
|
|
* - `PROFILES` mirrors ADR-1239 "Host-capability profiles (negotiation
|
|
* baselines)": `programmatic-cli`, `declarative-cli`, `ide`.
|
|
* - `AXES` mirrors ADR-1239 "the eight negotiated axes" (the negotiated
|
|
* capability schema exchanged at `initialize`): `embeddingMode`,
|
|
* `commandSurface`, `dispatch`, `modelMode`, `hookBus`, `stateIO`,
|
|
* `transport`, `runtime`. Seven of the eight are closed enums here;
|
|
* `dispatch` is ADR-1239's structured negotiated object
|
|
* (`{ namedDispatch, nested, maxDepth, background, subagentToolkit }`) —
|
|
* this registry accepts a free-form human summary string instead, so it
|
|
* carries the `AXES_FREE_STRING` sentinel rather than an enum array.
|
|
* `OPTIONAL_AXES` adds one further, OPTIONAL key on top of those eight:
|
|
* `effortSurface` (ADR-1239 amendment #2481). An entry may omit it
|
|
* (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` / `REVIEWER_REQUIRED` mirror the
|
|
* required top-level fields for each entry type, including `enginesMsd`
|
|
* (ADR-1244 D1 "Versioned capability manifest" — the `engines.msd`
|
|
* 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.
|
|
* `scripts/validate-registry.cjs` and `scripts/gen-registry.cjs` are the thin
|
|
* CLI wrappers that perform I/O around these functions.
|
|
*/
|
|
|
|
// ─── ADR-857 "Loop Extension Points (the 12)" ────────────────────────────────
|
|
const LOOP_POINTS = Object.freeze([
|
|
'discuss:pre',
|
|
'discuss:post',
|
|
'plan:pre',
|
|
'plan:post',
|
|
'execute:pre',
|
|
'execute:wave:pre',
|
|
'execute:wave:post',
|
|
'execute:post',
|
|
'verify:pre',
|
|
'verify:post',
|
|
'ship:pre',
|
|
'ship:post',
|
|
]);
|
|
|
|
// ─── ADR-857 Decision 4 — three hook kinds ───────────────────────────────────
|
|
const HOOK_KINDS = Object.freeze(['step', 'contribution', 'gate']);
|
|
|
|
// ─── ADR-1239 "The six interface points" ─────────────────────────────────────
|
|
const INTERFACE_POINTS = Object.freeze(['command', 'dispatch', 'model', 'hooks', 'state', 'artifact']);
|
|
|
|
// ─── ADR-1239 "Host-capability profiles (negotiation baselines)" ────────────
|
|
const PROFILES = Object.freeze(['programmatic-cli', 'declarative-cli', 'ide']);
|
|
|
|
// Sentinel marking an AXES entry as a free-form descriptive string rather than
|
|
// a closed enum array. `Array.isArray(AXES_FREE_STRING)` is false, so callers
|
|
// can branch on `Array.isArray(AXES[key])` vs `AXES[key] === AXES_FREE_STRING`
|
|
// without risking confusion with a real enum value.
|
|
const AXES_FREE_STRING = Symbol('registry-schema.AXES_FREE_STRING');
|
|
|
|
// ─── ADR-1239 "the eight negotiated axes" ────────────────────────────────────
|
|
const AXES = Object.freeze({
|
|
embeddingMode: Object.freeze(['imperative', 'declarative']),
|
|
commandSurface: Object.freeze(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only']),
|
|
dispatch: AXES_FREE_STRING,
|
|
modelMode: Object.freeze(['active', 'passive']),
|
|
hookBus: Object.freeze(['host', 'engine', 'none']),
|
|
stateIO: Object.freeze(['filesystem', 'sandboxed-storage', 'session-log-append']),
|
|
transport: Object.freeze(['mcp', 'native-extension']),
|
|
runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']),
|
|
});
|
|
|
|
// ─── ADR-1239 amendment #2481 — one additional, OPTIONAL negotiated axis ─────
|
|
// `effortSurface` was added to the runtime-descriptor vocabulary AFTER the
|
|
// original eight (`HOST_INTEGRATION_AXES.effortSurface` in
|
|
// `src/host-integration.cts`). It is kept OPTIONAL here — not folded into
|
|
// `AXES` — because registry entries mirror their upstream
|
|
// `registry/eos-entry.json` byte-for-byte, and requiring it would
|
|
// retroactively invalidate every entry published before the amendment.
|
|
// Values must match `HOST_INTEGRATION_AXES.effortSurface` exactly.
|
|
const OPTIONAL_AXES = Object.freeze({
|
|
effortSurface: Object.freeze(['argv', 'none']),
|
|
});
|
|
|
|
// ─── 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 `msd-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
|
|
// `msd-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;
|
|
|
|
// ─── 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',
|
|
'enginesMsd', 'install', 'uninstall', 'interactions', 'discussion',
|
|
]);
|
|
const CAPABILITY_REQUIRED = Object.freeze([...BASE_REQUIRED]);
|
|
const EOS_REQUIRED = Object.freeze([...BASE_REQUIRED, 'protocolVersion']);
|
|
// A lane is installed with `msd capability install`, owns a repo, a license and
|
|
// an `engines.msd` 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.
|
|
// Neutralizes: link hijack ([ ] ( )), table breakout (|), code span (`),
|
|
// and backslash. Newlines are collapsed to a single space (inline contexts).
|
|
function mdInline(value) {
|
|
return String(value).replace(/[\\`*_[\]()|~<>]/g, '\\$&').replace(/[\r\n]+/g, ' ');
|
|
}
|
|
// A fenced-code fence guaranteed longer than any backtick run in `value`, so a
|
|
// value containing ``` cannot escape the block (CommonMark rule). Min length 3.
|
|
function fenceFor(value) {
|
|
const runs = String(value).match(/`+/g) || [];
|
|
const longest = runs.reduce((m, r) => Math.max(m, r.length), 0);
|
|
return '`'.repeat(Math.max(3, longest + 1));
|
|
}
|
|
|
|
// A single `engines.msd` range clause: optional comparison operator, optional
|
|
// leading `v`, exactly three dot-separated numeric segments, optional
|
|
// prerelease (`-...`) and build (`+...`) suffixes. Operator alternation order
|
|
// matters — `>=`/`<=` must be tried before `>`/`<` or the longer operator
|
|
// would never match.
|
|
const MSD_RANGE_CLAUSE_RE = /^(>=|<=|>|<|=|\^|~)?v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
|
|
|
|
/**
|
|
* Validate the SHAPE of an `engines.msd`-style semver range string (ADR-1244
|
|
* D1). Self-contained — no `semver` dependency, modelled on the constraint
|
|
* parsing in `scripts/check-env.cjs` (`satisfiesConstraint`), but this
|
|
* function validates that the range is well-formed rather than comparing it
|
|
* against a concrete version.
|
|
*
|
|
* @param {string} range
|
|
* @returns {boolean}
|
|
*/
|
|
function isValidMsdRange(range) {
|
|
if (typeof range !== 'string') return false;
|
|
const trimmed = range.trim();
|
|
if (trimmed === '') return false;
|
|
if (trimmed === '*') return true;
|
|
const clauses = trimmed.split(/\s+/);
|
|
return clauses.length > 0 && clauses.every((clause) => clause !== '' && MSD_RANGE_CLAUSE_RE.test(clause));
|
|
}
|
|
|
|
/**
|
|
* Validate the `interactions` sub-object for a capability entry.
|
|
*
|
|
* @param {object} interactions
|
|
* @param {(field: string, reason: string) => void} addError
|
|
* @returns {void}
|
|
*/
|
|
function validateCapabilityInteractions(interactions, addError) {
|
|
const allowedKeys = new Set([
|
|
'loopExtensionPoints',
|
|
'hookKinds',
|
|
'configKeys',
|
|
'requires',
|
|
'runtimeCompat',
|
|
'produces',
|
|
'consumes',
|
|
]);
|
|
for (const key of Object.keys(interactions)) {
|
|
if (!allowedKeys.has(key)) addError(`interactions.${key}`, 'unknown field');
|
|
}
|
|
|
|
for (const field of ['loopExtensionPoints', 'hookKinds']) {
|
|
if (interactions[field] === undefined) addError(`interactions.${field}`, 'missing required field');
|
|
}
|
|
|
|
if (interactions.loopExtensionPoints !== undefined) {
|
|
const v = interactions.loopExtensionPoints;
|
|
if (!Array.isArray(v) || v.length === 0 || !v.every((x) => LOOP_POINTS.includes(x))) {
|
|
addError('interactions.loopExtensionPoints', 'must be a non-empty array of valid loop extension points');
|
|
}
|
|
}
|
|
|
|
if (interactions.hookKinds !== undefined) {
|
|
const v = interactions.hookKinds;
|
|
if (!Array.isArray(v) || !v.every((x) => HOOK_KINDS.includes(x))) {
|
|
addError('interactions.hookKinds', 'must be an array of valid hook kinds');
|
|
}
|
|
}
|
|
|
|
for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
|
|
if (interactions[field] === undefined) continue;
|
|
validateStringArrayField(interactions, field, addError);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate the `interactions` sub-object for an eos entry.
|
|
*
|
|
* @param {object} interactions
|
|
* @param {(field: string, reason: string) => void} addError
|
|
* @returns {void}
|
|
*/
|
|
function validateEosInteractions(interactions, addError) {
|
|
const allowedKeys = new Set(['interfacePoints', 'profile', 'axes']);
|
|
for (const key of Object.keys(interactions)) {
|
|
if (!allowedKeys.has(key)) addError(`interactions.${key}`, 'unknown field');
|
|
}
|
|
|
|
for (const field of ['interfacePoints', 'profile', 'axes']) {
|
|
if (interactions[field] === undefined) addError(`interactions.${field}`, 'missing required field');
|
|
}
|
|
|
|
if (interactions.interfacePoints !== undefined) {
|
|
const v = interactions.interfacePoints;
|
|
if (!Array.isArray(v) || v.length === 0 || !v.every((x) => INTERFACE_POINTS.includes(x))) {
|
|
addError('interactions.interfacePoints', 'must be a non-empty array of valid interface points');
|
|
}
|
|
}
|
|
|
|
if (interactions.profile !== undefined) {
|
|
if (typeof interactions.profile !== 'string' || !PROFILES.includes(interactions.profile)) {
|
|
addError('interactions.profile', 'must be one of the valid negotiation profiles');
|
|
}
|
|
}
|
|
|
|
if (interactions.axes !== undefined) {
|
|
const axes = interactions.axes;
|
|
if (typeof axes !== 'object' || axes === null || Array.isArray(axes)) {
|
|
addError('interactions.axes', 'axes must be an object');
|
|
} else {
|
|
const requiredKeys = Object.keys(AXES);
|
|
const optionalKeys = Object.keys(OPTIONAL_AXES);
|
|
const actualKeys = Object.keys(axes);
|
|
const actualKeySet = new Set(actualKeys);
|
|
|
|
// Every AXES key is mandatory; an extra key is tolerated ONLY when it is
|
|
// a recognized OPTIONAL_AXES key (currently just `effortSurface`) — any
|
|
// other extra key is still rejected as unknown.
|
|
const missingRequiredKeys = requiredKeys.filter((k) => !actualKeySet.has(k));
|
|
const unknownKeys = actualKeys.filter((k) => !requiredKeys.includes(k) && !optionalKeys.includes(k));
|
|
|
|
if (missingRequiredKeys.length > 0) {
|
|
addError('interactions.axes', `axes is missing required key(s): ${missingRequiredKeys.join(', ')}`);
|
|
}
|
|
if (unknownKeys.length > 0) {
|
|
addError('interactions.axes', `axes has unknown key(s): ${unknownKeys.join(', ')}`);
|
|
}
|
|
|
|
// Only validate individual values once the key set itself is sound —
|
|
// mirrors the original gate (values were never checked against a
|
|
// malformed key set either).
|
|
if (missingRequiredKeys.length === 0 && unknownKeys.length === 0) {
|
|
for (const key of actualKeys) {
|
|
// Inline literal guards — CodeQL barrier pattern. Reaching here already
|
|
// implies `key` is one of the nine literal axis names (the unknown-key
|
|
// gate above rejected everything else), so this is unreachable in
|
|
// practice; it is written inline anyway because CodeQL cannot follow
|
|
// that gate across the `.includes()` filter and would otherwise flag
|
|
// the bracket reads below as prototype-pollution sinks.
|
|
if (key === '__proto__') continue;
|
|
if (key === 'constructor') continue;
|
|
if (key === 'prototype') continue;
|
|
const allowedValues = Object.hasOwn(AXES, key) ? AXES[key] : OPTIONAL_AXES[key];
|
|
const v = axes[key];
|
|
if (allowedValues === AXES_FREE_STRING) {
|
|
if (typeof v !== 'string' || v.trim() === '') {
|
|
addError(`interactions.axes.${key}`, 'must be a non-empty string');
|
|
} else if (v.length > 300) {
|
|
addError(`interactions.axes.${key}`, 'exceeds max length 300');
|
|
}
|
|
} else if (typeof v !== 'string' || !allowedValues.includes(v)) {
|
|
addError(`interactions.axes.${key}`, `must be one of the allowed values for ${key}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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' | 'reviewer').
|
|
*
|
|
* @param {object[]} entries
|
|
* @param {{type: 'capability'|'eos'|'reviewer'}} opts
|
|
* @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
|
|
*/
|
|
function validateEntries(entries, opts) {
|
|
if (!Array.isArray(entries)) {
|
|
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 = rules.required;
|
|
const requiredSet = new Set(required);
|
|
const seenIds = new Set();
|
|
const errors = [];
|
|
|
|
entries.forEach((entry, index) => {
|
|
const addError = (field, reason) => {
|
|
const err = { index, field, reason };
|
|
if (entry && typeof entry === 'object' && typeof entry.id === 'string') err.id = entry.id;
|
|
errors.push(err);
|
|
};
|
|
|
|
// Null/non-object element guard — a malformed array element (null,
|
|
// undefined-via-hole, a primitive, or an array) cannot be destructured by
|
|
// the field checks below, so reject it outright rather than throwing.
|
|
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
addError('(entry)', 'entry must be a JSON object');
|
|
return;
|
|
}
|
|
|
|
for (const key of Object.keys(entry)) {
|
|
if (!requiredSet.has(key)) addError(key, 'unknown field');
|
|
}
|
|
|
|
const missing = new Set();
|
|
for (const field of required) {
|
|
if (entry[field] === undefined) {
|
|
addError(field, 'missing required field');
|
|
missing.add(field);
|
|
}
|
|
}
|
|
|
|
// 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];
|
|
if (typeof v !== 'string') return;
|
|
if (hasDisallowedControlChar(v, allowTabNewline)) addError(field, 'must not contain control characters');
|
|
};
|
|
// Length cap: reject oversized fields (untrusted third-party input feeding
|
|
// a committed Markdown catalog should not be allowed to blow up the doc).
|
|
const checkMaxLength = (field, max) => {
|
|
if (missing.has(field)) return;
|
|
const v = entry[field];
|
|
if (typeof v === 'string' && v.length > max) addError(field, `exceeds max length ${max}`);
|
|
};
|
|
|
|
if (!missing.has('id')) {
|
|
const id = entry.id;
|
|
if (typeof id !== 'string' || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id)) {
|
|
addError('id', 'id must be kebab-case');
|
|
}
|
|
if (seenIds.has(id)) {
|
|
addError('id', `duplicate id: ${id}`);
|
|
} else {
|
|
seenIds.add(id);
|
|
}
|
|
}
|
|
checkMaxLength('id', 100);
|
|
|
|
for (const field of ['name', 'description', 'author']) {
|
|
if (missing.has(field)) continue;
|
|
const v = entry[field];
|
|
if (typeof v !== 'string' || v.trim() === '') addError(field, 'must be a non-empty string');
|
|
checkNoControlChars(field, false);
|
|
}
|
|
checkMaxLength('name', 120);
|
|
checkMaxLength('author', 120);
|
|
checkMaxLength('description', 1000);
|
|
|
|
if (!missing.has('type') && entry.type !== opts.type) {
|
|
addError('type', `type must be "${opts.type}"`);
|
|
}
|
|
|
|
if (!missing.has('repo')) {
|
|
if (typeof entry.repo !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(entry.repo)) {
|
|
addError('repo', 'repo must be in "owner/repo" form');
|
|
}
|
|
}
|
|
checkMaxLength('repo', 100);
|
|
|
|
if (!missing.has('license')) {
|
|
const v = entry.license;
|
|
if (typeof v !== 'string' || v.trim() === '' || !/^[A-Za-z0-9.+()\- ]+$/.test(v)) {
|
|
addError('license', 'license must be a non-empty SPDX-like string');
|
|
}
|
|
}
|
|
checkMaxLength('license', 120);
|
|
|
|
if (!missing.has('enginesMsd') && !isValidMsdRange(entry.enginesMsd)) {
|
|
addError('enginesMsd', 'enginesMsd must be a valid semver range');
|
|
}
|
|
checkMaxLength('enginesMsd', 100);
|
|
|
|
for (const field of ['install', 'uninstall']) {
|
|
if (missing.has(field)) continue;
|
|
const v = entry[field];
|
|
if (typeof v !== 'string' || v.trim() === '') addError(field, 'must be a non-empty string');
|
|
checkNoControlChars(field, true);
|
|
}
|
|
checkMaxLength('install', 2000);
|
|
checkMaxLength('uninstall', 2000);
|
|
|
|
if (!missing.has('discussion')) {
|
|
const v = entry.discussion;
|
|
if (typeof v !== 'string' || !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/discussions\/\d+$/.test(v)) {
|
|
addError('discussion', 'discussion must be a GitHub discussions URL');
|
|
}
|
|
}
|
|
checkMaxLength('discussion', 300);
|
|
|
|
if (!missing.has('interactions')) {
|
|
const interactions = entry.interactions;
|
|
if (typeof interactions !== 'object' || interactions === null || Array.isArray(interactions)) {
|
|
addError('interactions', 'interactions must be an object');
|
|
} else {
|
|
rules.validateInteractions(interactions, addError);
|
|
}
|
|
}
|
|
|
|
if (opts.type === 'eos' && !missing.has('protocolVersion')) {
|
|
if (!Number.isInteger(entry.protocolVersion) || entry.protocolVersion < 1) {
|
|
addError('protocolVersion', 'protocolVersion must be an integer >= 1');
|
|
}
|
|
}
|
|
});
|
|
|
|
return { ok: errors.length === 0, errors };
|
|
}
|
|
|
|
// 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 MSD" 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: 'MSD 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: 'MSD 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: 'MSD 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'|'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) => {
|
|
if (a.id < b.id) return -1;
|
|
if (a.id > b.id) return 1;
|
|
return 0;
|
|
});
|
|
const isEos = opts.type === 'eos';
|
|
// An unrecognized type must fail loudly rather than silently render a
|
|
// "MSD 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(`# ${meta.title}`);
|
|
lines.push('');
|
|
lines.push(
|
|
"> **Not an endorsement.** Inclusion means only that a maintainer merged a PR linking the author's repository — MSD has not reviewed, tested, or verified any listing. See the [registry README](./README.md).",
|
|
);
|
|
lines.push('');
|
|
lines.push(`_To add your ${meta.addNoun}, see the [registry README](./README.md)._`);
|
|
lines.push('');
|
|
|
|
if (sorted.length === 0) {
|
|
lines.push('_No entries yet — be the first: see [README](./README.md)._');
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
lines.push('| Name | What it is | Latest release | MSD compat | Discussion |');
|
|
lines.push('|---|---|---|---|---|');
|
|
for (const entry of sorted) {
|
|
// entry.repo/enginesMsd/discussion are regex-constrained (validateEntries)
|
|
// and used as link DESTINATIONS / badge URLs here — never mdInline those,
|
|
// it would corrupt the URL. entry.name/description are untrusted free-text
|
|
// link TEXT / body copy and MUST be escaped.
|
|
lines.push(
|
|
`| [${mdInline(entry.name)}](https://github.com/${entry.repo}) | ${mdInline(entry.description)} | ` +
|
|
` | ` +
|
|
`\`${entry.enginesMsd}\` | [discuss](${entry.discussion}) |`,
|
|
);
|
|
}
|
|
lines.push('');
|
|
|
|
sorted.forEach((entry, i) => {
|
|
const interactions = entry.interactions || {};
|
|
|
|
lines.push(`## ${mdInline(entry.name)}`);
|
|
lines.push(
|
|
`- **Repository:** https://github.com/${entry.repo} — [latest release](https://github.com/${entry.repo}/releases/latest)`,
|
|
);
|
|
lines.push(`- **What it is:** ${mdInline(entry.description)}`);
|
|
lines.push(`- **Author:** ${mdInline(entry.author)}`);
|
|
|
|
// 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 MSD:** ${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
|
|
// fence strictly longer than any backtick run inside its own content, so
|
|
// an embedded ``` cannot prematurely close the fence (CommonMark rule).
|
|
const installFence = fenceFor(entry.install);
|
|
lines.push('- **Install:**');
|
|
lines.push(`${installFence}sh`);
|
|
lines.push(entry.install);
|
|
lines.push(installFence);
|
|
const uninstallFence = fenceFor(entry.uninstall);
|
|
lines.push('- **Uninstall:**');
|
|
lines.push(`${uninstallFence}sh`);
|
|
lines.push(entry.uninstall);
|
|
lines.push(uninstallFence);
|
|
|
|
lines.push(
|
|
isEos
|
|
? `- **MSD compatibility:** \`${entry.enginesMsd}\`, protocol v${entry.protocolVersion}`
|
|
: `- **MSD compatibility:** \`${entry.enginesMsd}\``,
|
|
);
|
|
lines.push(`- **License:** ${mdInline(entry.license)}`);
|
|
lines.push(`- **Discussion / ranking:** ${entry.discussion}`);
|
|
|
|
if (i < sorted.length - 1) lines.push('');
|
|
});
|
|
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
module.exports = {
|
|
LOOP_POINTS,
|
|
HOOK_KINDS,
|
|
INTERFACE_POINTS,
|
|
PROFILES,
|
|
AXES,
|
|
OPTIONAL_AXES,
|
|
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,
|
|
isValidMsdRange,
|
|
validateEntries,
|
|
renderMarkdown,
|
|
};
|