@opengsd/gsd-core 1.7.0-rc.5 → 1.7.0

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.
Files changed (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-executor.md +2 -1
  4. package/agents/gsd-security-auditor.md +13 -15
  5. package/agents/gsd-ui-checker.md +2 -0
  6. package/agents/gsd-ui-researcher.md +1 -0
  7. package/bin/install.js +975 -196
  8. package/commands/gsd/mempalace-capture.md +27 -1
  9. package/commands/gsd/surface.md +6 -6
  10. package/gsd-core/bin/gsd-tools.cjs +63 -2
  11. package/gsd-core/bin/lib/api-coverage.cjs +3 -4
  12. package/gsd-core/bin/lib/audit.cjs +7 -6
  13. package/gsd-core/bin/lib/capability-registry.cjs +503 -87
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/check-command-router.cjs +1 -1
  16. package/gsd-core/bin/lib/clock.cjs +19 -0
  17. package/gsd-core/bin/lib/commands.cjs +48 -9
  18. package/gsd-core/bin/lib/config-loader.cjs +6 -2
  19. package/gsd-core/bin/lib/config.cjs +12 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +8 -2
  21. package/gsd-core/bin/lib/drift.cjs +4 -4
  22. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  23. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  24. package/gsd-core/bin/lib/host-integration.cjs +33 -8
  25. package/gsd-core/bin/lib/init.cjs +60 -53
  26. package/gsd-core/bin/lib/install-engine.cjs +93 -22
  27. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  28. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  29. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
  31. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  32. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  33. package/gsd-core/bin/lib/milestone.cjs +217 -31
  34. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  35. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  36. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  37. package/gsd-core/bin/lib/phase.cjs +436 -61
  38. package/gsd-core/bin/lib/plan-scan.cjs +3 -0
  39. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  40. package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
  41. package/gsd-core/bin/lib/roadmap.cjs +100 -49
  42. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +242 -44
  43. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  44. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -14
  45. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  46. package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
  47. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +526 -29
  48. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  49. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  50. package/gsd-core/bin/lib/security.cjs +7 -37
  51. package/gsd-core/bin/lib/shell-command-projection.cjs +176 -27
  52. package/gsd-core/bin/lib/smart-entry.cjs +4 -3
  53. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  54. package/gsd-core/bin/lib/state-transition.cjs +100 -45
  55. package/gsd-core/bin/lib/state.cjs +391 -126
  56. package/gsd-core/bin/lib/surface.cjs +12 -8
  57. package/gsd-core/bin/lib/template.cjs +2 -1
  58. package/gsd-core/bin/lib/uat.cjs +54 -8
  59. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  60. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  61. package/gsd-core/bin/lib/verify.cjs +4 -3
  62. package/gsd-core/bin/lib/workstream.cjs +3 -2
  63. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  64. package/gsd-core/bin/lib/write-set.cjs +38 -0
  65. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  66. package/gsd-core/bin/shared/model-catalog.json +8 -3
  67. package/gsd-core/references/checkpoints.md +12 -0
  68. package/gsd-core/references/ui-consideration-probe.md +73 -0
  69. package/gsd-core/templates/UI-SPEC.md +25 -0
  70. package/gsd-core/templates/VALIDATION.md +2 -0
  71. package/gsd-core/workflows/add-tests.md +1 -1
  72. package/gsd-core/workflows/audit-milestone.md +7 -4
  73. package/gsd-core/workflows/debug.md +2 -0
  74. package/gsd-core/workflows/execute-phase.md +5 -3
  75. package/gsd-core/workflows/fast.md +8 -22
  76. package/gsd-core/workflows/plan-phase.md +6 -0
  77. package/gsd-core/workflows/progress.md +2 -2
  78. package/gsd-core/workflows/quick.md +2 -0
  79. package/gsd-core/workflows/review.md +42 -3
  80. package/gsd-core/workflows/secure-phase.md +1 -1
  81. package/gsd-core/workflows/settings-advanced.md +7 -4
  82. package/gsd-core/workflows/ship.md +8 -2
  83. package/gsd-core/workflows/spec-phase.md +1 -1
  84. package/gsd-core/workflows/transition.md +1 -1
  85. package/gsd-core/workflows/ui-phase.md +146 -1
  86. package/gsd-core/workflows/validate-phase.md +2 -2
  87. package/hooks/dist/gsd-statusline.js +164 -14
  88. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  89. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  90. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  91. package/hooks/gsd-statusline.js +164 -14
  92. package/hooks/gsd-windsurf-pre-command.js +275 -0
  93. package/hooks/gsd-windsurf-pre-write.js +132 -0
  94. package/hooks/managed-hooks-registry.cjs +2 -0
  95. package/package.json +10 -4
  96. package/pi/gsd.cjs +354 -0
  97. package/scripts/build-hooks.js +3 -0
  98. package/scripts/ci-test-scope.cjs +39 -1
  99. package/scripts/gen-golden-install-parity-zcode.cjs +35 -35
  100. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  101. package/scripts/gen-registry.cjs +128 -0
  102. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  103. package/scripts/lint-table-schema-drift.cjs +157 -0
  104. package/scripts/lint-test-file-count.allowlist.json +2 -1
  105. package/scripts/registry-schema.cjs +565 -0
  106. package/scripts/validate-registry.cjs +117 -0
  107. package/skills/gsd-mempalace-capture/SKILL.md +27 -1
  108. package/skills/gsd-surface/SKILL.md +6 -6
  109. package/vscode/browser.js +197 -0
  110. package/vscode/extension.js +383 -0
  111. package/vscode/host-binding.js +113 -0
  112. package/vscode/package.json +96 -0
@@ -0,0 +1,565 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * scripts/registry-schema.cjs — pure schema/vocab constants + validation +
5
+ * markdown-generation logic for the two third-party discoverability catalogs
6
+ * (issue #2182):
7
+ *
8
+ * - `docs/registries/capabilities.json` → "GSD Community Capability Registry"
9
+ * - `docs/registries/eos.json` → "GSD EoS Registry" (PR2)
10
+ *
11
+ * The vocabulary constants below are ADDITIVE CONTRACTS that track the
12
+ * runtime/ADR closed vocabularies they describe — they are a documentation-
13
+ * registry-scoped mirror, not the runtime source of truth:
14
+ *
15
+ * - `LOOP_POINTS` mirrors ADR-857 "Loop Extension Points (the 12)"
16
+ * (docs/adr/857-capability-system.md §"Loop Extension Points (the 12)").
17
+ * The canonical runtime set lives in `src/loop-resolver.cts`
18
+ * (`CANONICAL_POINTS` / `CANONICAL_POINTS_FALLBACK`, derived from
19
+ * `loop-host-contract.cjs`) — changing that set requires updating this
20
+ * list too, since a registry entry's `loopExtensionPoints` describes
21
+ * which of those 12 points a third-party capability extends.
22
+ * - `HOOK_KINDS` mirrors ADR-857 Decision 4 "three hook kinds": `step`
23
+ * (runs as its own sequenced unit), `contribution` (injects into the
24
+ * core step's prompt/context), `gate` (checks and optionally blocks).
25
+ * - `INTERFACE_POINTS` mirrors ADR-1239 "The six interface points" (the
26
+ * Host-Integration Interface integration surface): command/workflow
27
+ * invocation, agent dispatch, model invocation, lifecycle hooks,
28
+ * state+config IO, artifact surface.
29
+ * - `PROFILES` mirrors ADR-1239 "Host-capability profiles (negotiation
30
+ * baselines)": `programmatic-cli`, `declarative-cli`, `ide`.
31
+ * - `AXES` mirrors ADR-1239 "the eight negotiated axes" (the negotiated
32
+ * capability schema exchanged at `initialize`): `embeddingMode`,
33
+ * `commandSurface`, `dispatch`, `modelMode`, `hookBus`, `stateIO`,
34
+ * `transport`, `runtime`. Seven of the eight are closed enums here;
35
+ * `dispatch` is ADR-1239's structured negotiated object
36
+ * (`{ namedDispatch, nested, maxDepth, background, subagentToolkit }`) —
37
+ * this registry accepts a free-form human summary string instead, so it
38
+ * carries the `AXES_FREE_STRING` sentinel rather than an enum array.
39
+ * - `CAPABILITY_REQUIRED` / `EOS_REQUIRED` mirror the required top-level
40
+ * fields for each entry type, including `enginesGsd` (ADR-1244 D1
41
+ * "Versioned capability manifest" — the `engines.gsd` semver-range gate,
42
+ * modelled on VS Code's `engines.vscode`).
43
+ *
44
+ * This module is pure — no `fs`/`process`/child-process access — so tests
45
+ * can `require()` it directly and assert on structured return values.
46
+ * `scripts/validate-registry.cjs` and `scripts/gen-registry.cjs` are the thin
47
+ * CLI wrappers that perform I/O around these functions.
48
+ */
49
+
50
+ // ─── ADR-857 "Loop Extension Points (the 12)" ────────────────────────────────
51
+ const LOOP_POINTS = Object.freeze([
52
+ 'discuss:pre',
53
+ 'discuss:post',
54
+ 'plan:pre',
55
+ 'plan:post',
56
+ 'execute:pre',
57
+ 'execute:wave:pre',
58
+ 'execute:wave:post',
59
+ 'execute:post',
60
+ 'verify:pre',
61
+ 'verify:post',
62
+ 'ship:pre',
63
+ 'ship:post',
64
+ ]);
65
+
66
+ // ─── ADR-857 Decision 4 — three hook kinds ───────────────────────────────────
67
+ const HOOK_KINDS = Object.freeze(['step', 'contribution', 'gate']);
68
+
69
+ // ─── ADR-1239 "The six interface points" ─────────────────────────────────────
70
+ const INTERFACE_POINTS = Object.freeze(['command', 'dispatch', 'model', 'hooks', 'state', 'artifact']);
71
+
72
+ // ─── ADR-1239 "Host-capability profiles (negotiation baselines)" ────────────
73
+ const PROFILES = Object.freeze(['programmatic-cli', 'declarative-cli', 'ide']);
74
+
75
+ // Sentinel marking an AXES entry as a free-form descriptive string rather than
76
+ // a closed enum array. `Array.isArray(AXES_FREE_STRING)` is false, so callers
77
+ // can branch on `Array.isArray(AXES[key])` vs `AXES[key] === AXES_FREE_STRING`
78
+ // without risking confusion with a real enum value.
79
+ const AXES_FREE_STRING = Symbol('registry-schema.AXES_FREE_STRING');
80
+
81
+ // ─── ADR-1239 "the eight negotiated axes" ────────────────────────────────────
82
+ const AXES = Object.freeze({
83
+ embeddingMode: Object.freeze(['imperative', 'declarative']),
84
+ commandSurface: Object.freeze(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only']),
85
+ dispatch: AXES_FREE_STRING,
86
+ modelMode: Object.freeze(['active', 'passive']),
87
+ hookBus: Object.freeze(['host', 'engine', 'none']),
88
+ stateIO: Object.freeze(['filesystem', 'sandboxed-storage', 'session-log-append']),
89
+ transport: Object.freeze(['mcp', 'native-extension']),
90
+ runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']),
91
+ });
92
+
93
+ // ─── Required top-level fields ───────────────────────────────────────────────
94
+ const CAPABILITY_REQUIRED = Object.freeze([
95
+ 'id',
96
+ 'name',
97
+ 'type',
98
+ 'repo',
99
+ 'description',
100
+ 'author',
101
+ 'license',
102
+ 'enginesGsd',
103
+ 'install',
104
+ 'uninstall',
105
+ 'interactions',
106
+ 'discussion',
107
+ ]);
108
+
109
+ const EOS_REQUIRED = Object.freeze([
110
+ 'id',
111
+ 'name',
112
+ 'type',
113
+ 'repo',
114
+ 'description',
115
+ 'author',
116
+ 'license',
117
+ 'enginesGsd',
118
+ 'install',
119
+ 'uninstall',
120
+ 'interactions',
121
+ 'discussion',
122
+ 'protocolVersion',
123
+ ]);
124
+
125
+ // Escape Markdown inline metacharacters in UNTRUSTED free text so a registry
126
+ // entry cannot inject links/tables/code-spans into the generated catalog.
127
+ // Neutralizes: link hijack ([ ] ( )), table breakout (|), code span (`),
128
+ // and backslash. Newlines are collapsed to a single space (inline contexts).
129
+ function mdInline(value) {
130
+ return String(value).replace(/[\\`*_[\]()|~<>]/g, '\\$&').replace(/[\r\n]+/g, ' ');
131
+ }
132
+ // A fenced-code fence guaranteed longer than any backtick run in `value`, so a
133
+ // value containing ``` cannot escape the block (CommonMark rule). Min length 3.
134
+ function fenceFor(value) {
135
+ const runs = String(value).match(/`+/g) || [];
136
+ const longest = runs.reduce((m, r) => Math.max(m, r.length), 0);
137
+ return '`'.repeat(Math.max(3, longest + 1));
138
+ }
139
+
140
+ // A single `engines.gsd` range clause: optional comparison operator, optional
141
+ // leading `v`, exactly three dot-separated numeric segments, optional
142
+ // prerelease (`-...`) and build (`+...`) suffixes. Operator alternation order
143
+ // matters — `>=`/`<=` must be tried before `>`/`<` or the longer operator
144
+ // would never match.
145
+ const GSD_RANGE_CLAUSE_RE = /^(>=|<=|>|<|=|\^|~)?v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
146
+
147
+ /**
148
+ * Validate the SHAPE of an `engines.gsd`-style semver range string (ADR-1244
149
+ * D1). Self-contained — no `semver` dependency, modelled on the constraint
150
+ * parsing in `scripts/check-env.cjs` (`satisfiesConstraint`), but this
151
+ * function validates that the range is well-formed rather than comparing it
152
+ * against a concrete version.
153
+ *
154
+ * @param {string} range
155
+ * @returns {boolean}
156
+ */
157
+ function isValidGsdRange(range) {
158
+ if (typeof range !== 'string') return false;
159
+ const trimmed = range.trim();
160
+ if (trimmed === '') return false;
161
+ if (trimmed === '*') return true;
162
+ const clauses = trimmed.split(/\s+/);
163
+ return clauses.length > 0 && clauses.every((clause) => clause !== '' && GSD_RANGE_CLAUSE_RE.test(clause));
164
+ }
165
+
166
+ /**
167
+ * Validate the `interactions` sub-object for a capability entry.
168
+ *
169
+ * @param {object} interactions
170
+ * @param {(field: string, reason: string) => void} addError
171
+ * @returns {void}
172
+ */
173
+ function validateCapabilityInteractions(interactions, addError) {
174
+ const allowedKeys = new Set([
175
+ 'loopExtensionPoints',
176
+ 'hookKinds',
177
+ 'configKeys',
178
+ 'requires',
179
+ 'runtimeCompat',
180
+ 'produces',
181
+ 'consumes',
182
+ ]);
183
+ for (const key of Object.keys(interactions)) {
184
+ if (!allowedKeys.has(key)) addError(`interactions.${key}`, 'unknown field');
185
+ }
186
+
187
+ for (const field of ['loopExtensionPoints', 'hookKinds']) {
188
+ if (interactions[field] === undefined) addError(`interactions.${field}`, 'missing required field');
189
+ }
190
+
191
+ if (interactions.loopExtensionPoints !== undefined) {
192
+ const v = interactions.loopExtensionPoints;
193
+ if (!Array.isArray(v) || v.length === 0 || !v.every((x) => LOOP_POINTS.includes(x))) {
194
+ addError('interactions.loopExtensionPoints', 'must be a non-empty array of valid loop extension points');
195
+ }
196
+ }
197
+
198
+ if (interactions.hookKinds !== undefined) {
199
+ const v = interactions.hookKinds;
200
+ if (!Array.isArray(v) || !v.every((x) => HOOK_KINDS.includes(x))) {
201
+ addError('interactions.hookKinds', 'must be an array of valid hook kinds');
202
+ }
203
+ }
204
+
205
+ for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
206
+ if (interactions[field] === undefined) continue;
207
+ const v = interactions[field];
208
+ if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
209
+ addError(`interactions.${field}`, 'must be an array of strings');
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Validate the `interactions` sub-object for an eos entry.
216
+ *
217
+ * @param {object} interactions
218
+ * @param {(field: string, reason: string) => void} addError
219
+ * @returns {void}
220
+ */
221
+ function validateEosInteractions(interactions, addError) {
222
+ const allowedKeys = new Set(['interfacePoints', 'profile', 'axes']);
223
+ for (const key of Object.keys(interactions)) {
224
+ if (!allowedKeys.has(key)) addError(`interactions.${key}`, 'unknown field');
225
+ }
226
+
227
+ for (const field of ['interfacePoints', 'profile', 'axes']) {
228
+ if (interactions[field] === undefined) addError(`interactions.${field}`, 'missing required field');
229
+ }
230
+
231
+ if (interactions.interfacePoints !== undefined) {
232
+ const v = interactions.interfacePoints;
233
+ if (!Array.isArray(v) || v.length === 0 || !v.every((x) => INTERFACE_POINTS.includes(x))) {
234
+ addError('interactions.interfacePoints', 'must be a non-empty array of valid interface points');
235
+ }
236
+ }
237
+
238
+ if (interactions.profile !== undefined) {
239
+ if (typeof interactions.profile !== 'string' || !PROFILES.includes(interactions.profile)) {
240
+ addError('interactions.profile', 'must be one of the valid negotiation profiles');
241
+ }
242
+ }
243
+
244
+ if (interactions.axes !== undefined) {
245
+ const axes = interactions.axes;
246
+ if (typeof axes !== 'object' || axes === null || Array.isArray(axes)) {
247
+ addError('interactions.axes', 'axes must be an object');
248
+ } else {
249
+ const expectedKeys = Object.keys(AXES);
250
+ const actualKeys = Object.keys(axes);
251
+ const actualKeySet = new Set(actualKeys);
252
+ const keysMatch = expectedKeys.length === actualKeys.length && expectedKeys.every((k) => actualKeySet.has(k));
253
+ if (!keysMatch) {
254
+ addError('interactions.axes', 'axes key set must exactly match the eight negotiated axes');
255
+ } else {
256
+ for (const key of expectedKeys) {
257
+ const allowedValues = AXES[key];
258
+ const v = axes[key];
259
+ if (allowedValues === AXES_FREE_STRING) {
260
+ if (typeof v !== 'string' || v.trim() === '') {
261
+ addError(`interactions.axes.${key}`, 'must be a non-empty string');
262
+ } else if (v.length > 300) {
263
+ addError(`interactions.axes.${key}`, 'exceeds max length 300');
264
+ }
265
+ } else if (typeof v !== 'string' || !allowedValues.includes(v)) {
266
+ addError(`interactions.axes.${key}`, `must be one of the allowed values for ${key}`);
267
+ }
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Validate an array of registry entries against the closed schema for
276
+ * `opts.type` ('capability' | 'eos').
277
+ *
278
+ * @param {object[]} entries
279
+ * @param {{type: 'capability'|'eos'}} opts
280
+ * @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
281
+ */
282
+ function validateEntries(entries, opts) {
283
+ if (!Array.isArray(entries)) {
284
+ return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'entries must be an array' }] };
285
+ }
286
+
287
+ // Entry-count cap: a pathologically large array (e.g. from an automated or
288
+ // malicious PR) is rejected wholesale rather than validated entry-by-entry.
289
+ if (entries.length > 2000) {
290
+ return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'too many entries (max 2000)' }] };
291
+ }
292
+
293
+ const required = opts.type === 'eos' ? EOS_REQUIRED : CAPABILITY_REQUIRED;
294
+ const requiredSet = new Set(required);
295
+ const seenIds = new Set();
296
+ const errors = [];
297
+
298
+ entries.forEach((entry, index) => {
299
+ const addError = (field, reason) => {
300
+ const err = { index, field, reason };
301
+ if (entry && typeof entry === 'object' && typeof entry.id === 'string') err.id = entry.id;
302
+ errors.push(err);
303
+ };
304
+
305
+ // Null/non-object element guard — a malformed array element (null,
306
+ // undefined-via-hole, a primitive, or an array) cannot be destructured by
307
+ // the field checks below, so reject it outright rather than throwing.
308
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
309
+ addError('(entry)', 'entry must be a JSON object');
310
+ return;
311
+ }
312
+
313
+ for (const key of Object.keys(entry)) {
314
+ if (!requiredSet.has(key)) addError(key, 'unknown field');
315
+ }
316
+
317
+ const missing = new Set();
318
+ for (const field of required) {
319
+ if (entry[field] === undefined) {
320
+ addError(field, 'missing required field');
321
+ missing.add(field);
322
+ }
323
+ }
324
+
325
+ // Control-character rejection (defense in depth): `allowTabNewline` widens
326
+ // the reject-set exception for the two shell-snippet fields (install/
327
+ // uninstall), which legitimately contain tabs/newlines; every other free
328
+ // text field disallows ALL C0 control characters plus DEL (incl. \n/\t).
329
+ // Checked via char codes (not a literal control-char regex range) — same
330
+ // approach as capability-validator.cjs's hooks[].matcher check, which
331
+ // avoids tripping ESLint's no-control-regex rule.
332
+ const hasDisallowedControlChar = (v, allowTabNewline) => {
333
+ for (let c = 0; c < v.length; c += 1) {
334
+ const code = v.charCodeAt(c);
335
+ if (allowTabNewline && (code === 0x09 || code === 0x0a)) continue;
336
+ if (code < 0x20 || code === 0x7f) return true;
337
+ }
338
+ return false;
339
+ };
340
+ const checkNoControlChars = (field, allowTabNewline) => {
341
+ if (missing.has(field)) return;
342
+ const v = entry[field];
343
+ if (typeof v !== 'string') return;
344
+ if (hasDisallowedControlChar(v, allowTabNewline)) addError(field, 'must not contain control characters');
345
+ };
346
+ // Length cap: reject oversized fields (untrusted third-party input feeding
347
+ // a committed Markdown catalog should not be allowed to blow up the doc).
348
+ const checkMaxLength = (field, max) => {
349
+ if (missing.has(field)) return;
350
+ const v = entry[field];
351
+ if (typeof v === 'string' && v.length > max) addError(field, `exceeds max length ${max}`);
352
+ };
353
+
354
+ if (!missing.has('id')) {
355
+ const id = entry.id;
356
+ if (typeof id !== 'string' || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id)) {
357
+ addError('id', 'id must be kebab-case');
358
+ }
359
+ if (seenIds.has(id)) {
360
+ addError('id', `duplicate id: ${id}`);
361
+ } else {
362
+ seenIds.add(id);
363
+ }
364
+ }
365
+ checkMaxLength('id', 100);
366
+
367
+ for (const field of ['name', 'description', 'author']) {
368
+ if (missing.has(field)) continue;
369
+ const v = entry[field];
370
+ if (typeof v !== 'string' || v.trim() === '') addError(field, 'must be a non-empty string');
371
+ checkNoControlChars(field, false);
372
+ }
373
+ checkMaxLength('name', 120);
374
+ checkMaxLength('author', 120);
375
+ checkMaxLength('description', 1000);
376
+
377
+ if (!missing.has('type') && entry.type !== opts.type) {
378
+ addError('type', `type must be "${opts.type}"`);
379
+ }
380
+
381
+ if (!missing.has('repo')) {
382
+ if (typeof entry.repo !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(entry.repo)) {
383
+ addError('repo', 'repo must be in "owner/repo" form');
384
+ }
385
+ }
386
+ checkMaxLength('repo', 100);
387
+
388
+ if (!missing.has('license')) {
389
+ const v = entry.license;
390
+ if (typeof v !== 'string' || v.trim() === '' || !/^[A-Za-z0-9.+()\- ]+$/.test(v)) {
391
+ addError('license', 'license must be a non-empty SPDX-like string');
392
+ }
393
+ }
394
+ checkMaxLength('license', 120);
395
+
396
+ if (!missing.has('enginesGsd') && !isValidGsdRange(entry.enginesGsd)) {
397
+ addError('enginesGsd', 'enginesGsd must be a valid semver range');
398
+ }
399
+ checkMaxLength('enginesGsd', 100);
400
+
401
+ for (const field of ['install', 'uninstall']) {
402
+ if (missing.has(field)) continue;
403
+ const v = entry[field];
404
+ if (typeof v !== 'string' || v.trim() === '') addError(field, 'must be a non-empty string');
405
+ checkNoControlChars(field, true);
406
+ }
407
+ checkMaxLength('install', 2000);
408
+ checkMaxLength('uninstall', 2000);
409
+
410
+ if (!missing.has('discussion')) {
411
+ const v = entry.discussion;
412
+ if (typeof v !== 'string' || !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/discussions\/\d+$/.test(v)) {
413
+ addError('discussion', 'discussion must be a GitHub discussions URL');
414
+ }
415
+ }
416
+ checkMaxLength('discussion', 300);
417
+
418
+ if (!missing.has('interactions')) {
419
+ const interactions = entry.interactions;
420
+ if (typeof interactions !== 'object' || interactions === null || Array.isArray(interactions)) {
421
+ addError('interactions', 'interactions must be an object');
422
+ } else if (opts.type === 'eos') {
423
+ validateEosInteractions(interactions, addError);
424
+ } else {
425
+ validateCapabilityInteractions(interactions, addError);
426
+ }
427
+ }
428
+
429
+ if (opts.type === 'eos' && !missing.has('protocolVersion')) {
430
+ if (!Number.isInteger(entry.protocolVersion) || entry.protocolVersion < 1) {
431
+ addError('protocolVersion', 'protocolVersion must be an integer >= 1');
432
+ }
433
+ }
434
+ });
435
+
436
+ return { ok: errors.length === 0, errors };
437
+ }
438
+
439
+ /**
440
+ * Render the deterministic Markdown document for a registry.
441
+ *
442
+ * @param {object[]} entries
443
+ * @param {{type: 'capability'|'eos', sourceFile?: string}} opts
444
+ * @returns {string}
445
+ */
446
+ function renderMarkdown(entries, opts) {
447
+ const sorted = [...entries].sort((a, b) => {
448
+ if (a.id < b.id) return -1;
449
+ if (a.id > b.id) return 1;
450
+ return 0;
451
+ });
452
+ const isEos = opts.type === 'eos';
453
+ const lines = [];
454
+
455
+ lines.push(
456
+ `<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/${opts.sourceFile} — do not edit by hand; run \`npm run gen:registry\` -->`,
457
+ );
458
+ lines.push('');
459
+ lines.push(isEos ? '# GSD EoS Registry' : '# GSD Community Capability Registry');
460
+ lines.push('');
461
+ lines.push(
462
+ "> **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).",
463
+ );
464
+ lines.push('');
465
+ lines.push(`_To add your ${isEos ? 'integration' : 'capability'}, see the [registry README](./README.md)._`);
466
+ lines.push('');
467
+
468
+ if (sorted.length === 0) {
469
+ lines.push('_No entries yet — be the first: see [README](./README.md)._');
470
+ return `${lines.join('\n')}\n`;
471
+ }
472
+
473
+ lines.push('| Name | What it is | Latest release | GSD compat | Discussion |');
474
+ lines.push('|---|---|---|---|---|');
475
+ for (const entry of sorted) {
476
+ // entry.repo/enginesGsd/discussion are regex-constrained (validateEntries)
477
+ // and used as link DESTINATIONS / badge URLs here — never mdInline those,
478
+ // it would corrupt the URL. entry.name/description are untrusted free-text
479
+ // link TEXT / body copy and MUST be escaped.
480
+ lines.push(
481
+ `| [${mdInline(entry.name)}](https://github.com/${entry.repo}) | ${mdInline(entry.description)} | ` +
482
+ `![release](https://img.shields.io/github/v/release/${entry.repo}?sort=semver&include_prereleases) | ` +
483
+ `\`${entry.enginesGsd}\` | [discuss](${entry.discussion}) |`,
484
+ );
485
+ }
486
+ lines.push('');
487
+
488
+ sorted.forEach((entry, i) => {
489
+ const interactions = entry.interactions || {};
490
+
491
+ lines.push(`## ${mdInline(entry.name)}`);
492
+ lines.push(
493
+ `- **Repository:** https://github.com/${entry.repo} — [latest release](https://github.com/${entry.repo}/releases/latest)`,
494
+ );
495
+ lines.push(`- **What it is:** ${mdInline(entry.description)}`);
496
+ lines.push(`- **Author:** ${mdInline(entry.author)}`);
497
+
498
+ if (isEos) {
499
+ const axesSummary = Object.keys(AXES)
500
+ .map((key) => `${key}=${interactions.axes ? interactions.axes[key] : undefined}`)
501
+ .join(', ');
502
+ const summary =
503
+ `Interface points: ${(interactions.interfacePoints || []).join(', ')}; ` +
504
+ `profile: ${interactions.profile}; protocol v${entry.protocolVersion}; axes: ${axesSummary}`;
505
+ // Single mdInline pass over the fully-assembled summary: none of the
506
+ // literal separator text above contains Markdown metacharacters, so
507
+ // this equally neutralizes every embedded free-text/vocab value
508
+ // (notably interactions.axes.dispatch, a free-form untrusted string).
509
+ lines.push(`- **Every interaction with GSD:** ${mdInline(summary)}`);
510
+ } else {
511
+ let summary =
512
+ `Loop Extension Points: ${(interactions.loopExtensionPoints || []).join(', ')}; ` +
513
+ `hook kinds: ${(interactions.hookKinds || []).join(', ')}`;
514
+ for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
515
+ const v = interactions[field];
516
+ if (Array.isArray(v) && v.length > 0) summary += `; ${field}: ${v.join(', ')}`;
517
+ }
518
+ // configKeys/requires/runtimeCompat/produces/consumes are untrusted
519
+ // free-form strings (schema only requires "array of strings") — same
520
+ // single-pass mdInline rationale as the eos branch above.
521
+ lines.push(`- **Every interaction with GSD:** ${mdInline(summary)}`);
522
+ }
523
+
524
+ // Code-span content (install/uninstall) is NOT mdInline-escaped — it is a
525
+ // verbatim shell snippet, not inline prose. Instead each block picks a
526
+ // fence strictly longer than any backtick run inside its own content, so
527
+ // an embedded ``` cannot prematurely close the fence (CommonMark rule).
528
+ const installFence = fenceFor(entry.install);
529
+ lines.push('- **Install:**');
530
+ lines.push(`${installFence}sh`);
531
+ lines.push(entry.install);
532
+ lines.push(installFence);
533
+ const uninstallFence = fenceFor(entry.uninstall);
534
+ lines.push('- **Uninstall:**');
535
+ lines.push(`${uninstallFence}sh`);
536
+ lines.push(entry.uninstall);
537
+ lines.push(uninstallFence);
538
+
539
+ lines.push(
540
+ isEos
541
+ ? `- **GSD compatibility:** \`${entry.enginesGsd}\`, protocol v${entry.protocolVersion}`
542
+ : `- **GSD compatibility:** \`${entry.enginesGsd}\``,
543
+ );
544
+ lines.push(`- **License:** ${mdInline(entry.license)}`);
545
+ lines.push(`- **Discussion / ranking:** ${entry.discussion}`);
546
+
547
+ if (i < sorted.length - 1) lines.push('');
548
+ });
549
+
550
+ return `${lines.join('\n')}\n`;
551
+ }
552
+
553
+ module.exports = {
554
+ LOOP_POINTS,
555
+ HOOK_KINDS,
556
+ INTERFACE_POINTS,
557
+ PROFILES,
558
+ AXES,
559
+ AXES_FREE_STRING,
560
+ CAPABILITY_REQUIRED,
561
+ EOS_REQUIRED,
562
+ isValidGsdRange,
563
+ validateEntries,
564
+ renderMarkdown,
565
+ };
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * scripts/validate-registry.cjs — CLI validator for the third-party
6
+ * discoverability catalogs (issue #2182):
7
+ *
8
+ * - docs/registries/capabilities.json ("GSD Community Capability Registry")
9
+ * - docs/registries/eos.json ("GSD EoS Registry", PR2 — optional
10
+ * until that JSON file ships)
11
+ *
12
+ * Validates each source's JSON array against the closed schema in
13
+ * scripts/registry-schema.cjs (validateEntries). Human-readable errors go to
14
+ * stderr; `--json` additionally prints a structured verdict to stdout.
15
+ *
16
+ * Usage:
17
+ * node scripts/validate-registry.cjs # human-readable report
18
+ * node scripts/validate-registry.cjs --json # structured JSON verdict
19
+ *
20
+ * Exit codes:
21
+ * 0 every present source's entries are all valid
22
+ * 1 one or more entries failed validation (or a source's JSON is malformed)
23
+ */
24
+
25
+ const fs = require('node:fs');
26
+ const path = require('node:path');
27
+
28
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
29
+ const { validateEntries } = require('./registry-schema.cjs');
30
+
31
+ // Resolved relative to process.cwd() (not __dirname) so the CLI validates
32
+ // whichever project it is invoked from — this is what lets tests drive it as
33
+ // a subprocess against isolated temp-fixture directories via `cwd`.
34
+ const SOURCES = [
35
+ { file: 'capabilities.json', type: 'capability' },
36
+ { file: 'eos.json', type: 'eos' },
37
+ ];
38
+
39
+ /**
40
+ * Load + validate a single registry JSON file.
41
+ *
42
+ * @param {string} jsonPath absolute path to the registry JSON file
43
+ * @param {'capability'|'eos'} type
44
+ * @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
45
+ */
46
+ function validateFile(jsonPath, type) {
47
+ let raw;
48
+ try {
49
+ raw = fs.readFileSync(jsonPath, 'utf8');
50
+ } catch (err) {
51
+ return {
52
+ ok: false,
53
+ errors: [{ index: -1, field: '<file>', reason: `could not read ${jsonPath}: ${err.message}` }],
54
+ };
55
+ }
56
+
57
+ let entries;
58
+ try {
59
+ entries = JSON.parse(raw);
60
+ } catch (err) {
61
+ return {
62
+ ok: false,
63
+ errors: [{ index: -1, field: '<file>', reason: `JSON parse error in ${jsonPath}: ${err.message}` }],
64
+ };
65
+ }
66
+
67
+ if (!Array.isArray(entries)) {
68
+ return {
69
+ ok: false,
70
+ errors: [{ index: -1, field: '<file>', reason: `${jsonPath} must be a JSON array of entries` }],
71
+ };
72
+ }
73
+
74
+ return validateEntries(entries, { type });
75
+ }
76
+
77
+ function main() {
78
+ const jsonMode = process.argv.includes('--json');
79
+ const registriesDir = path.join(process.cwd(), 'docs', 'registries');
80
+
81
+ const results = [];
82
+ let anyFailed = false;
83
+
84
+ for (const { file, type } of SOURCES) {
85
+ 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;
88
+
89
+ const verdict = validateFile(jsonPath, type);
90
+ results.push({ file, type, ok: verdict.ok, errors: verdict.errors });
91
+ if (!verdict.ok) anyFailed = true;
92
+ }
93
+
94
+ if (jsonMode) {
95
+ process.stdout.write(JSON.stringify({ ok: !anyFailed, results }, null, 2) + '\n');
96
+ } else if (anyFailed) {
97
+ process.stderr.write('\nERROR validate-registry: one or more entries failed validation\n');
98
+ for (const result of results) {
99
+ if (result.ok) continue;
100
+ process.stderr.write(`\n${result.file}:\n`);
101
+ for (const e of result.errors) {
102
+ const idPart = e.id ? ` (id: ${e.id})` : '';
103
+ process.stderr.write(` entry[${e.index}]${idPart} field "${e.field}": ${e.reason}\n`);
104
+ }
105
+ }
106
+ process.stderr.write('\n');
107
+ } else {
108
+ process.stdout.write('ok validate-registry: all entries valid\n');
109
+ }
110
+
111
+ if (anyFailed) throw new ExitError(1, 'registry validation failed');
112
+ return 0;
113
+ }
114
+
115
+ if (require.main === module) runMain(main);
116
+
117
+ module.exports = { main, validateFile, SOURCES };