@opengsd/gsd-core 1.9.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,11 +2,12 @@
2
2
 
3
3
  /**
4
4
  * scripts/registry-schema.cjs — pure schema/vocab constants + validation +
5
- * markdown-generation logic for the two third-party discoverability catalogs
6
- * (issue #2182):
5
+ * markdown-generation logic for the three third-party discoverability catalogs
6
+ * (issue #2182, plus #2904):
7
7
  *
8
8
  * - `docs/registries/capabilities.json` → "GSD Community Capability Registry"
9
9
  * - `docs/registries/eos.json` → "GSD EoS Registry" (PR2)
10
+ * - `docs/registries/reviewers.json` → "GSD Reviewer Lane Registry" (issue #2904)
10
11
  *
11
12
  * The vocabulary constants below are ADDITIVE CONTRACTS that track the
12
13
  * runtime/ADR closed vocabularies they describe — they are a documentation-
@@ -41,10 +42,14 @@
41
42
  * (every entry published before the amendment stays valid) or declare
42
43
  * it as `argv` | `none`, mirroring `HOST_INTEGRATION_AXES.effortSurface`
43
44
  * in `src/host-integration.cts`.
44
- * - `CAPABILITY_REQUIRED` / `EOS_REQUIRED` mirror the required top-level
45
- * fields for each entry type, including `enginesGsd` (ADR-1244 D1
46
- * "Versioned capability manifest" — the `engines.gsd` semver-range gate,
47
- * modelled on VS Code's `engines.vscode`).
45
+ * - `CAPABILITY_REQUIRED` / `EOS_REQUIRED` / `REVIEWER_REQUIRED` mirror the
46
+ * required top-level fields for each entry type, including `enginesGsd`
47
+ * (ADR-1244 D1 "Versioned capability manifest" — the `engines.gsd`
48
+ * semver-range gate, modelled on VS Code's `engines.vscode`).
49
+ * - `REVIEWER_LANE_TRANSPORTS` / `REVIEWER_EVIDENCE_CLASSES` /
50
+ * `REVIEWER_SLUG_RE` / `REVIEWER_FLAG_RE` / `REVIEWER_SECTION_MAX` mirror
51
+ * the ADR-2782 reviewer-lane vocabulary (`capability-validator.cjs`) for
52
+ * the `reviewer` entry type's `interactions` sub-object (issue #2904).
48
53
  *
49
54
  * This module is pure — no `fs`/`process`/child-process access — so tests
50
55
  * can `require()` it directly and assert on structured return values.
@@ -107,37 +112,118 @@ const OPTIONAL_AXES = Object.freeze({
107
112
  effortSurface: Object.freeze(['argv', 'none']),
108
113
  });
109
114
 
115
+ // ─── ADR-2782 reviewer-lane vocabulary (issue #2904) ─────────────────────────
116
+ // A THIRD catalog: third-party reviewer lanes (`role: "reviewer"`, ADR-2782
117
+ // D3). A lane registers on ZERO Loop Extension Points and is forbidden from
118
+ // declaring `steps`/`contributions`/`gates`/`skills`/`agents`/`hooks`
119
+ // (`FEATURE_FIELDS_FORBIDDEN_ON_REVIEWER`, capability-validator.cjs), so the
120
+ // Capability entry's two required `interactions` fields are unsatisfiable by
121
+ // construction for a lane — hence its own entry type rather than a relaxation
122
+ // of the Capability schema.
123
+ //
124
+ // These constants are ADDITIVE CONTRACTS mirroring the canonical runtime
125
+ // vocabulary in `gsd-core/bin/lib/capability-validator.cjs`, exactly the way
126
+ // `AXES` mirrors `HOST_INTEGRATION_AXES`. They are hand-written mirrors, NOT
127
+ // imports: this module is documented pure (no `fs`/`process`), and requiring a
128
+ // `gsd-core/bin/lib` runtime module from a docs-pipeline script would invert
129
+ // that. Parity is enforced instead by `tests/registry-reviewer-parity.test.cjs`.
130
+ //
131
+ // `REVIEWER_SLUG_RE` deliberately does NOT reuse the registry's kebab-case `id`
132
+ // grammar. `LANE_SLUG_RE` permits underscores AND a leading digit —
133
+ // `lm_studio`, `llama_cpp`, `4o-mini` are real shipped lane slugs — and
134
+ // capability-validator.cjs:807-810 requires the two grammars stay
135
+ // byte-identical. A kebab-only rule here would reject well-formed entries and
136
+ // leave authors with a schema satisfiable only by lying.
137
+ const REVIEWER_LANE_TRANSPORTS = Object.freeze(['spawn', 'openai-http']);
138
+ const REVIEWER_EVIDENCE_CLASSES = Object.freeze(['source-grounded', 'diff-only']);
139
+ const REVIEWER_SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/;
140
+ // Flags are kebab even when the slug is snake: `lm_studio` → `--lm-studio`.
141
+ const REVIEWER_FLAG_RE = /^--[a-z0-9][a-z0-9-]*$/;
142
+ // Cap for the one free-text reviewer interactions field, mirroring the 300-cap
143
+ // on the equivalently free-form `axes.dispatch`. A REVIEWS.md heading is short.
144
+ const REVIEWER_SECTION_MAX = 200;
145
+
110
146
  // ─── Required top-level fields ───────────────────────────────────────────────
111
- const CAPABILITY_REQUIRED = Object.freeze([
112
- 'id',
113
- 'name',
114
- 'type',
115
- 'repo',
116
- 'description',
117
- 'author',
118
- 'license',
119
- 'enginesGsd',
120
- 'install',
121
- 'uninstall',
122
- 'interactions',
123
- 'discussion',
147
+ // The twelve fields every entry type requires. Each type's set is DERIVED from
148
+ // this one so a future shared field cannot be added to one type's list and
149
+ // silently forgotten in another (DEFECT.GENERATIVE-FIX). The three sets are
150
+ // distinct frozen arrays, not aliases, so a type may still diverge deliberately
151
+ // — as `eos` already does with `protocolVersion`.
152
+ const BASE_REQUIRED = Object.freeze([
153
+ 'id', 'name', 'type', 'repo', 'description', 'author', 'license',
154
+ 'enginesGsd', 'install', 'uninstall', 'interactions', 'discussion',
124
155
  ]);
156
+ const CAPABILITY_REQUIRED = Object.freeze([...BASE_REQUIRED]);
157
+ const EOS_REQUIRED = Object.freeze([...BASE_REQUIRED, 'protocolVersion']);
158
+ // A lane is installed with `gsd capability install`, owns a repo, a license and
159
+ // an `engines.gsd` range exactly as a Feature Capability does — so it requires
160
+ // the same twelve top-level fields. Only `interactions` differs.
161
+ const REVIEWER_REQUIRED = Object.freeze([...BASE_REQUIRED]);
162
+
163
+ // Control-character rejection (defense in depth): `allowTabNewline` widens the
164
+ // reject-set exception for the two shell-snippet fields (install/uninstall),
165
+ // which legitimately contain tabs/newlines; every other free text field
166
+ // disallows ALL C0 control characters plus DEL (incl. \n/\t). Checked via char
167
+ // codes (not a literal control-char regex range) — same approach as
168
+ // capability-validator.cjs's hooks[].matcher check, which avoids tripping
169
+ // ESLint's no-control-regex rule. Module-scope so both the top-level field
170
+ // checks inside `validateEntries` and the `interactions` sub-object
171
+ // validators (module-level functions, outside that closure) share the ONE
172
+ // implementation rather than each keeping their own copy.
173
+ function hasDisallowedControlChar(v, allowTabNewline) {
174
+ for (let c = 0; c < v.length; c += 1) {
175
+ const code = v.charCodeAt(c);
176
+ if (allowTabNewline && (code === 0x09 || code === 0x0a)) continue;
177
+ if (code < 0x20 || code === 0x7f) return true;
178
+ }
179
+ return false;
180
+ }
125
181
 
126
- const EOS_REQUIRED = Object.freeze([
127
- 'id',
128
- 'name',
129
- 'type',
130
- 'repo',
131
- 'description',
132
- 'author',
133
- 'license',
134
- 'enginesGsd',
135
- 'install',
136
- 'uninstall',
137
- 'interactions',
138
- 'discussion',
139
- 'protocolVersion',
140
- ]);
182
+ // Caps for `interactions` array-of-strings fields (configKeys, requires,
183
+ // runtimeCompat, produces, consumes, requiresBinaries, ...). These bound
184
+ // UNTRUSTED third-party strings that are rendered verbatim (after mdInline
185
+ // escaping) into a committed Markdown catalog — an unbounded count or length
186
+ // lets a malicious registry PR blow up the generated doc.
187
+ const INTERACTION_STRING_MAX = 200;
188
+ const INTERACTION_ARRAY_MAX = 50;
189
+
190
+ /**
191
+ * Validate an interactions field that is an array of free-form untrusted
192
+ * strings: shape, element count, per-element length, and control characters.
193
+ * `allowEmpty` distinguishes "may be empty" fields from non-empty-required
194
+ * ones — non-empty-required fields' blank-array message is expected to be
195
+ * handled by the caller (this helper does not special-case emptiness itself
196
+ * beyond letting an empty array with `allowEmpty: true` through).
197
+ *
198
+ * @param {object} interactions
199
+ * @param {string} field
200
+ * @param {(field: string, reason: string) => void} addError
201
+ * @param {{allowEmpty?: boolean}} [opts]
202
+ * @returns {void}
203
+ */
204
+ function validateStringArrayField(interactions, field, addError, { allowEmpty = true } = {}) {
205
+ const v = interactions[field];
206
+ const qualifiedField = `interactions.${field}`;
207
+
208
+ if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
209
+ addError(qualifiedField, 'must be an array of strings');
210
+ return;
211
+ }
212
+
213
+ if (!allowEmpty && v.length === 0) return;
214
+
215
+ if (v.length > INTERACTION_ARRAY_MAX) {
216
+ addError(qualifiedField, `exceeds max entries ${INTERACTION_ARRAY_MAX}`);
217
+ }
218
+
219
+ for (const x of v) {
220
+ if (x.length > INTERACTION_STRING_MAX) {
221
+ addError(qualifiedField, `exceeds max length ${INTERACTION_STRING_MAX}`);
222
+ } else if (hasDisallowedControlChar(x, false)) {
223
+ addError(qualifiedField, 'must not contain control characters');
224
+ }
225
+ }
226
+ }
141
227
 
142
228
  // Escape Markdown inline metacharacters in UNTRUSTED free text so a registry
143
229
  // entry cannot inject links/tables/code-spans into the generated catalog.
@@ -221,10 +307,7 @@ function validateCapabilityInteractions(interactions, addError) {
221
307
 
222
308
  for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
223
309
  if (interactions[field] === undefined) continue;
224
- const v = interactions[field];
225
- if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
226
- addError(`interactions.${field}`, 'must be an array of strings');
227
- }
310
+ validateStringArrayField(interactions, field, addError);
228
311
  }
229
312
  }
230
313
 
@@ -312,12 +395,93 @@ function validateEosInteractions(interactions, addError) {
312
395
  }
313
396
  }
314
397
 
398
+ /**
399
+ * Validate the `interactions` sub-object for a reviewer entry (ADR-2782 D3
400
+ * lane vocabulary — issue #2904).
401
+ *
402
+ * @param {object} interactions
403
+ * @param {(field: string, reason: string) => void} addError
404
+ * @returns {void}
405
+ */
406
+ function validateReviewerInteractions(interactions, addError) {
407
+ const allowedKeys = new Set([
408
+ 'slug',
409
+ 'flags',
410
+ 'transport',
411
+ 'evidenceClass',
412
+ 'reviewsSection',
413
+ 'requiresBinaries',
414
+ 'configKeys',
415
+ 'runtimeCompat',
416
+ ]);
417
+ for (const key of Object.keys(interactions)) {
418
+ if (!allowedKeys.has(key)) addError(`interactions.${key}`, 'unknown field');
419
+ }
420
+
421
+ for (const field of allowedKeys) {
422
+ if (interactions[field] === undefined) addError(`interactions.${field}`, 'missing required field');
423
+ }
424
+
425
+ if (interactions.slug !== undefined) {
426
+ const v = interactions.slug;
427
+ if (typeof v !== 'string' || !REVIEWER_SLUG_RE.test(v)) {
428
+ addError('interactions.slug', 'must match the reviewer lane slug grammar');
429
+ }
430
+ }
431
+
432
+ if (interactions.flags !== undefined) {
433
+ const v = interactions.flags;
434
+ if (!Array.isArray(v) || v.length === 0 || !v.every((x) => typeof x === 'string' && REVIEWER_FLAG_RE.test(x))) {
435
+ addError('interactions.flags', 'must be a non-empty array of lane CLI flags');
436
+ }
437
+ }
438
+
439
+ if (interactions.transport !== undefined) {
440
+ const v = interactions.transport;
441
+ if (typeof v !== 'string' || !REVIEWER_LANE_TRANSPORTS.includes(v)) {
442
+ addError('interactions.transport', 'must be one of the allowed lane transports');
443
+ }
444
+ }
445
+
446
+ if (interactions.evidenceClass !== undefined) {
447
+ const v = interactions.evidenceClass;
448
+ if (typeof v !== 'string' || !REVIEWER_EVIDENCE_CLASSES.includes(v)) {
449
+ addError('interactions.evidenceClass', 'must be one of the allowed evidence classes');
450
+ }
451
+ }
452
+
453
+ if (interactions.reviewsSection !== undefined) {
454
+ const v = interactions.reviewsSection;
455
+ if (typeof v !== 'string' || v.trim() === '') {
456
+ addError('interactions.reviewsSection', 'must be a non-empty string');
457
+ } else if (v.length > REVIEWER_SECTION_MAX) {
458
+ addError('interactions.reviewsSection', `exceeds max length ${REVIEWER_SECTION_MAX}`);
459
+ } else if (hasDisallowedControlChar(v, false)) {
460
+ addError('interactions.reviewsSection', 'must not contain control characters');
461
+ }
462
+ }
463
+
464
+ for (const field of ['requiresBinaries', 'configKeys', 'runtimeCompat']) {
465
+ if (interactions[field] === undefined) continue;
466
+ validateStringArrayField(interactions, field, addError);
467
+ }
468
+ }
469
+
470
+ // Per-type rules. A Map (not a plain object) so the lookup below is not a
471
+ // bracket-read on a caller-supplied key — that shape reads as a
472
+ // prototype-pollution sink to CodeQL, and a Map.get does not.
473
+ const TYPE_RULES = new Map([
474
+ ['capability', { required: CAPABILITY_REQUIRED, validateInteractions: validateCapabilityInteractions }],
475
+ ['eos', { required: EOS_REQUIRED, validateInteractions: validateEosInteractions }],
476
+ ['reviewer', { required: REVIEWER_REQUIRED, validateInteractions: validateReviewerInteractions }],
477
+ ]);
478
+
315
479
  /**
316
480
  * Validate an array of registry entries against the closed schema for
317
- * `opts.type` ('capability' | 'eos').
481
+ * `opts.type` ('capability' | 'eos' | 'reviewer').
318
482
  *
319
483
  * @param {object[]} entries
320
- * @param {{type: 'capability'|'eos'}} opts
484
+ * @param {{type: 'capability'|'eos'|'reviewer'}} opts
321
485
  * @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
322
486
  */
323
487
  function validateEntries(entries, opts) {
@@ -325,13 +489,22 @@ function validateEntries(entries, opts) {
325
489
  return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'entries must be an array' }] };
326
490
  }
327
491
 
492
+ // An unrecognized type is a hard error, not a silent fallthrough. Before the
493
+ // third type existed this was a binary ternary whose ELSE branch was
494
+ // `capability`, so a typo'd type validated against the wrong schema and
495
+ // reported plausible-looking per-entry errors.
496
+ const rules = TYPE_RULES.get(opts.type);
497
+ if (!rules) {
498
+ return { ok: false, errors: [{ index: -1, field: '(root)', reason: `unknown registry type "${opts.type}"` }] };
499
+ }
500
+
328
501
  // Entry-count cap: a pathologically large array (e.g. from an automated or
329
502
  // malicious PR) is rejected wholesale rather than validated entry-by-entry.
330
503
  if (entries.length > 2000) {
331
504
  return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'too many entries (max 2000)' }] };
332
505
  }
333
506
 
334
- const required = opts.type === 'eos' ? EOS_REQUIRED : CAPABILITY_REQUIRED;
507
+ const required = rules.required;
335
508
  const requiredSet = new Set(required);
336
509
  const seenIds = new Set();
337
510
  const errors = [];
@@ -363,21 +536,9 @@ function validateEntries(entries, opts) {
363
536
  }
364
537
  }
365
538
 
366
- // Control-character rejection (defense in depth): `allowTabNewline` widens
367
- // the reject-set exception for the two shell-snippet fields (install/
368
- // uninstall), which legitimately contain tabs/newlines; every other free
369
- // text field disallows ALL C0 control characters plus DEL (incl. \n/\t).
370
- // Checked via char codes (not a literal control-char regex range) — same
371
- // approach as capability-validator.cjs's hooks[].matcher check, which
372
- // avoids tripping ESLint's no-control-regex rule.
373
- const hasDisallowedControlChar = (v, allowTabNewline) => {
374
- for (let c = 0; c < v.length; c += 1) {
375
- const code = v.charCodeAt(c);
376
- if (allowTabNewline && (code === 0x09 || code === 0x0a)) continue;
377
- if (code < 0x20 || code === 0x7f) return true;
378
- }
379
- return false;
380
- };
539
+ // Control-character rejection (defense in depth) — delegates to the
540
+ // module-scope `hasDisallowedControlChar` (shared with the `interactions`
541
+ // sub-object validators below) so there is exactly one implementation.
381
542
  const checkNoControlChars = (field, allowTabNewline) => {
382
543
  if (missing.has(field)) return;
383
544
  const v = entry[field];
@@ -460,10 +621,8 @@ function validateEntries(entries, opts) {
460
621
  const interactions = entry.interactions;
461
622
  if (typeof interactions !== 'object' || interactions === null || Array.isArray(interactions)) {
462
623
  addError('interactions', 'interactions must be an object');
463
- } else if (opts.type === 'eos') {
464
- validateEosInteractions(interactions, addError);
465
624
  } else {
466
- validateCapabilityInteractions(interactions, addError);
625
+ rules.validateInteractions(interactions, addError);
467
626
  }
468
627
  }
469
628
 
@@ -477,12 +636,92 @@ function validateEntries(entries, opts) {
477
636
  return { ok: errors.length === 0, errors };
478
637
  }
479
638
 
639
+ // Per-type page presentation AND per-type interaction summary both live in
640
+ // this ONE table (Map, for the same CodeQL reason as TYPE_RULES): title/
641
+ // addNoun drive the page header, buildSummary drives the per-entry "Every
642
+ // interaction with GSD" line. Folding both into a single lookup means a
643
+ // future fourth registry type MUST supply its own buildSummary or the
644
+ // `RENDER_META.get` miss below throws — it cannot silently inherit
645
+ // capability's (or any other type's) rendering the way the old if/else-if/
646
+ // else chain's final `else` branch used to.
647
+ const RENDER_META = new Map([
648
+ [
649
+ 'capability',
650
+ {
651
+ title: 'GSD Community Capability Registry',
652
+ addNoun: 'capability',
653
+ buildSummary(entry, interactions) {
654
+ let summary =
655
+ `Loop Extension Points: ${(interactions.loopExtensionPoints || []).join(', ')}; ` +
656
+ `hook kinds: ${(interactions.hookKinds || []).join(', ')}`;
657
+ for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
658
+ const v = interactions[field];
659
+ if (Array.isArray(v) && v.length > 0) summary += `; ${field}: ${v.join(', ')}`;
660
+ }
661
+ // configKeys/requires/runtimeCompat/produces/consumes are untrusted
662
+ // free-form strings (schema only requires "array of strings") — same
663
+ // single-pass mdInline rationale as the eos branch above.
664
+ return summary;
665
+ },
666
+ },
667
+ ],
668
+ [
669
+ 'eos',
670
+ {
671
+ title: 'GSD EoS Registry',
672
+ addNoun: 'integration',
673
+ buildSummary(entry, interactions) {
674
+ // Required AXES keys always render, in their fixed order; an OPTIONAL_AXES
675
+ // key (e.g. `effortSurface`) renders ONLY when the entry actually carries
676
+ // it — an entry that omits it must render byte-identical to before
677
+ // OPTIONAL_AXES existed (no `effortSurface=undefined` noise).
678
+ const presentOptionalKeys = Object.keys(OPTIONAL_AXES).filter(
679
+ (key) => interactions.axes && Object.hasOwn(interactions.axes, key),
680
+ );
681
+ const axesSummary = [...Object.keys(AXES), ...presentOptionalKeys]
682
+ .map((key) => `${key}=${interactions.axes ? interactions.axes[key] : undefined}`)
683
+ .join(', ');
684
+ return (
685
+ `Interface points: ${(interactions.interfacePoints || []).join(', ')}; ` +
686
+ `profile: ${interactions.profile}; protocol v${entry.protocolVersion}; axes: ${axesSummary}`
687
+ );
688
+ },
689
+ },
690
+ ],
691
+ [
692
+ 'reviewer',
693
+ {
694
+ title: 'GSD Reviewer Lane Registry',
695
+ addNoun: 'reviewer lane',
696
+ buildSummary(entry, interactions) {
697
+ let summary =
698
+ `Lane: ${interactions.slug}; ` +
699
+ `flags: ${(interactions.flags || []).join(', ')}; ` +
700
+ `transport: ${interactions.transport}; ` +
701
+ `evidence: ${interactions.evidenceClass}; ` +
702
+ `REVIEWS.md section: ${interactions.reviewsSection}`;
703
+ for (const field of ['requiresBinaries', 'configKeys', 'runtimeCompat']) {
704
+ const v = interactions[field];
705
+ if (Array.isArray(v) && v.length > 0) summary += `; ${field}: ${v.join(', ')}`;
706
+ }
707
+ // slug/flags/transport are vocab-constrained; reviewsSection and the
708
+ // three arrays are untrusted free text — same single-pass mdInline
709
+ // rationale as the eos/capability branches above: none of the literal
710
+ // separator text contains Markdown metacharacters, so one pass over the
711
+ // assembled summary neutralizes every embedded value.
712
+ return summary;
713
+ },
714
+ },
715
+ ],
716
+ ]);
717
+
480
718
  /**
481
719
  * Render the deterministic Markdown document for a registry.
482
720
  *
483
721
  * @param {object[]} entries
484
- * @param {{type: 'capability'|'eos', sourceFile?: string}} opts
722
+ * @param {{type: 'capability'|'eos'|'reviewer', sourceFile?: string}} opts
485
723
  * @returns {string}
724
+ * @throws {Error} when opts.type is not a known registry type
486
725
  */
487
726
  function renderMarkdown(entries, opts) {
488
727
  const sorted = [...entries].sort((a, b) => {
@@ -491,19 +730,27 @@ function renderMarkdown(entries, opts) {
491
730
  return 0;
492
731
  });
493
732
  const isEos = opts.type === 'eos';
733
+ // An unrecognized type must fail loudly rather than silently render a
734
+ // "GSD Community Capability Registry" page — mirroring the validateEntries
735
+ // unknown-type guard above. This function writes a COMMITTED catalog file,
736
+ // so a silent wrong-title render is the worst failure mode available.
737
+ // Message shape mirrors gen-registry.cjs#renderFor's existing
738
+ // `gen-registry: unknown registry type "..."` throw.
739
+ const meta = RENDER_META.get(opts.type);
740
+ if (!meta) throw new Error(`registry-schema: unknown registry type "${opts.type}"`);
494
741
  const lines = [];
495
742
 
496
743
  lines.push(
497
744
  `<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/${opts.sourceFile} — do not edit by hand; run \`npm run gen:registry\` -->`,
498
745
  );
499
746
  lines.push('');
500
- lines.push(isEos ? '# GSD EoS Registry' : '# GSD Community Capability Registry');
747
+ lines.push(`# ${meta.title}`);
501
748
  lines.push('');
502
749
  lines.push(
503
750
  "> **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).",
504
751
  );
505
752
  lines.push('');
506
- lines.push(`_To add your ${isEos ? 'integration' : 'capability'}, see the [registry README](./README.md)._`);
753
+ lines.push(`_To add your ${meta.addNoun}, see the [registry README](./README.md)._`);
507
754
  lines.push('');
508
755
 
509
756
  if (sorted.length === 0) {
@@ -536,38 +783,12 @@ function renderMarkdown(entries, opts) {
536
783
  lines.push(`- **What it is:** ${mdInline(entry.description)}`);
537
784
  lines.push(`- **Author:** ${mdInline(entry.author)}`);
538
785
 
539
- if (isEos) {
540
- // Required AXES keys always render, in their fixed order; an OPTIONAL_AXES
541
- // key (e.g. `effortSurface`) renders ONLY when the entry actually carries
542
- // it — an entry that omits it must render byte-identical to before
543
- // OPTIONAL_AXES existed (no `effortSurface=undefined` noise).
544
- const presentOptionalKeys = Object.keys(OPTIONAL_AXES).filter(
545
- (key) => interactions.axes && Object.hasOwn(interactions.axes, key),
546
- );
547
- const axesSummary = [...Object.keys(AXES), ...presentOptionalKeys]
548
- .map((key) => `${key}=${interactions.axes ? interactions.axes[key] : undefined}`)
549
- .join(', ');
550
- const summary =
551
- `Interface points: ${(interactions.interfacePoints || []).join(', ')}; ` +
552
- `profile: ${interactions.profile}; protocol v${entry.protocolVersion}; axes: ${axesSummary}`;
553
- // Single mdInline pass over the fully-assembled summary: none of the
554
- // literal separator text above contains Markdown metacharacters, so
555
- // this equally neutralizes every embedded free-text/vocab value
556
- // (notably interactions.axes.dispatch, a free-form untrusted string).
557
- lines.push(`- **Every interaction with GSD:** ${mdInline(summary)}`);
558
- } else {
559
- let summary =
560
- `Loop Extension Points: ${(interactions.loopExtensionPoints || []).join(', ')}; ` +
561
- `hook kinds: ${(interactions.hookKinds || []).join(', ')}`;
562
- for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
563
- const v = interactions[field];
564
- if (Array.isArray(v) && v.length > 0) summary += `; ${field}: ${v.join(', ')}`;
565
- }
566
- // configKeys/requires/runtimeCompat/produces/consumes are untrusted
567
- // free-form strings (schema only requires "array of strings") — same
568
- // single-pass mdInline rationale as the eos branch above.
569
- lines.push(`- **Every interaction with GSD:** ${mdInline(summary)}`);
570
- }
786
+ // Single mdInline pass over the fully-assembled per-type summary: none of
787
+ // the literal separator text in any RENDER_META buildSummary implementation
788
+ // contains Markdown metacharacters, so one pass over the assembled string
789
+ // equally neutralizes every embedded free-text/vocab value (notably eos's
790
+ // interactions.axes.dispatch, a free-form untrusted string).
791
+ lines.push(`- **Every interaction with GSD:** ${mdInline(meta.buildSummary(entry, interactions))}`);
571
792
 
572
793
  // Code-span content (install/uninstall) is NOT mdInline-escaped — it is a
573
794
  // verbatim shell snippet, not inline prose. Instead each block picks a
@@ -608,6 +829,14 @@ module.exports = {
608
829
  AXES_FREE_STRING,
609
830
  CAPABILITY_REQUIRED,
610
831
  EOS_REQUIRED,
832
+ REVIEWER_REQUIRED,
833
+ REVIEWER_LANE_TRANSPORTS,
834
+ REVIEWER_EVIDENCE_CLASSES,
835
+ REVIEWER_SLUG_RE,
836
+ REVIEWER_FLAG_RE,
837
+ REVIEWER_SECTION_MAX,
838
+ INTERACTION_STRING_MAX,
839
+ INTERACTION_ARRAY_MAX,
611
840
  isValidGsdRange,
612
841
  validateEntries,
613
842
  renderMarkdown,
@@ -3,11 +3,13 @@
3
3
 
4
4
  /**
5
5
  * scripts/validate-registry.cjs — CLI validator for the third-party
6
- * discoverability catalogs (issue #2182):
6
+ * discoverability catalogs (issue #2182, plus #2904):
7
7
  *
8
8
  * - docs/registries/capabilities.json ("GSD Community Capability Registry")
9
9
  * - docs/registries/eos.json ("GSD EoS Registry", PR2 — optional
10
10
  * until that JSON file ships)
11
+ * - docs/registries/reviewers.json ("GSD Reviewer Lane Registry",
12
+ * issue #2904 — optional until that JSON file ships)
11
13
  *
12
14
  * Validates each source's JSON array against the closed schema in
13
15
  * scripts/registry-schema.cjs (validateEntries). Human-readable errors go to
@@ -33,14 +35,15 @@ const { validateEntries } = require('./registry-schema.cjs');
33
35
  // a subprocess against isolated temp-fixture directories via `cwd`.
34
36
  const SOURCES = [
35
37
  { file: 'capabilities.json', type: 'capability' },
36
- { file: 'eos.json', type: 'eos' },
38
+ { file: 'eos.json', type: 'eos', optional: true },
39
+ { file: 'reviewers.json', type: 'reviewer', optional: true },
37
40
  ];
38
41
 
39
42
  /**
40
43
  * Load + validate a single registry JSON file.
41
44
  *
42
45
  * @param {string} jsonPath absolute path to the registry JSON file
43
- * @param {'capability'|'eos'} type
46
+ * @param {'capability'|'eos'|'reviewer'} type
44
47
  * @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
45
48
  */
46
49
  function validateFile(jsonPath, type) {
@@ -81,10 +84,11 @@ function main() {
81
84
  const results = [];
82
85
  let anyFailed = false;
83
86
 
84
- for (const { file, type } of SOURCES) {
87
+ for (const { file, type, optional } of SOURCES) {
85
88
  const jsonPath = path.join(registriesDir, file);
86
- // eos.json is optional until PR2 ships it — skip silently when absent.
87
- if (type === 'eos' && !fs.existsSync(jsonPath)) continue;
89
+ // eos.json (pre-PR2) and reviewers.json (issue #2904) are optional until
90
+ // their source JSON ships — skip silently when absent.
91
+ if (optional && !fs.existsSync(jsonPath)) continue;
88
92
 
89
93
  const verdict = validateFile(jsonPath, type);
90
94
  results.push({ file, type, ok: verdict.ok, errors: verdict.errors });
@@ -2,7 +2,7 @@
2
2
  "name": "gsd-core-vscode",
3
3
  "displayName": "GSD Core",
4
4
  "description": "GSD orchestration engine embedded in VS Code (ADR-1239 IDE profile).",
5
- "version": "1.9.0",
5
+ "version": "1.9.1",
6
6
  "publisher": "opengsd",
7
7
  "engines": {
8
8
  "vscode": "^1.105.0"