@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.md +106 -33
- package/bin/install.js +50 -52
- package/gsd-core/bin/gsd-tools.cjs +33 -2
- package/gsd-core/bin/lib/capability-registry.cjs +63 -63
- package/gsd-core/bin/lib/milestone.cjs +31 -4
- package/gsd-core/bin/lib/phase.cjs +4 -1
- package/gsd-core/bin/lib/project-root.cjs +48 -0
- package/gsd-core/bin/lib/verify.cjs +19 -3
- package/gsd-core/workflows/code-review.md +17 -2
- package/package.json +1 -1
- package/scripts/gen-registry.cjs +39 -15
- package/scripts/registry-schema.cjs +323 -94
- package/scripts/validate-registry.cjs +10 -6
- package/vscode/package.json +1 -1
|
@@ -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
|
|
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
|
|
45
|
-
* fields for each entry type, including `enginesGsd`
|
|
46
|
-
* "Versioned capability manifest" — the `engines.gsd`
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
'author',
|
|
118
|
-
'
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
367
|
-
//
|
|
368
|
-
//
|
|
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
|
-
|
|
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(
|
|
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 ${
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|
87
|
-
|
|
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 });
|
package/vscode/package.json
CHANGED