@kontourai/corpus 0.0.0-bootstrap.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,589 @@
1
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import picomatch from 'picomatch';
4
+ import { UsageError } from './errors.mjs';
5
+ import { isRepoFile, repoGlobError, repoPathError, statInRepo, toFsPath } from './paths.mjs';
6
+ import { synonymsError } from './search.mjs';
7
+
8
+ export const MANIFEST_FILE = 'corpus.json';
9
+ /** The v1 manifest name. Corpus no longer reads it; its presence alone is a usage error. */
10
+ export const RETIRED_MANIFEST_FILE = 'docs-kit.json';
11
+
12
+ /** What a v1 manifest needs to become a v2 corpus.json; shared by every refusal. */
13
+ const V2_REMEDY = 'set "schemaVersion": 2, and add "purpose" (one sentence on what the product is for) and "builtOn" (the Kontour products it builds on, or [])';
14
+
15
+ /** The `purpose` that `corpus init` writes. `check` refuses a manifest whose purpose is exactly this text. */
16
+ export const INIT_PURPOSE_PLACEHOLDER = 'TODO: replace with one sentence on the promise this product makes to its users.';
17
+
18
+ /**
19
+ * The manifest file name for `root`: always corpus.json. A docs-kit.json is
20
+ * refused even next to a corpus.json, so a leftover v1 file can never be read
21
+ * by mistake or disagree silently with the manifest that is.
22
+ */
23
+ export function manifestFile(root) {
24
+ const retired = path.join(root, RETIRED_MANIFEST_FILE);
25
+ if (existsSync(retired)) {
26
+ if (existsSync(path.join(root, MANIFEST_FILE)))
27
+ throw new UsageError(
28
+ `${retired} is a retired v1 manifest and Corpus no longer reads it, but it sits next to ${MANIFEST_FILE}; ` +
29
+ `delete ${RETIRED_MANIFEST_FILE} (move anything still needed into ${MANIFEST_FILE} first)`,
30
+ );
31
+ throw new UsageError(
32
+ `${retired} is a retired v1 manifest and Corpus no longer reads it; rename it to ${MANIFEST_FILE}, ${V2_REMEDY}. ` +
33
+ `Or move it aside and run "corpus init" to scaffold a v2 ${MANIFEST_FILE}`,
34
+ );
35
+ }
36
+ return MANIFEST_FILE;
37
+ }
38
+
39
+ export const DEFAULT_LANDING = 'README.md';
40
+
41
+ const PRODUCT_SLUG = /^[a-z0-9][a-z0-9._-]*$/;
42
+ const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
43
+ const TOPIC_ID = /^[a-z0-9][a-z0-9._-]*$/;
44
+
45
+ const MANIFEST_KEYS = ['schemaVersion', 'product', 'title', 'tagline', 'repo', 'landing', 'branch', 'selectionActions', 'documents', 'topics', 'exclude', 'redirects', 'allowLocalPaths', 'purpose', 'builtOn', 'decisions', 'synonyms'];
46
+ const MANIFEST_REQUIRED = ['schemaVersion', 'product', 'title', 'repo', 'documents', 'purpose', 'builtOn'];
47
+ const DOCUMENT_KEYS = ['source', 'section', 'title', 'covers', 'publish'];
48
+ const DOCUMENT_REQUIRED = ['source', 'section'];
49
+ const TOPIC_KEYS = ['id', 'title', 'source'];
50
+ const TOPIC_REQUIRED = ['id', 'title', 'source'];
51
+
52
+ export const DEFAULT_BRANCH = 'main';
53
+ export const SELECTION_PRIMARY_ACTIONS = ['ask', 'challenge-issue'];
54
+ const SELECTION_ACTIONS_KEYS = ['primary'];
55
+
56
+ /**
57
+ * A branch name the Edit link can put in https://github.com/<repo>/edit/<branch>/:
58
+ * the rules of `git check-ref-format --branch`, minus the forms that need a
59
+ * shell (`@{-1}`). Returns an error string or null.
60
+ */
61
+ function branchError(name) {
62
+ if (/[\x00-\x20\x7f~^:?*[\\]/.test(name)) return 'must not contain whitespace, control characters, or any of ~ ^ : ? * [ \\';
63
+ if (name.startsWith('-') || name.startsWith('/') || name.endsWith('/') || name.endsWith('.')) return 'must not start with "-" or "/" or end with "/" or "."';
64
+ if (name.includes('..') || name.includes('//') || name.includes('@{') || name === '@') return 'must not contain "..", "//", or "@{", and must not be "@"';
65
+ if (name.split('/').some((part) => part.startsWith('.') || part.endsWith('.lock'))) return 'must not have a component that starts with "." or ends with ".lock"';
66
+ return null;
67
+ }
68
+
69
+ const PRODUCTS_KEYS = ['schemaVersion', 'products'];
70
+ const PRODUCT_ENTRY_KEYS = ['product', 'repo'];
71
+
72
+ function isPlainObject(value) {
73
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
74
+ }
75
+
76
+ /** Refuse unknown keys and report missing required keys. */
77
+ function checkKeys(value, where, allowed, required, errors) {
78
+ if (!isPlainObject(value)) {
79
+ errors.push(`${where} must be an object`);
80
+ return false;
81
+ }
82
+ for (const key of Object.keys(value))
83
+ if (!allowed.includes(key)) errors.push(`${where}: unknown key "${key}" (allowed: ${allowed.join(', ')})`);
84
+ for (const key of required)
85
+ if (!Object.hasOwn(value, key)) errors.push(`${where}: missing required key "${key}"`);
86
+ return true;
87
+ }
88
+
89
+ function checkString(value, where, errors, { allowEmpty = false } = {}) {
90
+ if (typeof value !== 'string' || (!allowEmpty && value.trim() === '')) {
91
+ errors.push(`${where} must be a ${allowEmpty ? '' : 'non-empty '}string`);
92
+ return false;
93
+ }
94
+ if (value !== value.trim()) {
95
+ errors.push(`${where} must not have leading or trailing whitespace`);
96
+ return false;
97
+ }
98
+ return true;
99
+ }
100
+
101
+ function checkGlobs(value, where, errors) {
102
+ if (!Array.isArray(value)) {
103
+ errors.push(`${where} must be an array of glob strings`);
104
+ return;
105
+ }
106
+ value.forEach((glob, index) => {
107
+ const problem = repoGlobError(glob);
108
+ if (problem) errors.push(`${where}[${index}] ${problem}`);
109
+ });
110
+ }
111
+
112
+ const GLOB_CHARS = /[*?[\]{}()!+@]/;
113
+
114
+ /**
115
+ * Expand a bare directory name in `covers` to `dir/**`. Whether each glob
116
+ * matches a tracked file is the `covers` gate in check.mjs, not a manifest
117
+ * error, so a stale glob never blocks build or MCP.
118
+ */
119
+ function expandCovers(covers, root) {
120
+ return covers.map((glob) => {
121
+ const bare = glob.replace(/\/+$/, '');
122
+ return !GLOB_CHARS.test(bare) && statInRepo(root, bare)?.kind === 'dir' ? `${bare}/**` : glob;
123
+ });
124
+ }
125
+
126
+ /** Output page path for a published source. The landing page becomes the site index. */
127
+ export function outputPathFor(manifest, source) {
128
+ return source === manifest.landing ? 'index.html' : source.replace(/\.md$/, '.html');
129
+ }
130
+
131
+ /** Split a topic source `path#anchor`. */
132
+ export function splitTopicSource(source) {
133
+ const index = source.indexOf('#');
134
+ return index === -1
135
+ ? { path: source, anchor: null }
136
+ : { path: source.slice(0, index), anchor: source.slice(index + 1) };
137
+ }
138
+
139
+ /**
140
+ * A redirects key is the site-relative path of an old page URL. Returns an
141
+ * error string, or null when the key is acceptable.
142
+ */
143
+ function redirectKeyError(key) {
144
+ if (key === '') return 'must be a non-empty path';
145
+ if (key.startsWith('/')) return 'must be site-relative, without a leading "/"';
146
+ if (key.includes('..')) return 'must not contain ".."';
147
+ if (/[#?]/.test(key)) return 'must not contain "#" or "?"';
148
+ if (!key.endsWith('.html')) return 'must end in ".html"';
149
+ const problem = repoPathError(key);
150
+ if (problem) return problem;
151
+ if (path.posix.basename(key) === '.html') return 'must name a file before ".html"';
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * A redirects value is a published source (`path` or `path#anchor`) or an
157
+ * absolute https:// URL. Only the shape is checked here; whether the source is
158
+ * published and the anchor resolves is the `redirects` gate in check.mjs.
159
+ */
160
+ function parseRedirectTarget(value) {
161
+ if (typeof value !== 'string' || value.trim() === '') return { error: 'must be a non-empty string' };
162
+ if (value !== value.trim() || /\s/.test(value)) return { error: 'must not contain whitespace' };
163
+ if (/^[a-z][a-z\d+.-]*:/i.test(value) || value.startsWith('//')) {
164
+ let url;
165
+ try {
166
+ url = new URL(value);
167
+ } catch {
168
+ return { error: `"${value}" is not a valid URL` };
169
+ }
170
+ if (url.protocol !== 'https:' || !value.toLowerCase().startsWith('https://') || url.hostname === '')
171
+ return { error: `must be a published source path or an absolute https:// URL, got "${value}"` };
172
+ // Emit the parsed form: `HTTPS:///evil.com/x` validates but must not reach the stub raw.
173
+ return { url: url.href };
174
+ }
175
+ const { path: source, anchor } = splitTopicSource(value);
176
+ const problem = repoPathError(source);
177
+ if (problem) return { error: `path ${problem}` };
178
+ if (!source.endsWith('.md')) return { error: `must be a published .md source or an https:// URL, got "${value}"` };
179
+ if (anchor !== null && anchor === '') return { error: 'has an empty #anchor' };
180
+ return { source, anchor };
181
+ }
182
+
183
+ export function globMatcher(globs = []) {
184
+ if (globs.length === 0) return () => false;
185
+ return picomatch(globs, { dot: true });
186
+ }
187
+
188
+ /**
189
+ * Strict corpus.json (schema v2) validation. Returns { manifest, errors }; `manifest` is
190
+ * normalized (landing defaulted, optional arrays defaulted) and only
191
+ * meaningful when `errors` is empty.
192
+ *
193
+ * `allowMissingDecisionIndex` is for `corpus decisions index` only: a
194
+ * `documents` entry naming the declared `decisions.index` may be absent,
195
+ * because writing that file is the command's job. Every other gate keeps
196
+ * refusing a missing index.
197
+ */
198
+ export function validateManifest(raw, root, where = MANIFEST_FILE, { allowMissingDecisionIndex = false } = {}) {
199
+ const errors = [];
200
+ // First, so a v1 manifest leads with the reason instead of its missing v2 keys.
201
+ if (raw?.schemaVersion === 1) errors.push(`${where}: schemaVersion 1 is no longer supported; ${V2_REMEDY}`);
202
+ else if (isPlainObject(raw) && Object.hasOwn(raw, 'schemaVersion') && raw.schemaVersion !== 2)
203
+ errors.push(`${where}: schemaVersion must be 2 (got ${JSON.stringify(raw.schemaVersion)})`);
204
+ if (!checkKeys(raw, where, MANIFEST_KEYS, MANIFEST_REQUIRED, errors)) return { manifest: null, errors };
205
+
206
+ if (Object.hasOwn(raw, 'purpose')) checkString(raw.purpose, `${where}: purpose`, errors);
207
+ if (Object.hasOwn(raw, 'builtOn')) {
208
+ if (!Array.isArray(raw.builtOn)) errors.push(`${where}: builtOn must be an array`);
209
+ else raw.builtOn.forEach((entry, index) => {
210
+ const at = `${where}: builtOn[${index}]`;
211
+ if (!checkKeys(entry, at, ['product', 'via', 'for', 'evidence', 'bin'], ['product', 'via', 'for'], errors)) return;
212
+ for (const key of ['product', 'for']) checkString(entry[key], `${at}.${key}`, errors);
213
+ if (Array.isArray(entry.via)) {
214
+ if (entry.via.length === 0) errors.push(`${at}.via must be a non-empty array`);
215
+ entry.via.forEach((via, index) => checkString(via, `${at}.via[${index}]`, errors));
216
+ } else checkString(entry.via, `${at}.via`, errors);
217
+ if (Object.hasOwn(entry, 'bin') && (typeof entry.bin !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(entry.bin))) errors.push(`${at}.bin must be a bare CLI executable name`);
218
+ if (Object.hasOwn(entry, 'evidence') && entry.evidence !== 'cli') errors.push(`${at}.evidence must be "cli"`);
219
+ if (typeof entry.product === 'string' && !PRODUCT_SLUG.test(entry.product)) errors.push(`${at}.product must be a lowercase slug`);
220
+ });
221
+ }
222
+
223
+ if (Object.hasOwn(raw, 'product') && checkString(raw.product, `${where}: product`, errors) && !PRODUCT_SLUG.test(raw.product))
224
+ errors.push(`${where}: product must be a lowercase slug matching ${PRODUCT_SLUG}`);
225
+ if (Object.hasOwn(raw, 'title')) checkString(raw.title, `${where}: title`, errors);
226
+ if (Object.hasOwn(raw, 'tagline')) checkString(raw.tagline, `${where}: tagline`, errors, { allowEmpty: true });
227
+ if (Object.hasOwn(raw, 'repo') && checkString(raw.repo, `${where}: repo`, errors)) {
228
+ if (!REPO.test(raw.repo)) errors.push(`${where}: repo must be "owner/name", got "${raw.repo}"`);
229
+ else if (typeof raw.product === 'string' && raw.repo.split('/')[1] !== raw.product)
230
+ errors.push(
231
+ `${where}: product "${raw.product}" must equal the repository name in repo "${raw.repo}" ` +
232
+ '(the site is served at https://<owner>.github.io/<product>/ and the hub fetches /<product>/docs-bundle.json)',
233
+ );
234
+ }
235
+
236
+ const landing = Object.hasOwn(raw, 'landing') ? raw.landing : DEFAULT_LANDING;
237
+ const landingProblem = repoPathError(landing);
238
+ if (landingProblem) errors.push(`${where}: landing ${landingProblem}`);
239
+ else if (!landing.endsWith('.md')) errors.push(`${where}: landing must be a .md file`);
240
+ else if (!isRepoFile(root, landing)) errors.push(`${where}: landing "${landing}" does not exist (or is outside the repository)`);
241
+
242
+ if (Object.hasOwn(raw, 'branch') && checkString(raw.branch, `${where}: branch`, errors)) {
243
+ const problem = branchError(raw.branch);
244
+ if (problem) errors.push(`${where}: branch "${raw.branch}" ${problem}`);
245
+ }
246
+ let selectionPrimary = 'ask';
247
+ if (Object.hasOwn(raw, 'selectionActions')) {
248
+ const at = `${where}: selectionActions`;
249
+ if (checkKeys(raw.selectionActions, at, SELECTION_ACTIONS_KEYS, SELECTION_ACTIONS_KEYS, errors) && Object.hasOwn(raw.selectionActions, 'primary')) {
250
+ if (SELECTION_PRIMARY_ACTIONS.includes(raw.selectionActions.primary)) selectionPrimary = raw.selectionActions.primary;
251
+ else errors.push(`${at}.primary must be one of ${SELECTION_PRIMARY_ACTIONS.map((name) => `"${name}"`).join(', ')} (got ${JSON.stringify(raw.selectionActions.primary)})`);
252
+ }
253
+ }
254
+
255
+ const exclude = Object.hasOwn(raw, 'exclude') ? raw.exclude : [];
256
+ if (Object.hasOwn(raw, 'exclude')) checkGlobs(raw.exclude, `${where}: exclude`, errors);
257
+ const isExcluded = Array.isArray(exclude) && exclude.every((glob) => !repoGlobError(glob)) ? globMatcher(exclude) : () => false;
258
+
259
+ const pendingIndex = allowMissingDecisionIndex ? declaredDecisionIndex(raw.decisions) : null;
260
+ const sources = new Set();
261
+ const outputs = new Map([['index.html', landing]]);
262
+ const documents = [];
263
+ if (Object.hasOwn(raw, 'documents')) {
264
+ if (!Array.isArray(raw.documents)) errors.push(`${where}: documents must be an array`);
265
+ else
266
+ raw.documents.forEach((document, index) => {
267
+ const at = `${where}: documents[${index}]`;
268
+ if (!checkKeys(document, at, DOCUMENT_KEYS, DOCUMENT_REQUIRED, errors)) return;
269
+ if (Object.hasOwn(document, 'section')) checkString(document.section, `${at}.section`, errors);
270
+ if (Object.hasOwn(document, 'title')) checkString(document.title, `${at}.title`, errors);
271
+ if (Object.hasOwn(document, 'publish') && typeof document.publish !== 'boolean')
272
+ errors.push(`${at}.publish must be a boolean (got ${JSON.stringify(document.publish)})`);
273
+ let covers = [];
274
+ if (Object.hasOwn(document, 'covers')) {
275
+ const before = errors.length;
276
+ checkGlobs(document.covers, `${at}.covers`, errors);
277
+ if (errors.length === before) covers = expandCovers(document.covers, root);
278
+ }
279
+ if (!Object.hasOwn(document, 'source')) return;
280
+ const source = document.source;
281
+ const problem = repoPathError(source);
282
+ if (problem) return errors.push(`${at}.source ${problem}`);
283
+ if (!source.endsWith('.md')) return errors.push(`${at}.source must be a .md file, got "${source}"`);
284
+ if (sources.has(source)) return errors.push(`${at}.source "${source}" is listed more than once`);
285
+ sources.add(source);
286
+ if (source === landing)
287
+ return errors.push(`${at}.source "${source}" is the landing page; it is already published as index.html`);
288
+ if (isExcluded(source)) errors.push(`${at}.source "${source}" is listed but also matched by exclude`);
289
+ if (!isRepoFile(root, source) && !(source === pendingIndex && isAbsent(root, source)))
290
+ errors.push(`${at}.source "${source}" does not exist (or is outside the repository)`);
291
+ // A bundle-only document (publish: false) has no page, so it cannot collide with one.
292
+ if (document.publish !== false) {
293
+ const output = source.replace(/\.md$/, '.html');
294
+ if (outputs.has(output)) errors.push(`${at}.source "${source}" would overwrite the page generated for "${outputs.get(output)}"`);
295
+ outputs.set(output, source);
296
+ }
297
+ documents.push({
298
+ source,
299
+ section: document.section,
300
+ title: document.title ?? null,
301
+ covers,
302
+ coversAsWritten: document.covers ?? [],
303
+ publish: document.publish !== false,
304
+ });
305
+ });
306
+ }
307
+
308
+ const topics = [];
309
+ if (Object.hasOwn(raw, 'topics')) {
310
+ if (!Array.isArray(raw.topics)) errors.push(`${where}: topics must be an array`);
311
+ else {
312
+ const ids = new Set();
313
+ raw.topics.forEach((topic, index) => {
314
+ const at = `${where}: topics[${index}]`;
315
+ if (!checkKeys(topic, at, TOPIC_KEYS, TOPIC_REQUIRED, errors)) return;
316
+ if (Object.hasOwn(topic, 'id') && checkString(topic.id, `${at}.id`, errors)) {
317
+ if (!TOPIC_ID.test(topic.id)) errors.push(`${at}.id must be a lowercase slug matching ${TOPIC_ID}`);
318
+ else if (ids.has(topic.id)) errors.push(`${at}.id "${topic.id}" is used more than once`);
319
+ ids.add(topic.id);
320
+ }
321
+ if (Object.hasOwn(topic, 'title')) checkString(topic.title, `${at}.title`, errors);
322
+ if (!Object.hasOwn(topic, 'source') || !checkString(topic.source, `${at}.source`, errors)) return;
323
+ const { path: topicPath, anchor } = splitTopicSource(topic.source);
324
+ const problem = repoPathError(topicPath);
325
+ if (problem) return errors.push(`${at}.source path ${problem}`);
326
+ if (anchor !== null && anchor === '') return errors.push(`${at}.source has an empty #anchor`);
327
+ if (!isRepoFile(root, topicPath)) return errors.push(`${at}.source "${topicPath}" does not exist (or is outside the repository)`);
328
+ if (topicPath !== landing && !sources.has(topicPath))
329
+ errors.push(`${at}.source "${topicPath}" is not listed; a topic must point at the landing page or a listed document`);
330
+ topics.push({ id: topic.id, title: topic.title, source: topic.source });
331
+ });
332
+ }
333
+ }
334
+
335
+ const redirects = [];
336
+ if (Object.hasOwn(raw, 'redirects')) {
337
+ if (!isPlainObject(raw.redirects)) errors.push(`${where}: redirects must be an object mapping old paths to targets`);
338
+ else
339
+ for (const [from, to] of Object.entries(raw.redirects)) {
340
+ const at = `${where}: redirects["${from}"]`;
341
+ const keyProblem = redirectKeyError(from);
342
+ if (keyProblem) errors.push(`${at}: key ${keyProblem}`);
343
+ const target = parseRedirectTarget(to);
344
+ if (target.error) errors.push(`${at}: target ${target.error}`);
345
+ if (!keyProblem && !target.error) redirects.push({ from, ...target });
346
+ }
347
+ }
348
+
349
+ const allowLocalPaths = [];
350
+ if (Object.hasOwn(raw, 'allowLocalPaths')) {
351
+ if (!Array.isArray(raw.allowLocalPaths)) errors.push(`${where}: allowLocalPaths must be an array of document paths`);
352
+ else
353
+ raw.allowLocalPaths.forEach((entry, index) => {
354
+ const at = `${where}: allowLocalPaths[${index}]`;
355
+ const problem = repoPathError(entry);
356
+ if (problem) return errors.push(`${at} ${problem}`);
357
+ // Exact paths only: a glob would silently exempt documents added later.
358
+ if (GLOB_CHARS.test(entry)) return errors.push(`${at} "${entry}" must be an exact document path; globs are not allowed`);
359
+ if (entry !== landing && !sources.has(entry))
360
+ return errors.push(`${at} "${entry}" is not a document; list the landing page or a documents[] source`);
361
+ if (allowLocalPaths.includes(entry)) return errors.push(`${at} "${entry}" is listed more than once`);
362
+ allowLocalPaths.push(entry);
363
+ });
364
+ }
365
+
366
+ const decisions = Object.hasOwn(raw, 'decisions') ? validateDecisionsBlock(raw.decisions, root, `${where}: decisions`, errors) : null;
367
+
368
+ const synonymsProblem = Object.hasOwn(raw, 'synonyms') ? synonymsError(raw.synonyms, `${where}: synonyms`) : null;
369
+ if (synonymsProblem) errors.push(synonymsProblem);
370
+
371
+ return {
372
+ manifest: {
373
+ schemaVersion: raw.schemaVersion,
374
+ purpose: raw.purpose,
375
+ builtOn: raw.builtOn,
376
+ product: raw.product,
377
+ title: raw.title,
378
+ tagline: raw.tagline ?? '',
379
+ repo: raw.repo,
380
+ landing,
381
+ branch: typeof raw.branch === 'string' ? raw.branch : DEFAULT_BRANCH,
382
+ selectionActions: { primary: selectionPrimary },
383
+ documents,
384
+ topics,
385
+ exclude: Array.isArray(exclude) ? exclude : [],
386
+ redirects,
387
+ allowLocalPaths,
388
+ decisions,
389
+ ...(Object.hasOwn(raw, 'synonyms') && !synonymsProblem ? { synonyms: raw.synonyms } : {}),
390
+ },
391
+ errors,
392
+ };
393
+ }
394
+
395
+ export const DEFAULT_DECISIONS_DIR = 'docs/decisions';
396
+ export const DEFAULT_ADR_DIR = 'docs/adr';
397
+
398
+ /**
399
+ * The index path a raw `decisions` block names, before validation (null when
400
+ * it names none). The block is still validated in full, so a malformed one
401
+ * fails the manifest either way.
402
+ */
403
+ function declaredDecisionIndex(value) {
404
+ if (!isPlainObject(value)) return null;
405
+ const dir = Object.hasOwn(value, 'dir') ? value.dir : DEFAULT_DECISIONS_DIR;
406
+ const index = Object.hasOwn(value, 'index') ? value.index : `${dir}/index.md`;
407
+ return typeof index === 'string' ? index : null;
408
+ }
409
+
410
+ /** True only when nothing at all is at the repo-relative path: not a symlink, directory, or other file. */
411
+ function isAbsent(root, repoPath) {
412
+ try {
413
+ lstatSync(toFsPath(root, repoPath));
414
+ return false;
415
+ } catch (error) {
416
+ return error.code === 'ENOENT' || error.code === 'ENOTDIR';
417
+ }
418
+ }
419
+
420
+ /** null when nothing is at the repo-relative path; otherwise whether it is a real directory inside the repository. */
421
+ function directoryState(root, repoPath) {
422
+ let stat;
423
+ try {
424
+ stat = lstatSync(toFsPath(root, repoPath));
425
+ } catch {
426
+ return null;
427
+ }
428
+ return stat.isDirectory() && statInRepo(root, repoPath)?.kind === 'dir' ? 'dir' : 'other';
429
+ }
430
+
431
+ /**
432
+ * The optional v2 `decisions` block: `{ dir?, index?, adr?: { dir? } }`.
433
+ * `dir` defaults to docs/decisions and need not exist yet (`corpus decisions
434
+ * index` creates it); `index` defaults to `<dir>/index.md` and must sit
435
+ * directly in `dir`. Declaring `adr` freezes `adr.dir`, which must exist.
436
+ */
437
+ function validateDecisionsBlock(value, root, at, errors) {
438
+ const before = errors.length;
439
+ if (!checkKeys(value, at, ['dir', 'index', 'adr'], [], errors)) return null;
440
+ const pathOk = (repoPath, where) => {
441
+ const problem = repoPathError(repoPath);
442
+ if (problem) errors.push(`${where} ${problem}`);
443
+ return !problem;
444
+ };
445
+ const dir = Object.hasOwn(value, 'dir') ? value.dir : DEFAULT_DECISIONS_DIR;
446
+ const dirOk = pathOk(dir, `${at}.dir`);
447
+ if (dirOk && directoryState(root, dir) === 'other')
448
+ errors.push(`${at}.dir "${dir}" is not a directory inside the repository`);
449
+ const index = Object.hasOwn(value, 'index') ? value.index : `${dir}/index.md`;
450
+ if (pathOk(index, `${at}.index`) && dirOk) {
451
+ if (!index.endsWith('.md')) errors.push(`${at}.index must be a .md file, got "${index}"`);
452
+ else if (path.posix.dirname(index) !== dir) errors.push(`${at}.index "${index}" must be directly inside decisions.dir "${dir}"`);
453
+ }
454
+ let adr = null;
455
+ if (Object.hasOwn(value, 'adr') && checkKeys(value.adr, `${at}.adr`, ['dir'], [], errors)) {
456
+ const adrDir = Object.hasOwn(value.adr, 'dir') ? value.adr.dir : DEFAULT_ADR_DIR;
457
+ if (pathOk(adrDir, `${at}.adr.dir`)) {
458
+ // A frozen directory that does not exist would never refuse anything, so a typo must not pass.
459
+ if (directoryState(root, adrDir) !== 'dir') errors.push(`${at}.adr.dir "${adrDir}" is not an existing directory inside the repository`);
460
+ if (dirOk && (adrDir === dir || adrDir.startsWith(`${dir}/`) || dir.startsWith(`${adrDir}/`)))
461
+ errors.push(`${at}.adr.dir "${adrDir}" must not overlap decisions.dir "${dir}"`);
462
+ }
463
+ adr = { dir: adrDir };
464
+ }
465
+ return errors.length === before ? { dir, index, adr } : null;
466
+ }
467
+
468
+ /**
469
+ * JSON.parse keeps only the last of two equal keys, so a repeated redirects
470
+ * key would vanish silently. Scan the manifest text (already known to be valid
471
+ * JSON) and return every key that appears more than once in the top-level
472
+ * `redirects` object.
473
+ */
474
+ export function duplicateRedirectKeys(text) {
475
+ const STRING = /"(?:[^"\\]|\\.)*"/y;
476
+ const WS = /\s*/y;
477
+ let i = 0;
478
+ const skip = () => {
479
+ WS.lastIndex = i;
480
+ WS.exec(text);
481
+ i = WS.lastIndex;
482
+ };
483
+ const string = () => {
484
+ STRING.lastIndex = i;
485
+ const match = STRING.exec(text);
486
+ i = STRING.lastIndex;
487
+ return JSON.parse(match[0]);
488
+ };
489
+ const duplicates = [];
490
+ const value = (keyPath) => {
491
+ skip();
492
+ if (text[i] === '{') {
493
+ i += 1;
494
+ const seen = new Set();
495
+ skip();
496
+ while (text[i] !== '}') {
497
+ skip();
498
+ const key = string();
499
+ if (keyPath.length === 1 && keyPath[0] === 'redirects') {
500
+ if (seen.has(key) && !duplicates.includes(key)) duplicates.push(key);
501
+ seen.add(key);
502
+ }
503
+ skip();
504
+ i += 1; // ':'
505
+ value([...keyPath, key]);
506
+ skip();
507
+ if (text[i] === ',') i += 1;
508
+ skip();
509
+ }
510
+ i += 1;
511
+ } else if (text[i] === '[') {
512
+ i += 1;
513
+ skip();
514
+ while (text[i] !== ']') {
515
+ value([...keyPath, null]);
516
+ skip();
517
+ if (text[i] === ',') i += 1;
518
+ skip();
519
+ }
520
+ i += 1;
521
+ } else if (text[i] === '"') string();
522
+ else {
523
+ const scalar = /-?[0-9][0-9.eE+-]*|true|false|null/y;
524
+ scalar.lastIndex = i;
525
+ scalar.exec(text);
526
+ i = scalar.lastIndex;
527
+ }
528
+ };
529
+ value([]);
530
+ return duplicates;
531
+ }
532
+
533
+ /** Read and parse corpus.json. A missing, unparseable, or retired (docs-kit.json) manifest is a usage error. */
534
+ export function readManifestJson(root) {
535
+ const file = path.join(root, manifestFile(root));
536
+ let text;
537
+ try {
538
+ text = readFileSync(file, 'utf8');
539
+ } catch (error) {
540
+ if (error.code === 'ENOENT')
541
+ throw new UsageError(`no ${MANIFEST_FILE} in ${root} (run "corpus init" to scaffold one)`);
542
+ throw new UsageError(`cannot read ${file}: ${error.message}`);
543
+ }
544
+ try {
545
+ return JSON.parse(text);
546
+ } catch (error) {
547
+ throw new UsageError(`${file} is not valid JSON: ${error.message}`);
548
+ }
549
+ }
550
+
551
+ /** Load and validate; throws UsageError listing every manifest problem. */
552
+ export function loadManifest(root, options = {}) {
553
+ const { manifest, errors } = validateManifest(readManifestJson(root), root, manifestFile(root), options);
554
+ if (errors.length > 0)
555
+ throw new UsageError(`${manifestFile(root)} is invalid:\n${errors.map((error) => ` - ${error}`).join('\n')}`);
556
+ return manifest;
557
+ }
558
+
559
+ /** Strict products.json validation for `hub` and `mcp --hub`. */
560
+ export function validateProducts(raw) {
561
+ const errors = [];
562
+ const where = 'products.json';
563
+ if (!checkKeys(raw, where, PRODUCTS_KEYS, PRODUCTS_KEYS, errors)) return { products: [], errors };
564
+ if (Object.hasOwn(raw, 'schemaVersion') && raw.schemaVersion !== 1)
565
+ errors.push(`${where}: schemaVersion must be exactly 1 (got ${JSON.stringify(raw.schemaVersion)})`);
566
+ const products = [];
567
+ if (Object.hasOwn(raw, 'products')) {
568
+ if (!Array.isArray(raw.products)) errors.push(`${where}: products must be an array`);
569
+ else {
570
+ const seen = new Set();
571
+ raw.products.forEach((entry, index) => {
572
+ const at = `${where}: products[${index}]`;
573
+ if (!checkKeys(entry, at, PRODUCT_ENTRY_KEYS, PRODUCT_ENTRY_KEYS, errors)) return;
574
+ const productOk = checkString(entry.product, `${at}.product`, errors) && PRODUCT_SLUG.test(entry.product);
575
+ if (typeof entry.product === 'string' && !PRODUCT_SLUG.test(entry.product))
576
+ errors.push(`${at}.product must be a lowercase slug matching ${PRODUCT_SLUG}`);
577
+ const repoOk = checkString(entry.repo, `${at}.repo`, errors) && REPO.test(entry.repo);
578
+ if (typeof entry.repo === 'string' && !REPO.test(entry.repo)) errors.push(`${at}.repo must be "owner/name"`);
579
+ if (!productOk || !repoOk) return;
580
+ if (entry.repo.split('/')[1] !== entry.product)
581
+ errors.push(`${at}: product "${entry.product}" must equal the repository name in "${entry.repo}"`);
582
+ if (seen.has(entry.product)) errors.push(`${at}.product "${entry.product}" is listed more than once`);
583
+ seen.add(entry.product);
584
+ products.push({ product: entry.product, repo: entry.repo });
585
+ });
586
+ }
587
+ }
588
+ return { products, errors };
589
+ }