@opengsd/gsd-core 1.8.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.
Files changed (177) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +31 -1
  4. package/agents/gsd-code-fixer.md +107 -34
  5. package/agents/gsd-codebase-mapper.md +1 -1
  6. package/agents/gsd-debug-session-manager.md +36 -0
  7. package/agents/gsd-executor.md +20 -7
  8. package/agents/gsd-intel-updater.md +3 -3
  9. package/agents/gsd-phase-researcher.md +4 -2
  10. package/agents/gsd-plan-checker.md +20 -0
  11. package/agents/gsd-planner.md +15 -23
  12. package/agents/gsd-project-researcher.md +2 -2
  13. package/agents/gsd-ui-auditor.md +0 -40
  14. package/bin/install.js +236 -107
  15. package/commands/gsd/plan-review-convergence.md +5 -1
  16. package/gsd-core/bin/gsd-tools.cjs +882 -4
  17. package/gsd-core/bin/lib/api-coverage.cjs +22 -8
  18. package/gsd-core/bin/lib/audit.cjs +8 -8
  19. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  20. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  21. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  22. package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
  23. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  24. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  25. package/gsd-core/bin/lib/check-command-router.cjs +12 -2
  26. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  27. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
  28. package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
  29. package/gsd-core/bin/lib/commands.cjs +246 -18
  30. package/gsd-core/bin/lib/config-loader.cjs +200 -28
  31. package/gsd-core/bin/lib/config.cjs +90 -5
  32. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  33. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  34. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  35. package/gsd-core/bin/lib/init.cjs +44 -19
  36. package/gsd-core/bin/lib/install-engine.cjs +1 -0
  37. package/gsd-core/bin/lib/milestone.cjs +36 -9
  38. package/gsd-core/bin/lib/model-catalog.cjs +51 -1
  39. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  40. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  41. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  42. package/gsd-core/bin/lib/phase-id.cjs +278 -5
  43. package/gsd-core/bin/lib/phase.cjs +61 -6
  44. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  45. package/gsd-core/bin/lib/plan-scan.cjs +1 -1
  46. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  47. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  48. package/gsd-core/bin/lib/project-root.cjs +48 -0
  49. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  50. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  51. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  52. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
  54. package/gsd-core/bin/lib/roadmap.cjs +10 -4
  55. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
  56. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
  57. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
  58. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  59. package/gsd-core/bin/lib/smart-entry.cjs +1 -1
  60. package/gsd-core/bin/lib/state-document.cjs +164 -20
  61. package/gsd-core/bin/lib/state-transition.cjs +28 -10
  62. package/gsd-core/bin/lib/state.cjs +141 -21
  63. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  64. package/gsd-core/bin/lib/uat.cjs +9 -7
  65. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  66. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  67. package/gsd-core/bin/lib/validate.cjs +32 -0
  68. package/gsd-core/bin/lib/verification.cjs +51 -14
  69. package/gsd-core/bin/lib/verify.cjs +146 -22
  70. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  71. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
  73. package/gsd-core/bin/shared/model-catalog.json +5 -0
  74. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  75. package/gsd-core/references/context-budget.md +40 -0
  76. package/gsd-core/references/gate-prompts.md +6 -3
  77. package/gsd-core/references/model-profile-resolution.md +64 -13
  78. package/gsd-core/references/offer-next.md +88 -0
  79. package/gsd-core/references/planning-config.md +2 -1
  80. package/gsd-core/references/reviewer-instances.md +28 -21
  81. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  82. package/gsd-core/references/ui-consideration-probe.md +2 -2
  83. package/gsd-core/references/worktree-branch-check.md +4 -4
  84. package/gsd-core/templates/summary-minimal.md +4 -0
  85. package/gsd-core/templates/summary-standard.md +4 -0
  86. package/gsd-core/templates/summary.md +7 -0
  87. package/gsd-core/workflows/ai-integration-phase.md +4 -4
  88. package/gsd-core/workflows/audit-fix.md +4 -0
  89. package/gsd-core/workflows/audit-milestone.md +8 -0
  90. package/gsd-core/workflows/autonomous.md +19 -15
  91. package/gsd-core/workflows/check-todos.md +2 -2
  92. package/gsd-core/workflows/code-review-fix.md +14 -6
  93. package/gsd-core/workflows/code-review.md +93 -21
  94. package/gsd-core/workflows/debug.md +10 -2
  95. package/gsd-core/workflows/diagnose-issues.md +4 -0
  96. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  97. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  98. package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
  99. package/gsd-core/workflows/discuss-phase.md +2 -2
  100. package/gsd-core/workflows/docs-update.md +8 -0
  101. package/gsd-core/workflows/eval-review.md +1 -1
  102. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  103. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  104. package/gsd-core/workflows/execute-phase.md +85 -115
  105. package/gsd-core/workflows/execute-plan.md +5 -4
  106. package/gsd-core/workflows/explore.md +4 -0
  107. package/gsd-core/workflows/extract-learnings.md +21 -0
  108. package/gsd-core/workflows/help/modes/full.md +3 -3
  109. package/gsd-core/workflows/import.md +4 -1
  110. package/gsd-core/workflows/ingest-docs.md +4 -0
  111. package/gsd-core/workflows/map-codebase.md +13 -6
  112. package/gsd-core/workflows/new-milestone.md +10 -2
  113. package/gsd-core/workflows/new-project.md +11 -4
  114. package/gsd-core/workflows/next.md +5 -2
  115. package/gsd-core/workflows/plan-phase.md +42 -46
  116. package/gsd-core/workflows/plan-review-convergence.md +18 -14
  117. package/gsd-core/workflows/progress.md +1 -1
  118. package/gsd-core/workflows/quick.md +14 -3
  119. package/gsd-core/workflows/review.md +146 -575
  120. package/gsd-core/workflows/scan.md +9 -1
  121. package/gsd-core/workflows/secure-phase.md +10 -2
  122. package/gsd-core/workflows/ship.md +41 -11
  123. package/gsd-core/workflows/smart-entry.md +1 -1
  124. package/gsd-core/workflows/ui-phase.md +8 -1
  125. package/gsd-core/workflows/ui-review.md +8 -1
  126. package/gsd-core/workflows/update.md +104 -5
  127. package/gsd-core/workflows/validate-phase.md +10 -2
  128. package/gsd-core/workflows/verify-work.md +8 -1
  129. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  130. package/hooks/dist/gsd-cursor-stop.js +6 -2
  131. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  132. package/hooks/dist/gsd-graphify-update.sh +9 -0
  133. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  134. package/hooks/dist/gsd-prompt-guard.js +101 -2
  135. package/hooks/dist/gsd-read-guard.js +100 -2
  136. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  137. package/hooks/dist/gsd-statusline.js +9 -6
  138. package/hooks/dist/gsd-workflow-guard.js +110 -6
  139. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  140. package/hooks/dist/lib/cursor-workspace.js +74 -0
  141. package/hooks/gsd-cursor-session-start.js +6 -2
  142. package/hooks/gsd-cursor-stop.js +6 -2
  143. package/hooks/gsd-cursor-subagent-start.js +6 -2
  144. package/hooks/gsd-graphify-update.sh +9 -0
  145. package/hooks/gsd-phase-boundary.sh +14 -2
  146. package/hooks/gsd-prompt-guard.js +101 -2
  147. package/hooks/gsd-read-guard.js +100 -2
  148. package/hooks/gsd-read-injection-scanner.js +109 -2
  149. package/hooks/gsd-statusline.js +9 -6
  150. package/hooks/gsd-workflow-guard.js +110 -6
  151. package/hooks/gsd-worktree-path-guard.js +132 -8
  152. package/hooks/lib/cursor-workspace.js +74 -0
  153. package/package.json +7 -7
  154. package/pi/gsd.cjs +26 -1
  155. package/scripts/check-coverage-gate.cjs +51 -0
  156. package/scripts/check-glossary-refs.cjs +24 -0
  157. package/scripts/ci-test-scope.cjs +67 -17
  158. package/scripts/gen-adr-index.cjs +6 -4
  159. package/scripts/gen-capability-matrix.cjs +26 -2
  160. package/scripts/gen-capability-registry.cjs +132 -34
  161. package/scripts/gen-emitted-baseline.cjs +145 -0
  162. package/scripts/gen-registry.cjs +39 -15
  163. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  164. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  165. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  166. package/scripts/lint-resolution-provenance.cjs +9 -0
  167. package/scripts/mutation-matrix.cjs +4 -0
  168. package/scripts/prompt-injection-scan.sh +6 -0
  169. package/scripts/registry-schema.cjs +372 -94
  170. package/scripts/release-notes/conventional-title.cjs +19 -1
  171. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  172. package/scripts/validate-registry.cjs +10 -6
  173. package/scripts/workflow-size.cjs +16 -8
  174. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  175. package/vscode/package.json +1 -1
  176. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  177. package/scripts/update-size-baseline.cjs +0 -68
@@ -145,6 +145,10 @@ const COVERED = {
145
145
  tests: [
146
146
  'tests/frontmatter.property.test.cjs',
147
147
  'tests/frontmatter.unit.test.cjs',
148
+ // #1882 added the unterminated-fence detection to frontmatter.cjs, and the tests that
149
+ // constrain it live here. Without this entry the mutants in that branch are covered by
150
+ // no test in the shard, so the module's score drops even though the behaviour is tested.
151
+ 'tests/unusable-input.test.cjs',
148
152
  ],
149
153
  minScore: 62,
150
154
  },
@@ -102,6 +102,12 @@ ALLOWLIST=(
102
102
  # exec command strings (execFileSync('npm', ['install'])) as test DATA the rule
103
103
  # must lint — not attack vectors. ADR-1703 Phase 4 (#1726).
104
104
  'tests/no-bare-npm-exec.rule.test.cjs'
105
+ # #2547 — the Kimi field-shadowing regression proves gsd-prompt-guard still
106
+ # SCANS the reconstructed edit[].new content when a model-supplied new_string
107
+ # tries to shadow it. The fixture must be a real injection phrase or the test
108
+ # asserts nothing: it is the payload the guard is required to catch, carried
109
+ # as test DATA. Same class as the read-injection-scanner suites above.
110
+ 'tests/kimi-payload-field-shadowing.security.test.cjs'
105
111
  )
106
112
 
107
113
  is_allowlisted() {
@@ -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-
@@ -36,10 +37,19 @@
36
37
  * (`{ namedDispatch, nested, maxDepth, background, subagentToolkit }`) —
37
38
  * this registry accepts a free-form human summary string instead, so it
38
39
  * 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`).
40
+ * `OPTIONAL_AXES` adds one further, OPTIONAL key on top of those eight:
41
+ * `effortSurface` (ADR-1239 amendment #2481). An entry may omit it
42
+ * (every entry published before the amendment stays valid) or declare
43
+ * it as `argv` | `none`, mirroring `HOST_INTEGRATION_AXES.effortSurface`
44
+ * in `src/host-integration.cts`.
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).
43
53
  *
44
54
  * This module is pure — no `fs`/`process`/child-process access — so tests
45
55
  * can `require()` it directly and assert on structured return values.
@@ -90,37 +100,130 @@ const AXES = Object.freeze({
90
100
  runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']),
91
101
  });
92
102
 
103
+ // ─── ADR-1239 amendment #2481 — one additional, OPTIONAL negotiated axis ─────
104
+ // `effortSurface` was added to the runtime-descriptor vocabulary AFTER the
105
+ // original eight (`HOST_INTEGRATION_AXES.effortSurface` in
106
+ // `src/host-integration.cts`). It is kept OPTIONAL here — not folded into
107
+ // `AXES` — because registry entries mirror their upstream
108
+ // `registry/eos-entry.json` byte-for-byte, and requiring it would
109
+ // retroactively invalidate every entry published before the amendment.
110
+ // Values must match `HOST_INTEGRATION_AXES.effortSurface` exactly.
111
+ const OPTIONAL_AXES = Object.freeze({
112
+ effortSurface: Object.freeze(['argv', 'none']),
113
+ });
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
+
93
146
  // ─── 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',
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',
107
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
+ }
108
181
 
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
- ]);
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
+ }
124
227
 
125
228
  // Escape Markdown inline metacharacters in UNTRUSTED free text so a registry
126
229
  // entry cannot inject links/tables/code-spans into the generated catalog.
@@ -204,10 +307,7 @@ function validateCapabilityInteractions(interactions, addError) {
204
307
 
205
308
  for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
206
309
  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
- }
310
+ validateStringArrayField(interactions, field, addError);
211
311
  }
212
312
  }
213
313
 
@@ -246,15 +346,39 @@ function validateEosInteractions(interactions, addError) {
246
346
  if (typeof axes !== 'object' || axes === null || Array.isArray(axes)) {
247
347
  addError('interactions.axes', 'axes must be an object');
248
348
  } else {
249
- const expectedKeys = Object.keys(AXES);
349
+ const requiredKeys = Object.keys(AXES);
350
+ const optionalKeys = Object.keys(OPTIONAL_AXES);
250
351
  const actualKeys = Object.keys(axes);
251
352
  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];
353
+
354
+ // Every AXES key is mandatory; an extra key is tolerated ONLY when it is
355
+ // a recognized OPTIONAL_AXES key (currently just `effortSurface`) — any
356
+ // other extra key is still rejected as unknown.
357
+ const missingRequiredKeys = requiredKeys.filter((k) => !actualKeySet.has(k));
358
+ const unknownKeys = actualKeys.filter((k) => !requiredKeys.includes(k) && !optionalKeys.includes(k));
359
+
360
+ if (missingRequiredKeys.length > 0) {
361
+ addError('interactions.axes', `axes is missing required key(s): ${missingRequiredKeys.join(', ')}`);
362
+ }
363
+ if (unknownKeys.length > 0) {
364
+ addError('interactions.axes', `axes has unknown key(s): ${unknownKeys.join(', ')}`);
365
+ }
366
+
367
+ // Only validate individual values once the key set itself is sound —
368
+ // mirrors the original gate (values were never checked against a
369
+ // malformed key set either).
370
+ if (missingRequiredKeys.length === 0 && unknownKeys.length === 0) {
371
+ for (const key of actualKeys) {
372
+ // Inline literal guards — CodeQL barrier pattern. Reaching here already
373
+ // implies `key` is one of the nine literal axis names (the unknown-key
374
+ // gate above rejected everything else), so this is unreachable in
375
+ // practice; it is written inline anyway because CodeQL cannot follow
376
+ // that gate across the `.includes()` filter and would otherwise flag
377
+ // the bracket reads below as prototype-pollution sinks.
378
+ if (key === '__proto__') continue;
379
+ if (key === 'constructor') continue;
380
+ if (key === 'prototype') continue;
381
+ const allowedValues = Object.hasOwn(AXES, key) ? AXES[key] : OPTIONAL_AXES[key];
258
382
  const v = axes[key];
259
383
  if (allowedValues === AXES_FREE_STRING) {
260
384
  if (typeof v !== 'string' || v.trim() === '') {
@@ -271,12 +395,93 @@ function validateEosInteractions(interactions, addError) {
271
395
  }
272
396
  }
273
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
+
274
479
  /**
275
480
  * Validate an array of registry entries against the closed schema for
276
- * `opts.type` ('capability' | 'eos').
481
+ * `opts.type` ('capability' | 'eos' | 'reviewer').
277
482
  *
278
483
  * @param {object[]} entries
279
- * @param {{type: 'capability'|'eos'}} opts
484
+ * @param {{type: 'capability'|'eos'|'reviewer'}} opts
280
485
  * @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
281
486
  */
282
487
  function validateEntries(entries, opts) {
@@ -284,13 +489,22 @@ function validateEntries(entries, opts) {
284
489
  return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'entries must be an array' }] };
285
490
  }
286
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
+
287
501
  // Entry-count cap: a pathologically large array (e.g. from an automated or
288
502
  // malicious PR) is rejected wholesale rather than validated entry-by-entry.
289
503
  if (entries.length > 2000) {
290
504
  return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'too many entries (max 2000)' }] };
291
505
  }
292
506
 
293
- const required = opts.type === 'eos' ? EOS_REQUIRED : CAPABILITY_REQUIRED;
507
+ const required = rules.required;
294
508
  const requiredSet = new Set(required);
295
509
  const seenIds = new Set();
296
510
  const errors = [];
@@ -322,21 +536,9 @@ function validateEntries(entries, opts) {
322
536
  }
323
537
  }
324
538
 
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
- };
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.
340
542
  const checkNoControlChars = (field, allowTabNewline) => {
341
543
  if (missing.has(field)) return;
342
544
  const v = entry[field];
@@ -419,10 +621,8 @@ function validateEntries(entries, opts) {
419
621
  const interactions = entry.interactions;
420
622
  if (typeof interactions !== 'object' || interactions === null || Array.isArray(interactions)) {
421
623
  addError('interactions', 'interactions must be an object');
422
- } else if (opts.type === 'eos') {
423
- validateEosInteractions(interactions, addError);
424
624
  } else {
425
- validateCapabilityInteractions(interactions, addError);
625
+ rules.validateInteractions(interactions, addError);
426
626
  }
427
627
  }
428
628
 
@@ -436,12 +636,92 @@ function validateEntries(entries, opts) {
436
636
  return { ok: errors.length === 0, errors };
437
637
  }
438
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
+
439
718
  /**
440
719
  * Render the deterministic Markdown document for a registry.
441
720
  *
442
721
  * @param {object[]} entries
443
- * @param {{type: 'capability'|'eos', sourceFile?: string}} opts
722
+ * @param {{type: 'capability'|'eos'|'reviewer', sourceFile?: string}} opts
444
723
  * @returns {string}
724
+ * @throws {Error} when opts.type is not a known registry type
445
725
  */
446
726
  function renderMarkdown(entries, opts) {
447
727
  const sorted = [...entries].sort((a, b) => {
@@ -450,19 +730,27 @@ function renderMarkdown(entries, opts) {
450
730
  return 0;
451
731
  });
452
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}"`);
453
741
  const lines = [];
454
742
 
455
743
  lines.push(
456
744
  `<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/${opts.sourceFile} — do not edit by hand; run \`npm run gen:registry\` -->`,
457
745
  );
458
746
  lines.push('');
459
- lines.push(isEos ? '# GSD EoS Registry' : '# GSD Community Capability Registry');
747
+ lines.push(`# ${meta.title}`);
460
748
  lines.push('');
461
749
  lines.push(
462
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).",
463
751
  );
464
752
  lines.push('');
465
- 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)._`);
466
754
  lines.push('');
467
755
 
468
756
  if (sorted.length === 0) {
@@ -495,31 +783,12 @@ function renderMarkdown(entries, opts) {
495
783
  lines.push(`- **What it is:** ${mdInline(entry.description)}`);
496
784
  lines.push(`- **Author:** ${mdInline(entry.author)}`);
497
785
 
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
- }
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))}`);
523
792
 
524
793
  // Code-span content (install/uninstall) is NOT mdInline-escaped — it is a
525
794
  // verbatim shell snippet, not inline prose. Instead each block picks a
@@ -556,9 +825,18 @@ module.exports = {
556
825
  INTERFACE_POINTS,
557
826
  PROFILES,
558
827
  AXES,
828
+ OPTIONAL_AXES,
559
829
  AXES_FREE_STRING,
560
830
  CAPABILITY_REQUIRED,
561
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,
562
840
  isValidGsdRange,
563
841
  validateEntries,
564
842
  renderMarkdown,
@@ -30,18 +30,36 @@ const HEADER_RE = /^([a-z]+)(\([^)]*\))?(!)?:/i;
30
30
  // An issue reference inside a scope: `(#123)`, `(#123, core)`, etc.
31
31
  const ISSUE_REF_IN_SCOPE_RE = /#\d+/;
32
32
 
33
+ // #2716: recognized non-user-facing conventional-commit types. A title whose
34
+ // START-anchored type prefix is one of these is internal work (tests, chores,
35
+ // CI, docs, refactors, perf, reverts) and must NOT render under the user-facing
36
+ // "Enhancement" heading in release notes. Only a CLEAN prefix match qualifies —
37
+ // untyped or anchor-defeated titles fall through to the visible Enhancement
38
+ // fallback (a safety net so possibly-user-facing content is never hidden).
39
+ const NON_USER_FACING_TYPES = new Set([
40
+ 'docs', 'refactor', 'test', 'ci', 'chore', 'perf', 'revert',
41
+ ]);
42
+
33
43
  /**
34
44
  * Classify a clean conventional title into a changelog bucket.
35
45
  * Callers that hold a full changelog bullet line (with a `* ` marker and a
36
46
  * ` by @author` suffix) must strip those first; this operates on the title.
37
47
  *
38
48
  * @param {string} title
39
- * @returns {'Feature'|'Fix'|'Enhancement'}
49
+ * @returns {'Feature'|'Fix'|'Enhancement'|'Internal'}
40
50
  */
41
51
  function classifyBucket(title) {
42
52
  const t = String(title == null ? '' : title).trim();
43
53
  if (FEATURE_RE.test(t)) return 'Feature';
44
54
  if (FIX_RE.test(t)) return 'Fix';
55
+ // #2716: a clean non-user-facing type prefix → Internal (omitted from user-facing
56
+ // release-note sections). The HEADER_RE anchor ensures a leading tag/prefix
57
+ // (e.g. `[security] fix(...)`) does NOT match here — those keep falling through
58
+ // to the visible Enhancement fallback.
59
+ const headerMatch = HEADER_RE.exec(t);
60
+ if (headerMatch && NON_USER_FACING_TYPES.has(headerMatch[1].toLowerCase())) {
61
+ return 'Internal';
62
+ }
45
63
  return 'Enhancement';
46
64
  }
47
65