@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.
- package/LICENSE +201 -0
- package/README.md +497 -0
- package/bin/corpus.mjs +4 -0
- package/bin/docs-kit.mjs +4 -0
- package/package.json +58 -3
- package/schemas/decision-record.schema.json +85 -0
- package/src/assets.mjs +67 -0
- package/src/build.mjs +535 -0
- package/src/built-on.mjs +238 -0
- package/src/bundle.mjs +174 -0
- package/src/check.mjs +520 -0
- package/src/cli.mjs +226 -0
- package/src/content-freshness.mjs +37 -0
- package/src/content.mjs +222 -0
- package/src/decisions.mjs +317 -0
- package/src/errors.mjs +24 -0
- package/src/git.mjs +171 -0
- package/src/hub.mjs +198 -0
- package/src/index.mjs +12 -0
- package/src/init.mjs +63 -0
- package/src/locate.mjs +341 -0
- package/src/manifest.mjs +589 -0
- package/src/markdown.mjs +547 -0
- package/src/mcp.mjs +583 -0
- package/src/paths.mjs +122 -0
- package/src/search.mjs +508 -0
- package/src/site-client.js +622 -0
- package/src/site-template.mjs +191 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
import { UsageError } from './errors.mjs';
|
|
5
|
+
import { addedFiles } from './git.mjs';
|
|
6
|
+
import { toFsPath } from './paths.mjs';
|
|
7
|
+
|
|
8
|
+
// The topic-keyed decision registry: one living record per decision subject at
|
|
9
|
+
// <dir>/<topic-slug>.md, plus a generated index. The format is documented in
|
|
10
|
+
// docs/decisions.md and published as schemas/decision-record.schema.json; this
|
|
11
|
+
// module enforces the same rules directly, plus the rules a JSON Schema cannot
|
|
12
|
+
// express (filename slugs, relations to other records, the index, the ADR freeze).
|
|
13
|
+
|
|
14
|
+
export const DECISION_STATUSES = ['current', 'superseded', 'merged', 'needs-decision'];
|
|
15
|
+
export const EVIDENCE_KINDS = ['issue', 'pr', 'commit', 'session-archive', 'adr', 'doc', 'url'];
|
|
16
|
+
export const RECORD_KEYS = ['status', 'subject', 'decided', 'evidence', 'supersedes', 'superseded_by', 'merged_into'];
|
|
17
|
+
export const EVIDENCE_KEYS = ['kind', 'ref'];
|
|
18
|
+
export const DECISIONS_DOC_URL = 'https://github.com/kontourai/corpus/blob/main/docs/decisions.md';
|
|
19
|
+
|
|
20
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
21
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
22
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
23
|
+
|
|
24
|
+
/** Secret-shaped literals refused in evidence refs. The schema's `not.pattern` mirrors these. */
|
|
25
|
+
export const SECRET_PATTERNS = [
|
|
26
|
+
{ label: 'aws access key id', re: /AKIA[A-Z0-9]{16}/ },
|
|
27
|
+
{ label: 'aws sts key id', re: /ASIA[A-Z0-9]{16}/ },
|
|
28
|
+
{ label: 'github token', re: /gh[pousr]_[A-Za-z0-9_]{36,}/ },
|
|
29
|
+
{ label: 'private key block', re: /BEGIN[A-Z ]*PRIVATE KEY/ },
|
|
30
|
+
{ label: 'jwt', re: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
|
|
31
|
+
{ label: 'generic secret literal', re: /(secret|password|token|api[_-]?key)\s*[:=]\s*["'][^"']{8,}/i },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const problem = (rule, message) => ({ rule, message });
|
|
35
|
+
const show = (value) => (typeof value === 'string' ? `'${value}'` : JSON.stringify(value) ?? String(value));
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Split a record into its YAML frontmatter and body. The block must start at
|
|
39
|
+
* byte 0 with a `---` line (no BOM) and end at the next line that is `---`
|
|
40
|
+
* once trimmed. It is parsed with the failsafe schema, so every scalar is a
|
|
41
|
+
* string (`ref: 310` stays "310"), and duplicate keys, anchors, aliases, and
|
|
42
|
+
* explicit tags are refused. Returns { frontmatter, body, errors }; frontmatter
|
|
43
|
+
* is null when the block is missing or unparseable.
|
|
44
|
+
*/
|
|
45
|
+
export function parseDecisionRecord(text) {
|
|
46
|
+
const errors = [];
|
|
47
|
+
if (!text.startsWith('---\n') && !text.startsWith('---\r\n')) {
|
|
48
|
+
errors.push(problem('frontmatter', "file must begin with a YAML frontmatter block delimited by '---' (at byte 0)"));
|
|
49
|
+
return { frontmatter: null, body: text, errors };
|
|
50
|
+
}
|
|
51
|
+
const lines = text.split(/\r?\n/);
|
|
52
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
|
|
53
|
+
if (end === -1) {
|
|
54
|
+
errors.push(problem('frontmatter', "frontmatter block is not terminated by a closing '---'"));
|
|
55
|
+
return { frontmatter: null, body: text, errors };
|
|
56
|
+
}
|
|
57
|
+
const body = lines.slice(end + 1).join('\n');
|
|
58
|
+
const doc = YAML.parseDocument(lines.slice(1, end).join('\n'), { schema: 'failsafe', uniqueKeys: true, prettyErrors: false });
|
|
59
|
+
for (const error of [...doc.errors, ...doc.warnings])
|
|
60
|
+
errors.push(problem('frontmatter', `frontmatter is not valid YAML: ${error.message.split('\n')[0]}`));
|
|
61
|
+
YAML.visit(doc, {
|
|
62
|
+
Alias() {
|
|
63
|
+
errors.push(problem('frontmatter', 'frontmatter must not use YAML aliases'));
|
|
64
|
+
},
|
|
65
|
+
Node(_, node) {
|
|
66
|
+
if (node.anchor) errors.push(problem('frontmatter', `frontmatter must not use YAML anchors (&${node.anchor})`));
|
|
67
|
+
if (node.tag) errors.push(problem('frontmatter', `frontmatter must not use explicit YAML tags (${node.tag})`));
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
if (errors.length > 0) return { frontmatter: null, body, errors };
|
|
71
|
+
const frontmatter = doc.toJS();
|
|
72
|
+
if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
|
|
73
|
+
errors.push(problem('frontmatter', 'frontmatter must be a mapping of keys to values'));
|
|
74
|
+
return { frontmatter: null, body, errors };
|
|
75
|
+
}
|
|
76
|
+
return { frontmatter, body, errors };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The record rules. `knownSlugs` is every record slug in the registry. Returns [{ rule, message }]. */
|
|
80
|
+
export function validateDecisionRecord(slug, fm, knownSlugs, { dir = 'docs/decisions' } = {}) {
|
|
81
|
+
const errors = [];
|
|
82
|
+
if (!SLUG.test(slug)) errors.push(problem('slug', `filename slug '${slug}' is not a valid kebab-case topic slug`));
|
|
83
|
+
for (const key of Object.keys(fm))
|
|
84
|
+
if (!RECORD_KEYS.includes(key)) errors.push(problem('unknown-key', `unknown frontmatter key '${key}' (allowed: ${RECORD_KEYS.join(', ')})`));
|
|
85
|
+
|
|
86
|
+
if (!Object.hasOwn(fm, 'status')) errors.push(problem('status', 'missing required field: status'));
|
|
87
|
+
else if (!DECISION_STATUSES.includes(fm.status)) errors.push(problem('status', `unknown status ${show(fm.status)} (allowed: ${DECISION_STATUSES.join(', ')})`));
|
|
88
|
+
|
|
89
|
+
if (!Object.hasOwn(fm, 'subject') || fm.subject === '' || (typeof fm.subject === 'string' && fm.subject.trim() === ''))
|
|
90
|
+
errors.push(problem('subject', 'missing required field: subject (non-empty)'));
|
|
91
|
+
else if (typeof fm.subject !== 'string') errors.push(problem('subject', `subject must be a single-line string; got ${show(fm.subject)}`));
|
|
92
|
+
// The subject is a cell of the index table, so a line break or other control character would break it.
|
|
93
|
+
else if (CONTROL.test(fm.subject)) errors.push(problem('subject', 'subject must be a single line without control characters'));
|
|
94
|
+
|
|
95
|
+
if (!Object.hasOwn(fm, 'decided')) errors.push(problem('decided', 'missing required field: decided'));
|
|
96
|
+
else if (typeof fm.decided !== 'string' || !DATE.test(fm.decided)) errors.push(problem('decided', `decided must be an ISO date (YYYY-MM-DD); got ${show(fm.decided)}`));
|
|
97
|
+
|
|
98
|
+
if (!Object.hasOwn(fm, 'evidence')) errors.push(problem('evidence', 'missing required field: evidence[]'));
|
|
99
|
+
else if (!Array.isArray(fm.evidence) || fm.evidence.length === 0) errors.push(problem('evidence', 'evidence must be a non-empty array of {kind, ref}'));
|
|
100
|
+
else
|
|
101
|
+
fm.evidence.forEach((item, index) => {
|
|
102
|
+
const at = `evidence[${index}]`;
|
|
103
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) return errors.push(problem('evidence', `${at} must be an object {kind, ref}`));
|
|
104
|
+
for (const key of Object.keys(item))
|
|
105
|
+
if (!EVIDENCE_KEYS.includes(key)) errors.push(problem('unknown-key', `${at}: unknown key '${key}' (allowed: ${EVIDENCE_KEYS.join(', ')})`));
|
|
106
|
+
if (!EVIDENCE_KINDS.includes(item.kind)) errors.push(problem('evidence-kind', `${at}.kind ${show(item.kind)} is not one of ${EVIDENCE_KINDS.join(', ')}`));
|
|
107
|
+
if (typeof item.ref !== 'string' || item.ref.trim() === '')
|
|
108
|
+
return errors.push(problem('evidence', `${at}.ref must be a non-empty string (quote a ref that starts with '#': a bare # begins a YAML comment)`));
|
|
109
|
+
const secret = SECRET_PATTERNS.find(({ re }) => re.test(item.ref));
|
|
110
|
+
if (secret) errors.push(problem('secret', `${at}.ref contains a secret-shaped literal (${secret.label}); link durable provenance, never a credential`));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
for (const key of ['superseded_by', 'merged_into']) {
|
|
114
|
+
if (!Object.hasOwn(fm, key)) continue;
|
|
115
|
+
const target = fm[key];
|
|
116
|
+
if (typeof target !== 'string' || !SLUG.test(target)) errors.push(problem('relation', `${key} must be a single topic slug; got ${show(target)}`));
|
|
117
|
+
else if (target === slug) errors.push(problem('self-reference', `${key} must not point at its own slug`));
|
|
118
|
+
else if (!knownSlugs.has(target)) errors.push(problem('relation', `${key} points at missing topic slug '${target}' (no ${dir}/${target}.md)`));
|
|
119
|
+
}
|
|
120
|
+
if (Object.hasOwn(fm, 'supersedes')) {
|
|
121
|
+
if (!Array.isArray(fm.supersedes)) errors.push(problem('relation', `supersedes must be a list of topic slugs; got ${show(fm.supersedes)}`));
|
|
122
|
+
else
|
|
123
|
+
for (const target of fm.supersedes) {
|
|
124
|
+
if (typeof target !== 'string' || !SLUG.test(target)) errors.push(problem('relation', `supersedes entry must be a topic slug; got ${show(target)}`));
|
|
125
|
+
else if (!knownSlugs.has(target)) errors.push(problem('relation', `supersedes points at missing topic slug '${target}' (no ${dir}/${target}.md)`));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const has = (key) => Object.hasOwn(fm, key);
|
|
130
|
+
if ((fm.status === 'current' || fm.status === 'needs-decision') && (has('superseded_by') || has('merged_into')))
|
|
131
|
+
errors.push(problem('tombstone', `status '${fm.status}' must not carry superseded_by or merged_into`));
|
|
132
|
+
if (fm.status === 'superseded') {
|
|
133
|
+
if (!has('superseded_by')) errors.push(problem('tombstone', "status 'superseded' requires superseded_by"));
|
|
134
|
+
if (has('merged_into')) errors.push(problem('tombstone', "status 'superseded' must not carry merged_into"));
|
|
135
|
+
}
|
|
136
|
+
if (fm.status === 'merged') {
|
|
137
|
+
if (!has('merged_into')) errors.push(problem('tombstone', "status 'merged' requires merged_into"));
|
|
138
|
+
if (has('superseded_by')) errors.push(problem('tombstone', "status 'merged' must not carry superseded_by"));
|
|
139
|
+
}
|
|
140
|
+
return errors;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** First non-blank body line after the frontmatter, without heading marks (the index fallback when a record has no subject). */
|
|
144
|
+
function firstBodyLine(text) {
|
|
145
|
+
let fences = 0;
|
|
146
|
+
for (const line of text.split(/\r?\n/)) {
|
|
147
|
+
if (line.trim() === '---') {
|
|
148
|
+
fences += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (fences < 2 || line.trim() === '') continue;
|
|
152
|
+
return line.trim().replace(/^#+\s*/, '').trim();
|
|
153
|
+
}
|
|
154
|
+
return '';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The generated index. Byte-compatible with the records' previous generator except for the header lines. */
|
|
158
|
+
const INDEX_PREFIX = '---\ntitle: Decision Index\n---\n';
|
|
159
|
+
|
|
160
|
+
export function renderDecisionIndex(records, { adrDir = null } = {}) {
|
|
161
|
+
const lines = [
|
|
162
|
+
...INDEX_PREFIX.trimEnd().split('\n'),
|
|
163
|
+
'',
|
|
164
|
+
'# Decision Index',
|
|
165
|
+
'',
|
|
166
|
+
'Generated by `corpus decisions index`. Do not edit by hand.',
|
|
167
|
+
`Topic-keyed living decision records; the format is described at ${DECISIONS_DOC_URL}.`,
|
|
168
|
+
...(adrDir ? [`Numbered ADRs under \`${adrDir}/\` are frozen history and are not listed here.`] : []),
|
|
169
|
+
'',
|
|
170
|
+
'| Topic | Status | Decision |',
|
|
171
|
+
'| --- | --- | --- |',
|
|
172
|
+
];
|
|
173
|
+
for (const record of [...records].sort((a, b) => a.slug.localeCompare(b.slug, 'en'))) {
|
|
174
|
+
const fm = record.frontmatter ?? {};
|
|
175
|
+
const status = typeof fm.status === 'string' ? fm.status : 'unknown';
|
|
176
|
+
const subject = typeof fm.subject === 'string' ? fm.subject.trim() : '';
|
|
177
|
+
const summary = (subject || firstBodyLine(record.text) || record.slug).replace(/\|/g, '\\|');
|
|
178
|
+
lines.push(`| [${record.slug}](./${record.slug}.md) | ${status} | ${summary} |`);
|
|
179
|
+
}
|
|
180
|
+
lines.push('');
|
|
181
|
+
return lines.join('\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isDirectory(fsPath) {
|
|
185
|
+
try {
|
|
186
|
+
return lstatSync(fsPath).isDirectory();
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Read and validate every `<dir>/*.md` except the index. Never consults documents or exclude. */
|
|
193
|
+
export function readDecisionRecords(root, decisions) {
|
|
194
|
+
const fsDir = toFsPath(root, decisions.dir);
|
|
195
|
+
const indexName = path.posix.basename(decisions.index);
|
|
196
|
+
const entries = isDirectory(fsDir) ? readdirSync(fsDir, { withFileTypes: true }) : [];
|
|
197
|
+
const candidates = entries.filter((entry) => entry.name.endsWith('.md') && entry.name !== indexName).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
198
|
+
const knownSlugs = new Set(candidates.map((entry) => entry.name.slice(0, -3)));
|
|
199
|
+
return candidates.map((entry) => {
|
|
200
|
+
const slug = entry.name.slice(0, -3);
|
|
201
|
+
const repoPath = `${decisions.dir}/${entry.name}`;
|
|
202
|
+
if (!entry.isFile()) return { slug, path: repoPath, text: '', frontmatter: null, errors: [problem('frontmatter', 'must be a regular file (not a symlink or directory)')] };
|
|
203
|
+
let text;
|
|
204
|
+
try {
|
|
205
|
+
text = readFileSync(path.join(fsDir, entry.name), 'utf8');
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return { slug, path: repoPath, text: '', frontmatter: null, errors: [problem('frontmatter', `cannot be read (${error.code ?? error.message})`)] };
|
|
208
|
+
}
|
|
209
|
+
const { frontmatter, errors } = parseDecisionRecord(text);
|
|
210
|
+
if (frontmatter) errors.push(...validateDecisionRecord(slug, frontmatter, knownSlugs, { dir: decisions.dir }));
|
|
211
|
+
return { slug, path: repoPath, text, frontmatter, errors };
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function readIndex(root, decisions) {
|
|
216
|
+
try {
|
|
217
|
+
const fsPath = toFsPath(root, decisions.index);
|
|
218
|
+
if (!lstatSync(fsPath).isFile()) return { state: 'invalid' };
|
|
219
|
+
return { state: 'present', text: readFileSync(fsPath, 'utf8') };
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return { state: 'missing' };
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The decisions gate: every record is valid and the index is exactly what
|
|
228
|
+
* `corpus decisions index` would write. Returns { records, failures }.
|
|
229
|
+
*/
|
|
230
|
+
export function validateDecisions(root, decisions) {
|
|
231
|
+
const records = readDecisionRecords(root, decisions);
|
|
232
|
+
const failures = [];
|
|
233
|
+
for (const record of records) for (const error of record.errors) failures.push(`${record.path}: ${error.message}`);
|
|
234
|
+
const index = readIndex(root, decisions);
|
|
235
|
+
const fix = 'run `corpus decisions index`';
|
|
236
|
+
if (index.state === 'missing') failures.push(`${decisions.index} is missing; ${fix}`);
|
|
237
|
+
else if (index.state === 'invalid') failures.push(`${decisions.index} must be a regular file (not a symlink or directory)`);
|
|
238
|
+
else if (index.text !== renderDecisionIndex(records, { adrDir: decisions.adr?.dir ?? null })) failures.push(`${decisions.index} is stale; ${fix}`);
|
|
239
|
+
return { records, failures };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Files that may be added directly in `adr.dir` despite the freeze, so the
|
|
244
|
+
* directory can explain itself. An exact, case-sensitive list rather than a
|
|
245
|
+
* "looks numbered" pattern: a pattern would let any ADR named off-pattern
|
|
246
|
+
* (`adr-0005.md`, `0005_x.md`, a subdirectory) through, while this admits
|
|
247
|
+
* two known names and nothing else.
|
|
248
|
+
*/
|
|
249
|
+
export const ADR_FREEZE_EXEMPT = ['README.md', 'index.md'];
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The ADR freeze: with a base ref, a file added under `adr.dir` since the
|
|
253
|
+
* merge base is a failure, except an `ADR_FREEZE_EXEMPT` name directly in
|
|
254
|
+
* `adr.dir`. Renames are read as a deletion plus an addition, so renaming an
|
|
255
|
+
* ADR to a new path is refused unless the new path is exempt. Edits and
|
|
256
|
+
* deletions are allowed. Git errors throw UsageError.
|
|
257
|
+
*/
|
|
258
|
+
export function checkAdrFreeze(root, decisions, base) {
|
|
259
|
+
const prefix = `${decisions.adr.dir}/`;
|
|
260
|
+
const exempt = new Set(ADR_FREEZE_EXEMPT.map((name) => `${prefix}${name}`));
|
|
261
|
+
const added = addedFiles(root, base).filter((file) => file.startsWith(prefix) && !exempt.has(file));
|
|
262
|
+
return added.map(
|
|
263
|
+
(file) =>
|
|
264
|
+
`${file} was added since ${base}, but numbered ADRs under ${decisions.adr.dir}/ are frozen history; ` +
|
|
265
|
+
`record the decision as ${decisions.dir}/<topic-slug>.md instead (see ${DECISIONS_DOC_URL}#numbered-adrs). ` +
|
|
266
|
+
`Only ${ADR_FREEZE_EXEMPT.join(' and ')} may be added directly in ${decisions.adr.dir}/.`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Refuse to write through a symlink or over a non-file anywhere on the repo-relative path. */
|
|
271
|
+
function assertPlainPath(root, repoPath) {
|
|
272
|
+
let current = root;
|
|
273
|
+
const segments = repoPath.split('/');
|
|
274
|
+
segments.forEach((segment, index) => {
|
|
275
|
+
current = path.join(current, segment);
|
|
276
|
+
let stat;
|
|
277
|
+
try {
|
|
278
|
+
stat = lstatSync(current);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error.code === 'ENOENT') return;
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
const last = index === segments.length - 1;
|
|
284
|
+
if (stat.isSymbolicLink()) throw new UsageError(`refusing to write ${repoPath}: ${segments.slice(0, index + 1).join('/')} is a symlink`);
|
|
285
|
+
if (last ? !stat.isFile() : !stat.isDirectory()) throw new UsageError(`refusing to write ${repoPath}: ${segments.slice(0, index + 1).join('/')} is not a ${last ? 'file' : 'directory'}`);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* True for a file the generator may replace: empty, or starting with the
|
|
291
|
+
* generated frontmatter once a BOM, CRLF line endings, and leading merge
|
|
292
|
+
* conflict markers are allowed for, which are all states `check` calls stale.
|
|
293
|
+
*/
|
|
294
|
+
function isGeneratedIndex(text) {
|
|
295
|
+
if (text.trim() === '') return true;
|
|
296
|
+
const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/);
|
|
297
|
+
while (lines.length > 0 && /^(<{7}|={7}|>{7}|\|{7})( |$)/.test(lines[0])) lines.shift();
|
|
298
|
+
const [open, title, close] = INDEX_PREFIX.split('\n');
|
|
299
|
+
return lines[0] === open && lines[1] === title && lines[2] === close;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** `corpus decisions index`: write the index file, and nothing else. Returns a report line. */
|
|
303
|
+
export function writeDecisionIndex(root, decisions) {
|
|
304
|
+
assertPlainPath(root, decisions.index);
|
|
305
|
+
const existing = readIndex(root, decisions);
|
|
306
|
+
// decisions.index could name a record or any other file; only a previously generated index is replaced.
|
|
307
|
+
if (existing.state === 'present' && !isGeneratedIndex(existing.text))
|
|
308
|
+
throw new UsageError(`refusing to overwrite ${decisions.index}: it is not a generated decision index (it does not begin with the generated "title: Decision Index" frontmatter); move it or change decisions.index`);
|
|
309
|
+
const records = readDecisionRecords(root, decisions);
|
|
310
|
+
mkdirSync(toFsPath(root, decisions.dir), { recursive: true });
|
|
311
|
+
writeFileSync(toFsPath(root, decisions.index), renderDecisionIndex(records, { adrDir: decisions.adr?.dir ?? null }));
|
|
312
|
+
const invalid = records.filter((record) => record.errors.length > 0).length;
|
|
313
|
+
return (
|
|
314
|
+
`corpus decisions index: wrote ${decisions.index} (${records.length} topic${records.length === 1 ? '' : 's'})` +
|
|
315
|
+
(invalid > 0 ? `\n note: ${invalid} record${invalid === 1 ? ' has' : 's have'} errors; run corpus decisions check` : '')
|
|
316
|
+
);
|
|
317
|
+
}
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Exit 1: a docs gate failed. Exit 2: the invocation or configuration is wrong. */
|
|
2
|
+
export const EXIT_OK = 0;
|
|
3
|
+
export const EXIT_GATE = 1;
|
|
4
|
+
export const EXIT_USAGE = 2;
|
|
5
|
+
|
|
6
|
+
export class DocsKitError extends Error {
|
|
7
|
+
constructor(message, exitCode) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = this.constructor.name;
|
|
10
|
+
this.exitCode = exitCode;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class UsageError extends DocsKitError {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message, EXIT_USAGE);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class GateFailure extends DocsKitError {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message, EXIT_GATE);
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/git.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { UsageError } from './errors.mjs';
|
|
3
|
+
|
|
4
|
+
// Variables that point git at a repository other than the one `cwd` is in.
|
|
5
|
+
// Git hooks export them (GIT_DIR in pre-push, GIT_INDEX_FILE in pre-commit),
|
|
6
|
+
// so docs-kit run from a hook would otherwise read, or a caller's fixture
|
|
7
|
+
// setup write, the hook's repository instead of `--root`.
|
|
8
|
+
const REPO_REDIRECT_VARS = [
|
|
9
|
+
'GIT_DIR',
|
|
10
|
+
'GIT_WORK_TREE',
|
|
11
|
+
'GIT_INDEX_FILE',
|
|
12
|
+
'GIT_COMMON_DIR',
|
|
13
|
+
'GIT_OBJECT_DIRECTORY',
|
|
14
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
15
|
+
'GIT_NAMESPACE',
|
|
16
|
+
'GIT_PREFIX',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** `env` without the variables that redirect git away from `cwd`. */
|
|
20
|
+
export function gitEnv(env = process.env) {
|
|
21
|
+
const clean = { ...env };
|
|
22
|
+
for (const name of REPO_REDIRECT_VARS) delete clean[name];
|
|
23
|
+
return clean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function git(root, args) {
|
|
27
|
+
return execFileSync('git', args, {
|
|
28
|
+
cwd: root,
|
|
29
|
+
env: gitEnv(),
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
32
|
+
windowsHide: true,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function gitError(error) {
|
|
37
|
+
const stderr = error?.stderr?.toString().trim();
|
|
38
|
+
return stderr || error?.message || String(error);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** HEAD commit of the checkout containing `root`, or throws UsageError. */
|
|
42
|
+
export function headSha(root) {
|
|
43
|
+
try {
|
|
44
|
+
return git(root, ['rev-parse', '--verify', 'HEAD']).trim();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new UsageError(
|
|
47
|
+
`cannot read the git HEAD of ${root}: ${gitError(error)}\n` +
|
|
48
|
+
'docs-kit needs a git checkout with at least one commit to stamp the bundle sha.',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** HEAD sha, or null when `root` is not a git checkout (in-memory MCP use). */
|
|
54
|
+
export function headShaOrNull(root) {
|
|
55
|
+
try {
|
|
56
|
+
return git(root, ['rev-parse', '--verify', 'HEAD']).trim();
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Path of `root` relative to the git top level, with a trailing slash ('' at
|
|
64
|
+
* the top level). Blob links need it when the docs root is a subdirectory.
|
|
65
|
+
*/
|
|
66
|
+
export function repoPrefix(root) {
|
|
67
|
+
try {
|
|
68
|
+
return git(root, ['rev-parse', '--show-prefix']).trim();
|
|
69
|
+
} catch {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Throws UsageError unless `base` is a ref that resolves to a commit in `root`. */
|
|
75
|
+
export function verifyBase(root, base) {
|
|
76
|
+
if (typeof base !== 'string' || base.trim() === '' || base.startsWith('-'))
|
|
77
|
+
throw new UsageError(`--base must be a git ref, got "${base}"`);
|
|
78
|
+
try {
|
|
79
|
+
git(root, ['rev-parse', '--verify', '--quiet', `${base}^{commit}`]);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new UsageError(
|
|
82
|
+
`--base ${base} does not resolve to a commit in ${root}` +
|
|
83
|
+
(gitError(error) ? `: ${gitError(error)}` : '') +
|
|
84
|
+
'\n(in CI, fetch the base branch, e.g. actions/checkout with fetch-depth: 0)',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function diffNames(root, base, extra = []) {
|
|
90
|
+
verifyBase(root, base);
|
|
91
|
+
try {
|
|
92
|
+
// --no-renames lists a renamed file's old path too, so covers on it still match.
|
|
93
|
+
return git(root, ['diff', '--name-only', '--no-renames', ...extra, '--relative', '-z', `${base}...HEAD`])
|
|
94
|
+
.split('\0')
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new UsageError(`git diff ${base}...HEAD failed: ${gitError(error)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Files changed between the merge base of `base` and HEAD, relative to `root`
|
|
103
|
+
* (`--relative` also limits the diff to that subtree). A bad ref or a missing
|
|
104
|
+
* repository is a usage error, never an empty change set.
|
|
105
|
+
*/
|
|
106
|
+
export function changedFiles(root, base) {
|
|
107
|
+
return diffNames(root, base);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read a repo-relative file at the merge base, distinguishing absence from Git failure. */
|
|
111
|
+
export function readFileAtMergeBase(root, base, file) {
|
|
112
|
+
verifyBase(root, base);
|
|
113
|
+
try {
|
|
114
|
+
const mergeBase = git(root, ['merge-base', base, 'HEAD']).trim();
|
|
115
|
+
const repoFile = `${repoPrefix(root)}${file}`;
|
|
116
|
+
if (!git(root, ['ls-tree', '--full-tree', '-z', mergeBase, '--', repoFile])) return null;
|
|
117
|
+
return git(root, ['show', `${mergeBase}:${repoFile}`]);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new UsageError(`cannot read ${file} at the merge base: ${gitError(error)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Files added between the merge base of `base` and HEAD, relative to `root`.
|
|
125
|
+
* Without rename detection, a file renamed into place counts as added.
|
|
126
|
+
*/
|
|
127
|
+
export function addedFiles(root, base) {
|
|
128
|
+
return diffNames(root, base, ['--diff-filter=A']);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Tracked files under `root` (repo-relative to root), or null outside git.
|
|
133
|
+
* `git ls-files` lists paths relative to the working directory and limits
|
|
134
|
+
* them to that subtree.
|
|
135
|
+
*/
|
|
136
|
+
export function trackedFiles(root) {
|
|
137
|
+
try {
|
|
138
|
+
return git(root, ['ls-files', '-z', '--cached']).split('\0').filter(Boolean);
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `git status --porcelain` for root's subtree, or '' outside git. */
|
|
145
|
+
export function gitStatusPorcelain(root) {
|
|
146
|
+
try {
|
|
147
|
+
return git(root, ['status', '--porcelain', '--untracked-files=normal', '--', '.']);
|
|
148
|
+
} catch {
|
|
149
|
+
return '';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** True when the checkout has uncommitted changes (tracked or untracked). */
|
|
154
|
+
export function isDirty(root) {
|
|
155
|
+
try {
|
|
156
|
+
return git(root, ['status', '--porcelain', '--untracked-files=normal', '--', '.']).trim() !== '';
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `owner/name` from the origin remote, or null. Used only by `init`. */
|
|
163
|
+
export function originRepo(root) {
|
|
164
|
+
try {
|
|
165
|
+
const url = git(root, ['remote', 'get-url', 'origin']).trim();
|
|
166
|
+
const match = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
167
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|