@cleocode/contracts 2026.5.92 → 2026.5.93

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.
@@ -0,0 +1,682 @@
1
+ /**
2
+ * Canonical Doc-Kind Taxonomy Registry (T9788).
3
+ *
4
+ * Single source of truth for the user-facing document-kind taxonomy consumed
5
+ * by `cleo docs` (add / list / publish-pr / list-types / schema). Prior to
6
+ * this module the taxonomy was duplicated across three files:
7
+ *
8
+ * - `packages/contracts/src/operations/docs.ts` — `DOCS_TYPE_VALUES` (6 kinds)
9
+ * - `packages/core/src/docs/publish-pr.ts` — `KNOWN_DOC_TYPES` (6 kinds)
10
+ * - `packages/cleo/src/dispatch/domains/docs.ts` — local mirror (6 kinds)
11
+ *
12
+ * The three copies drifted independently. T9788 consolidates them behind
13
+ * {@link DocKindRegistry} so a new kind requires editing exactly one place
14
+ * (or one config file for project-level extensions).
15
+ *
16
+ * IMPORTANT — relationship to {@link import('./docs-accessor.js').DocKind}:
17
+ * `DocKind` in `docs-accessor.ts` is the STORAGE-LAYER discriminator (which
18
+ * backing store holds the bytes — llmtxt.db vs manifest.db). The taxonomy
19
+ * here is the USER-FACING DOCUMENT CLASSIFICATION (what kind of document
20
+ * the human / agent authored). The two are intentionally distinct:
21
+ *
22
+ * - `docs-accessor.DocKind` answers "where is it stored?"
23
+ * - `docs-taxonomy.DocKindMetadata.kind` answers "what is it about?"
24
+ *
25
+ * Most user-facing docs are stored under `docs-accessor.DocKind = 'adr'` or
26
+ * `'agent-output'`; their taxonomy `kind` (this file) carries the semantic
27
+ * classification.
28
+ *
29
+ * Backward compatibility (T9788 AC8):
30
+ * - Every prior `DOCS_TYPE_VALUES` value remains in {@link BUILTIN_DOC_KINDS}.
31
+ * - Tests using literal strings `'spec'`, `'adr'`, etc. continue to pass.
32
+ * - The stored `type` column shape is unchanged (still a string).
33
+ *
34
+ * @epic T9787 (E-DOCS-TAXONOMY-V2)
35
+ * @task T9788
36
+ * @see ADR-073 §1 — Task Hierarchy Charter (sibling registry pattern)
37
+ */
38
+
39
+ import { readFileSync } from 'node:fs';
40
+ import { join } from 'node:path';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Core taxonomy metadata interface
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Metadata for a single document kind in the canonical registry.
48
+ *
49
+ * Built-in kinds are declared in {@link BUILTIN_DOC_KINDS}; project-level
50
+ * extensions are loaded from `.cleo/docs-config.json` via
51
+ * {@link DocKindRegistry.load}.
52
+ *
53
+ * @see {@link DocKindRegistry} — runtime accessor and validator
54
+ */
55
+ export interface DocKindMetadata {
56
+ /** Canonical kind id, lowercase kebab-case. */
57
+ readonly kind: string;
58
+ /** Human label for display in CLI output and UIs. */
59
+ readonly label: string;
60
+ /** One-line description shown by `cleo docs list-types`. */
61
+ readonly description: string;
62
+ /**
63
+ * Default owner kind prefix when no explicit owner is provided.
64
+ *
65
+ * - `task` — `T###`
66
+ * - `session` — `ses_*`
67
+ * - `observation` — `O-*`
68
+ * - `project` — repo-root project doc (no entity owner)
69
+ */
70
+ readonly defaultOwnerKind: 'task' | 'session' | 'observation' | 'project';
71
+ /**
72
+ * Publish-dir under the repo root for `cleo docs publish-pr`.
73
+ *
74
+ * Examples: `docs/adr`, `docs/spec`, `.changeset`, `docs/release`.
75
+ */
76
+ readonly publishDir: string;
77
+ /**
78
+ * When `true`, every doc of this kind MUST carry a slug matching
79
+ * {@link entityIdPattern}.
80
+ *
81
+ * Used by `cleo docs add --type X --slug Y` to enforce naming
82
+ * conventions for kinds whose downstream consumers parse the slug
83
+ * (e.g. ADRs expect `adr-NNN-<rest>`).
84
+ */
85
+ readonly requiresEntityId: boolean;
86
+ /**
87
+ * Regex slug pattern. Validated when {@link requiresEntityId} is `true`.
88
+ *
89
+ * Stored as a {@link RegExp} so the validator can run without a
90
+ * compile step. Extensions loaded from `.cleo/docs-config.json` parse
91
+ * their string pattern into a `RegExp` at load time.
92
+ */
93
+ readonly entityIdPattern?: RegExp;
94
+ /**
95
+ * Marks an entry that was loaded from `.cleo/docs-config.json` rather
96
+ * than the built-in {@link BUILTIN_DOC_KINDS} array.
97
+ *
98
+ * Built-in kinds leave this unset; extensions set it to `true`.
99
+ */
100
+ readonly isExtension?: boolean;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Built-in registry (10 canonical kinds)
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Canonical list of built-in document kinds.
109
+ *
110
+ * Adding a kind requires:
111
+ * 1. Append an entry here (preserving existing kinds for back-compat).
112
+ * 2. Run the contracts build — every consumer picks the new kind up.
113
+ *
114
+ * Order is preserved by {@link DocKindRegistry.list}. Built-in kinds
115
+ * always sort before extensions.
116
+ *
117
+ * @see {@link DocKindMetadata} — entry shape
118
+ */
119
+ export const BUILTIN_DOC_KINDS: ReadonlyArray<DocKindMetadata> = [
120
+ {
121
+ kind: 'adr',
122
+ label: 'ADR',
123
+ description: 'Architectural decision record',
124
+ defaultOwnerKind: 'task',
125
+ publishDir: 'docs/adr',
126
+ requiresEntityId: true,
127
+ entityIdPattern: /^adr-\d{3,4}-[a-z0-9-]+$/,
128
+ },
129
+ {
130
+ kind: 'spec',
131
+ label: 'Spec',
132
+ description: 'Technical specification',
133
+ defaultOwnerKind: 'task',
134
+ publishDir: 'docs/spec',
135
+ requiresEntityId: false,
136
+ },
137
+ {
138
+ kind: 'research',
139
+ label: 'Research',
140
+ description: 'Investigation / research note',
141
+ defaultOwnerKind: 'task',
142
+ publishDir: 'docs/research',
143
+ requiresEntityId: false,
144
+ },
145
+ {
146
+ kind: 'handoff',
147
+ label: 'Handoff',
148
+ description: 'Session / agent handoff',
149
+ defaultOwnerKind: 'session',
150
+ publishDir: 'docs/handoff',
151
+ requiresEntityId: false,
152
+ },
153
+ {
154
+ kind: 'note',
155
+ label: 'Note',
156
+ description: 'Agent observation / informal note',
157
+ defaultOwnerKind: 'observation',
158
+ publishDir: 'docs/note',
159
+ requiresEntityId: false,
160
+ },
161
+ {
162
+ kind: 'llm-readme',
163
+ label: 'LLM README',
164
+ description: 'Machine-readable README (llms.txt)',
165
+ defaultOwnerKind: 'project',
166
+ publishDir: '.',
167
+ requiresEntityId: false,
168
+ },
169
+ {
170
+ kind: 'changeset',
171
+ label: 'Changeset',
172
+ description: 'Atomic change entry (release-note input)',
173
+ defaultOwnerKind: 'task',
174
+ publishDir: '.changeset',
175
+ requiresEntityId: true,
176
+ entityIdPattern: /^t\d+-[a-z0-9-]+$/,
177
+ },
178
+ {
179
+ kind: 'release-note',
180
+ label: 'Release Note',
181
+ description: 'Composed release notes',
182
+ defaultOwnerKind: 'project',
183
+ publishDir: 'docs/release',
184
+ requiresEntityId: true,
185
+ entityIdPattern: /^v\d{4}\.\d+\.\d+(-[a-z0-9-]+)?$/,
186
+ },
187
+ {
188
+ kind: 'plan',
189
+ label: 'Plan',
190
+ description: 'Epic / saga decomposition plan',
191
+ defaultOwnerKind: 'task',
192
+ publishDir: 'docs/plan',
193
+ requiresEntityId: false,
194
+ },
195
+ {
196
+ kind: 'rcasd',
197
+ label: 'RCASD',
198
+ description: 'Root-cause analysis + scoped delivery',
199
+ defaultOwnerKind: 'task',
200
+ publishDir: '.cleo/rcasd',
201
+ requiresEntityId: true,
202
+ entityIdPattern: /^t\d+(-.+)?$/,
203
+ },
204
+ ];
205
+
206
+ /**
207
+ * Tuple of every built-in kind id (lowercase kebab-case).
208
+ *
209
+ * Useful for `satisfies` checks and union derivation in downstream
210
+ * contracts (e.g. `operations/docs.ts`). Kept frozen.
211
+ */
212
+ export const BUILTIN_DOC_KIND_VALUES: ReadonlyArray<string> = Object.freeze(
213
+ BUILTIN_DOC_KINDS.map((d) => d.kind),
214
+ );
215
+
216
+ /**
217
+ * Union of every built-in kind id.
218
+ *
219
+ * Extensions loaded at runtime widen the runtime registry but NOT this
220
+ * compile-time type — that is intentional: extensions are opt-in and
221
+ * code that only handles the built-in surface stays type-safe.
222
+ */
223
+ export type BuiltinDocKind = (typeof BUILTIN_DOC_KINDS)[number]['kind'];
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Extension config schema
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * Wire shape of an extension entry in `.cleo/docs-config.json`.
231
+ *
232
+ * `entityIdPattern` is a string here (not a {@link RegExp}) because JSON
233
+ * cannot carry compiled regexes. {@link DocKindRegistry.load} compiles it
234
+ * to a `RegExp` while validating the config.
235
+ */
236
+ export interface DocKindExtensionConfig {
237
+ /** Canonical kind id, lowercase kebab-case. */
238
+ readonly kind: string;
239
+ /** Human label for display. */
240
+ readonly label: string;
241
+ /** One-line description for `cleo docs list-types`. */
242
+ readonly description: string;
243
+ /** Default owner kind prefix. */
244
+ readonly defaultOwnerKind: 'task' | 'session' | 'observation' | 'project';
245
+ /** Publish-dir under repo root. */
246
+ readonly publishDir: string;
247
+ /** When `true`, slug MUST match {@link entityIdPattern}. */
248
+ readonly requiresEntityId: boolean;
249
+ /**
250
+ * Regex source string (no flags). Compiled to a `RegExp` at load time.
251
+ *
252
+ * Validated against {@link DocKindRegistry.SAFE_REGEX_LENGTH_LIMIT} so
253
+ * a malformed config can't trigger pathological backtracking.
254
+ */
255
+ readonly entityIdPattern?: string;
256
+ }
257
+
258
+ /**
259
+ * Top-level shape of `.cleo/docs-config.json`.
260
+ *
261
+ * Future fields stay backward compatible by being optional.
262
+ */
263
+ export interface DocKindConfigFile {
264
+ /** Project-level extension registry. */
265
+ readonly extensions?: ReadonlyArray<DocKindExtensionConfig>;
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Slug validation result type
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /**
273
+ * Result of {@link DocKindRegistry.validateSlug}.
274
+ *
275
+ * - `ok: true` — slug is valid for the given kind.
276
+ * - `ok: false` — slug fails validation; `error` carries a human-readable
277
+ * reason and `example` shows a passing slug.
278
+ */
279
+ export type SlugValidationResult =
280
+ | { readonly ok: true }
281
+ | { readonly ok: false; readonly error: string; readonly example?: string };
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Registry class
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Runtime accessor for the canonical doc-kind registry.
289
+ *
290
+ * Combines {@link BUILTIN_DOC_KINDS} with project-level extensions loaded
291
+ * from `.cleo/docs-config.json`. Built-in entries always win on collision
292
+ * — extensions cannot override a built-in kind.
293
+ *
294
+ * Usage:
295
+ * ```ts
296
+ * const registry = DocKindRegistry.load(projectRoot);
297
+ * const adr = registry.get('adr');
298
+ * const check = registry.validateSlug('adr', 'adr-001-intro');
299
+ * ```
300
+ *
301
+ * @task T9788
302
+ */
303
+ export class DocKindRegistry {
304
+ /**
305
+ * Maximum allowed length of an `entityIdPattern` source string.
306
+ *
307
+ * Caps the input surface so a malformed extension config cannot trigger
308
+ * pathological regex backtracking. 256 chars is far more than any
309
+ * realistic slug pattern (typical: 30–60 chars).
310
+ */
311
+ static readonly SAFE_REGEX_LENGTH_LIMIT = 256;
312
+
313
+ private readonly byKind: ReadonlyMap<string, DocKindMetadata>;
314
+ private readonly orderedEntries: ReadonlyArray<DocKindMetadata>;
315
+
316
+ /**
317
+ * Construct a registry from an explicit array of entries.
318
+ *
319
+ * Most callers should use {@link DocKindRegistry.load} instead; this
320
+ * constructor is exposed for tests that want to bypass filesystem I/O.
321
+ *
322
+ * @param entries - Pre-validated doc-kind metadata (built-in + extensions).
323
+ */
324
+ constructor(entries: ReadonlyArray<DocKindMetadata>) {
325
+ this.orderedEntries = entries;
326
+ const map = new Map<string, DocKindMetadata>();
327
+ for (const entry of entries) {
328
+ // First write wins; built-ins are passed first by `load`, so this
329
+ // automatically gives built-ins precedence over extensions.
330
+ if (!map.has(entry.kind)) map.set(entry.kind, entry);
331
+ }
332
+ this.byKind = map;
333
+ }
334
+
335
+ /**
336
+ * Load the canonical registry, merging built-ins with extensions from
337
+ * `<projectRoot>/.cleo/docs-config.json`.
338
+ *
339
+ * Missing or unreadable config file → returns the built-in-only registry.
340
+ * Malformed config (bad JSON, invalid entry, regex too long, etc.) →
341
+ * throws {@link DocKindConfigError} so the caller can surface a clear
342
+ * envelope rather than silently dropping extensions.
343
+ *
344
+ * @param projectRoot - Absolute path to the repo root.
345
+ * @throws DocKindConfigError when the config exists but is invalid.
346
+ */
347
+ static load(projectRoot: string): DocKindRegistry {
348
+ const configPath = join(projectRoot, '.cleo', 'docs-config.json');
349
+
350
+ let raw: string;
351
+ try {
352
+ raw = readFileSync(configPath, 'utf-8');
353
+ } catch {
354
+ // No config file → built-ins only.
355
+ return new DocKindRegistry(BUILTIN_DOC_KINDS);
356
+ }
357
+
358
+ let parsed: unknown;
359
+ try {
360
+ parsed = JSON.parse(raw);
361
+ } catch (err) {
362
+ throw new DocKindConfigError(
363
+ `${configPath}: invalid JSON — ${(err as Error).message}`,
364
+ configPath,
365
+ );
366
+ }
367
+
368
+ const config = validateDocKindConfig(parsed, configPath);
369
+ const extensions = (config.extensions ?? []).map((ext) => compileExtension(ext, configPath));
370
+
371
+ return new DocKindRegistry([...BUILTIN_DOC_KINDS, ...extensions]);
372
+ }
373
+
374
+ /**
375
+ * Build a registry from already-parsed config — bypasses filesystem I/O.
376
+ *
377
+ * Used by tests and HTTP-dispatch callers that hand-construct a config
378
+ * object instead of reading from disk.
379
+ *
380
+ * @param config - Parsed config object (or `undefined` for built-ins only).
381
+ * @param sourceLabel - Optional label used in error messages.
382
+ * @throws DocKindConfigError when the config object is invalid.
383
+ */
384
+ static fromConfig(
385
+ config: DocKindConfigFile | undefined,
386
+ sourceLabel = '<inline-config>',
387
+ ): DocKindRegistry {
388
+ if (!config) return new DocKindRegistry(BUILTIN_DOC_KINDS);
389
+ const validated = validateDocKindConfig(config, sourceLabel);
390
+ const extensions = (validated.extensions ?? []).map((ext) =>
391
+ compileExtension(ext, sourceLabel),
392
+ );
393
+ return new DocKindRegistry([...BUILTIN_DOC_KINDS, ...extensions]);
394
+ }
395
+
396
+ /**
397
+ * Default registry — built-in kinds only, no extensions.
398
+ *
399
+ * Suitable for code paths that never need project-level extensions
400
+ * (e.g. unit tests, library-mode consumers).
401
+ */
402
+ static builtinOnly(): DocKindRegistry {
403
+ return new DocKindRegistry(BUILTIN_DOC_KINDS);
404
+ }
405
+
406
+ /** True when `kind` is registered (built-in OR extension). */
407
+ has(kind: string): boolean {
408
+ return this.byKind.has(kind);
409
+ }
410
+
411
+ /** Look up metadata for a registered kind. Returns `undefined` on miss. */
412
+ get(kind: string): DocKindMetadata | undefined {
413
+ return this.byKind.get(kind);
414
+ }
415
+
416
+ /**
417
+ * List every registered kind, built-ins first then extensions in
418
+ * declaration order.
419
+ *
420
+ * Used by `cleo docs schema` and `cleo docs list-types`.
421
+ */
422
+ list(): ReadonlyArray<DocKindMetadata> {
423
+ return this.orderedEntries;
424
+ }
425
+
426
+ /**
427
+ * Validate a slug against the registered pattern for `kind`.
428
+ *
429
+ * Behaviour:
430
+ * - Unknown kind → `{ ok: false, error: "unknown kind '<kind>'" }`.
431
+ * - Known kind with `requiresEntityId === false` → always `{ ok: true }`.
432
+ * - Known kind with `requiresEntityId === true` and no pattern → defensive
433
+ * `{ ok: false }` since the registry entry is internally inconsistent.
434
+ * - Known kind with pattern → tests `slug` against the pattern.
435
+ *
436
+ * @param kind - Registered kind id.
437
+ * @param slug - Slug to validate.
438
+ * @returns Pass/fail result with a human-readable error on failure.
439
+ */
440
+ validateSlug(kind: string, slug: string): SlugValidationResult {
441
+ const meta = this.byKind.get(kind);
442
+ if (!meta) {
443
+ return { ok: false, error: `unknown kind '${kind}'` };
444
+ }
445
+ if (!meta.requiresEntityId) {
446
+ return { ok: true };
447
+ }
448
+ if (!meta.entityIdPattern) {
449
+ // Defensive: registry entry marked requiresEntityId but lacks the
450
+ // pattern. Built-in entries always carry one; an extension that
451
+ // omits it is rejected at load time, so this branch is reachable
452
+ // only via the public constructor with a hand-crafted array.
453
+ return {
454
+ ok: false,
455
+ error: `kind '${kind}' requires an entityIdPattern but the registry entry omits one`,
456
+ };
457
+ }
458
+ if (!meta.entityIdPattern.test(slug)) {
459
+ return {
460
+ ok: false,
461
+ error: `slug '${slug}' does not match pattern ${meta.entityIdPattern.source} for kind '${kind}'`,
462
+ example: buildSlugExample(meta),
463
+ };
464
+ }
465
+ return { ok: true };
466
+ }
467
+
468
+ /**
469
+ * Map a kind to its `publishDir` (e.g. `'adr'` → `'docs/adr'`).
470
+ *
471
+ * Returns `undefined` for unknown kinds — callers decide whether to
472
+ * fall back to a default (e.g. `'docs/note'`) or surface an error.
473
+ */
474
+ publishDirFor(kind: string): string | undefined {
475
+ return this.byKind.get(kind)?.publishDir;
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Error thrown by {@link DocKindRegistry.load} and
481
+ * {@link DocKindRegistry.fromConfig} when the supplied config is invalid.
482
+ *
483
+ * Carries the offending source path / label so the CLI surface can render
484
+ * a `details` payload pointing the user at the right file.
485
+ */
486
+ export class DocKindConfigError extends Error {
487
+ /** Source identifier — file path on disk, or `<inline-config>` for tests. */
488
+ readonly source: string;
489
+
490
+ constructor(message: string, source: string) {
491
+ super(message);
492
+ this.name = 'DocKindConfigError';
493
+ this.source = source;
494
+ }
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Config validation helpers
499
+ // ---------------------------------------------------------------------------
500
+
501
+ /**
502
+ * Narrow an arbitrary parsed-JSON value to {@link DocKindConfigFile}.
503
+ *
504
+ * Performs structural validation only — regex compilation happens in
505
+ * {@link compileExtension}.
506
+ *
507
+ * @internal
508
+ */
509
+ function validateDocKindConfig(raw: unknown, source: string): DocKindConfigFile {
510
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
511
+ throw new DocKindConfigError(`${source}: top-level value must be an object`, source);
512
+ }
513
+ const obj = raw as Record<string, unknown>;
514
+ if (obj.extensions === undefined) {
515
+ return {};
516
+ }
517
+ if (!Array.isArray(obj.extensions)) {
518
+ throw new DocKindConfigError(`${source}: 'extensions' must be an array`, source);
519
+ }
520
+ const extensions: DocKindExtensionConfig[] = [];
521
+ for (let i = 0; i < obj.extensions.length; i++) {
522
+ const item = obj.extensions[i];
523
+ extensions.push(validateExtensionEntry(item, source, i));
524
+ }
525
+ return { extensions };
526
+ }
527
+
528
+ /**
529
+ * Narrow one extension entry from the parsed `extensions[]` array.
530
+ *
531
+ * @internal
532
+ */
533
+ function validateExtensionEntry(
534
+ raw: unknown,
535
+ source: string,
536
+ index: number,
537
+ ): DocKindExtensionConfig {
538
+ const where = `${source} extensions[${index}]`;
539
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
540
+ throw new DocKindConfigError(`${where}: must be an object`, source);
541
+ }
542
+ const obj = raw as Record<string, unknown>;
543
+
544
+ const kind = requireString(obj, 'kind', where, source);
545
+ if (!/^[a-z][a-z0-9-]*$/.test(kind)) {
546
+ throw new DocKindConfigError(
547
+ `${where}: 'kind' must be lowercase kebab-case (got '${kind}')`,
548
+ source,
549
+ );
550
+ }
551
+ const builtinNames = new Set(BUILTIN_DOC_KIND_VALUES);
552
+ if (builtinNames.has(kind)) {
553
+ throw new DocKindConfigError(
554
+ `${where}: kind '${kind}' shadows a built-in — built-ins cannot be overridden`,
555
+ source,
556
+ );
557
+ }
558
+ const label = requireString(obj, 'label', where, source);
559
+ const description = requireString(obj, 'description', where, source);
560
+ const defaultOwnerKind = requireString(obj, 'defaultOwnerKind', where, source);
561
+ if (
562
+ defaultOwnerKind !== 'task' &&
563
+ defaultOwnerKind !== 'session' &&
564
+ defaultOwnerKind !== 'observation' &&
565
+ defaultOwnerKind !== 'project'
566
+ ) {
567
+ throw new DocKindConfigError(
568
+ `${where}: 'defaultOwnerKind' must be task|session|observation|project (got '${defaultOwnerKind}')`,
569
+ source,
570
+ );
571
+ }
572
+ const publishDir = requireString(obj, 'publishDir', where, source);
573
+ const requiresEntityId = obj.requiresEntityId;
574
+ if (typeof requiresEntityId !== 'boolean') {
575
+ throw new DocKindConfigError(`${where}: 'requiresEntityId' must be a boolean`, source);
576
+ }
577
+
578
+ let entityIdPattern: string | undefined;
579
+ if (obj.entityIdPattern !== undefined) {
580
+ if (typeof obj.entityIdPattern !== 'string') {
581
+ throw new DocKindConfigError(`${where}: 'entityIdPattern' must be a string`, source);
582
+ }
583
+ if (obj.entityIdPattern.length > DocKindRegistry.SAFE_REGEX_LENGTH_LIMIT) {
584
+ throw new DocKindConfigError(
585
+ `${where}: 'entityIdPattern' exceeds ${DocKindRegistry.SAFE_REGEX_LENGTH_LIMIT} chars`,
586
+ source,
587
+ );
588
+ }
589
+ entityIdPattern = obj.entityIdPattern;
590
+ }
591
+
592
+ if (requiresEntityId && entityIdPattern === undefined) {
593
+ throw new DocKindConfigError(
594
+ `${where}: 'entityIdPattern' is required when 'requiresEntityId' is true`,
595
+ source,
596
+ );
597
+ }
598
+
599
+ return {
600
+ kind,
601
+ label,
602
+ description,
603
+ defaultOwnerKind,
604
+ publishDir,
605
+ requiresEntityId,
606
+ ...(entityIdPattern !== undefined ? { entityIdPattern } : {}),
607
+ };
608
+ }
609
+
610
+ /**
611
+ * Compile an extension config into a `DocKindMetadata` (regex compiled,
612
+ * `isExtension` set).
613
+ *
614
+ * @internal
615
+ */
616
+ function compileExtension(ext: DocKindExtensionConfig, source: string): DocKindMetadata {
617
+ let entityIdPattern: RegExp | undefined;
618
+ if (ext.entityIdPattern !== undefined) {
619
+ try {
620
+ entityIdPattern = new RegExp(ext.entityIdPattern);
621
+ } catch (err) {
622
+ throw new DocKindConfigError(
623
+ `${source}: invalid regex for kind '${ext.kind}': ${(err as Error).message}`,
624
+ source,
625
+ );
626
+ }
627
+ }
628
+ return {
629
+ kind: ext.kind,
630
+ label: ext.label,
631
+ description: ext.description,
632
+ defaultOwnerKind: ext.defaultOwnerKind,
633
+ publishDir: ext.publishDir,
634
+ requiresEntityId: ext.requiresEntityId,
635
+ ...(entityIdPattern !== undefined ? { entityIdPattern } : {}),
636
+ isExtension: true,
637
+ };
638
+ }
639
+
640
+ /**
641
+ * Extract a required string field from a parsed JSON object.
642
+ *
643
+ * @internal
644
+ */
645
+ function requireString(
646
+ obj: Record<string, unknown>,
647
+ field: string,
648
+ where: string,
649
+ source: string,
650
+ ): string {
651
+ const value = obj[field];
652
+ if (typeof value !== 'string' || value.length === 0) {
653
+ throw new DocKindConfigError(`${where}: '${field}' must be a non-empty string`, source);
654
+ }
655
+ return value;
656
+ }
657
+
658
+ /**
659
+ * Build a best-effort example slug from a metadata entry's pattern.
660
+ *
661
+ * Used by {@link DocKindRegistry.validateSlug} to give the caller a
662
+ * concrete sample slug that would pass. Built-in patterns ship with
663
+ * hard-coded samples; extensions fall back to the pattern source.
664
+ *
665
+ * @internal
666
+ */
667
+ function buildSlugExample(meta: DocKindMetadata): string | undefined {
668
+ // Hard-coded samples for built-in kinds keep the error message friendly.
669
+ // Extensions get the raw pattern as a fallback.
670
+ switch (meta.kind) {
671
+ case 'adr':
672
+ return 'adr-001-intro';
673
+ case 'changeset':
674
+ return 't9788-docs-taxonomy';
675
+ case 'release-note':
676
+ return 'v2026.5.93';
677
+ case 'rcasd':
678
+ return 't9788-investigation';
679
+ default:
680
+ return meta.entityIdPattern?.source;
681
+ }
682
+ }