@cleocode/contracts 2026.5.92 → 2026.5.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/docs-taxonomy.test.d.ts +16 -0
- package/dist/__tests__/docs-taxonomy.test.d.ts.map +1 -0
- package/dist/__tests__/docs-taxonomy.test.js +404 -0
- package/dist/__tests__/docs-taxonomy.test.js.map +1 -0
- package/dist/cli-category.d.ts +24 -0
- package/dist/cli-category.d.ts.map +1 -0
- package/dist/cli-category.js +32 -0
- package/dist/cli-category.js.map +1 -0
- package/dist/docs-taxonomy.d.ts +286 -0
- package/dist/docs-taxonomy.d.ts.map +1 -0
- package/dist/docs-taxonomy.js +489 -0
- package/dist/docs-taxonomy.js.map +1 -0
- package/dist/doctor.d.ts +120 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +16 -0
- package/dist/doctor.js.map +1 -0
- package/dist/exit-codes.d.ts +29 -0
- package/dist/exit-codes.d.ts.map +1 -1
- package/dist/exit-codes.js +29 -0
- package/dist/exit-codes.js.map +1 -1
- package/dist/index.d.ts +11 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/operations/docs.d.ts +89 -11
- package/dist/operations/docs.d.ts.map +1 -1
- package/dist/operations/docs.js +19 -12
- package/dist/operations/docs.js.map +1 -1
- package/dist/operations/release.d.ts +0 -25
- package/dist/operations/release.d.ts.map +1 -1
- package/dist/operations/session.d.ts +66 -0
- package/dist/operations/session.d.ts.map +1 -1
- package/dist/operations/tasks.d.ts +64 -0
- package/dist/operations/tasks.d.ts.map +1 -1
- package/dist/operations/validate.d.ts +32 -0
- package/dist/operations/validate.d.ts.map +1 -1
- package/dist/release/evidence-atoms.d.ts +37 -1
- package/dist/release/evidence-atoms.d.ts.map +1 -1
- package/dist/release/evidence-atoms.js +22 -0
- package/dist/release/evidence-atoms.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/docs-taxonomy.test.ts +465 -0
- package/src/cli-category.ts +52 -0
- package/src/docs-taxonomy.ts +682 -0
- package/src/doctor.ts +130 -0
- package/src/exit-codes.ts +30 -0
- package/src/index.ts +36 -2
- package/src/operations/docs.ts +92 -18
- package/src/operations/release.ts +0 -28
- package/src/operations/session.ts +71 -0
- package/src/operations/tasks.ts +67 -0
- package/src/operations/validate.ts +34 -0
- package/src/release/evidence-atoms.ts +38 -0
|
@@ -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
|
+
}
|