@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,238 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import picomatch from 'picomatch';
4
+ import npa from 'npm-package-arg';
5
+ import { parse as parseYaml } from 'yaml';
6
+ import { parse as parseShell } from 'shell-quote';
7
+ import { UsageError } from './errors.mjs';
8
+ import { listFiles } from './paths.mjs';
9
+
10
+ function readPackage(root, file) {
11
+ try {
12
+ return JSON.parse(readFileSync(path.join(root, file), 'utf8'));
13
+ } catch (error) {
14
+ throw new UsageError(`${file}: cannot read dependency evidence: ${error.message}`);
15
+ }
16
+ }
17
+
18
+ function readYaml(root, file) {
19
+ try {
20
+ return parseYaml(readFileSync(path.join(root, file), 'utf8'));
21
+ } catch (error) {
22
+ throw new UsageError(`${file}: cannot parse YAML evidence: ${error.message}`);
23
+ }
24
+ }
25
+
26
+ function workspacePatterns(root, pkg) {
27
+ const pnpm = existsSync(path.join(root, 'pnpm-workspace.yaml'));
28
+ const patterns = pnpm ? readYaml(root, 'pnpm-workspace.yaml')?.packages ?? [] : pkg.workspaces?.packages ?? pkg.workspaces ?? [];
29
+ if (!Array.isArray(patterns) || patterns.some((value) => typeof value !== 'string' || !value.trim())) throw new UsageError(`${pnpm ? 'pnpm-workspace.yaml' : 'package.json'}: workspace packages must be an array of non-empty directory globs`);
30
+ return { patterns, pnpm };
31
+ }
32
+
33
+ function isKontourDependency(name, spec) {
34
+ let parsed;
35
+ try { parsed = npa.resolve(name, spec); } catch {
36
+ // npm-package-arg does not support workspace/link/patch protocols.
37
+ return name.startsWith('@kontourai/');
38
+ }
39
+ if (parsed.type === 'alias') return parsed.subSpec.name.startsWith('@kontourai/');
40
+ if (name.startsWith('@kontourai/')) return true;
41
+ if (parsed.hosted?.type === 'github') return parsed.hosted.user.split('/')[0].toLowerCase() === 'kontourai';
42
+ if (parsed.type === 'remote') {
43
+ const url = new URL(parsed.fetchSpec);
44
+ const [, owner, repo] = url.pathname.split('/');
45
+ return ['github.com', 'codeload.github.com'].includes(url.hostname.toLowerCase()) && owner?.toLowerCase() === 'kontourai' && Boolean(repo);
46
+ }
47
+ return false;
48
+ }
49
+
50
+ export function deriveBuiltOn(root) {
51
+ if (!existsSync(path.join(root, 'package.json'))) return { skipped: 'no package.json; Kontour dependency comparison was not verified' };
52
+ const pkg = readPackage(root, 'package.json');
53
+ const { patterns, pnpm } = workspacePatterns(root, pkg);
54
+ const files = listFiles(root);
55
+ // `.` (or `./`) names the root package, which is always compared. Exclusions only subtract, so a
56
+ // list with no other inclusions describes a single-package repo, like an empty list.
57
+ const members = patterns.filter((pattern) => !pattern.startsWith('!')).map((pattern) => pattern.replace(/\/+$/, '') || '.').filter((pattern) => pattern !== '.');
58
+ const include = picomatch(members);
59
+ const exclude = picomatch(patterns.filter((pattern) => pattern.startsWith('!')).map((pattern) => pattern.slice(1).replace(/\/$/, '')));
60
+ const matches = (directory) => include(directory) && !exclude(directory);
61
+ const packages = [['package.json', pkg], ...files.filter((file) => file.endsWith('/package.json') && matches(path.posix.dirname(file))).map((file) => [file, readPackage(root, file)])];
62
+ if (pnpm && members.length > 0 && packages.length === 1) throw new UsageError('pnpm-workspace.yaml: no workspace members resolve; fix the non-empty packages globs');
63
+ const ownPackages = new Set(packages.map(([, data]) => data.name).filter((name) => typeof name === 'string'));
64
+ const dependencies = [];
65
+ const excluded = [];
66
+ for (const [file, data] of packages) {
67
+ for (const section of ['dependencies', 'peerDependencies', 'optionalDependencies', 'devDependencies']) {
68
+ for (const [name, spec] of Object.entries(data[section] ?? {})) {
69
+ if (!isKontourDependency(name, spec)) continue;
70
+ const dependency = { package: name, file, section, dev: section === 'devDependencies' };
71
+ if (name.startsWith('@kontourai/') && ownPackages.has(name)) excluded.push(dependency);
72
+ else dependencies.push(dependency);
73
+ }
74
+ }
75
+ }
76
+ return { dependencies, excluded, packages, files };
77
+ }
78
+
79
+ function workflowCommands(root, file) {
80
+ const data = readYaml(root, file);
81
+ const runs = (steps, prefix) => (Array.isArray(steps) ? steps : []).flatMap((step, index) => (typeof step?.run === 'string' ? [{ source: `${file} ${prefix}steps[${index}].run`, text: step.run }] : []));
82
+ if (file.startsWith('.github/actions/')) return data?.runs?.using === 'composite' ? runs(data.runs.steps, 'runs.') : [];
83
+ return Object.entries(data?.jobs ?? {}).flatMap(([job, value]) => runs(value?.steps, `jobs.${job}.`));
84
+ }
85
+
86
+ const EXPRESSION = '__CORPUS_GITHUB_EXPRESSION__';
87
+ const UNTERMINATED_EXPRESSION = '__CORPUS_UNTERMINATED_GITHUB_EXPRESSION__';
88
+
89
+ // Replaces each `${{ … }}` with a placeholder word and joins backslash continuations, recording
90
+ // the original line number of every output line. Like GitHub's template reader, an expression ends
91
+ // at the first `}}` outside a single-quoted string ('' escapes a quote inside one). An expression
92
+ // that never closes makes GitHub reject the workflow; here it costs only the rest of its own line.
93
+ function neutralise(text) {
94
+ let out = '';
95
+ let line = 1;
96
+ const lines = [1];
97
+ for (let index = 0; index < text.length;) {
98
+ if (text.startsWith('${{', index)) {
99
+ let end = -1;
100
+ let inString = false;
101
+ for (let cursor = index + 3; cursor < text.length; cursor++) {
102
+ if (text[cursor] === "'") inString = !inString;
103
+ else if (!inString && text.startsWith('}}', cursor)) { end = cursor + 2; break; }
104
+ }
105
+ if (end === -1) {
106
+ out += UNTERMINATED_EXPRESSION;
107
+ end = text.indexOf('\n', index);
108
+ if (end === -1) end = text.length;
109
+ else if (text[end - 1] === '\r') end -= 1;
110
+ }
111
+ else out += EXPRESSION;
112
+ line += (text.slice(index, end).match(/\n/g) ?? []).length;
113
+ index = end;
114
+ } else if (text[index] === '\\' && /^\\\r?\n/.test(text.slice(index, index + 3))) {
115
+ out += ' ';
116
+ line += 1;
117
+ index += text[index + 1] === '\r' ? 3 : 2;
118
+ } else {
119
+ if (text[index] === '\n') lines.push(++line);
120
+ out += text[index++];
121
+ }
122
+ }
123
+ return { text: out, lines };
124
+ }
125
+
126
+ // Splits a script into simple commands. The unit of tokenisation is the logical line: physical
127
+ // lines joined by backslash continuations and by quotes still open at a line end, which is what the
128
+ // shell reads as one command line. A line the tokenizer cannot read (a bad `${…}` substitution or
129
+ // an unclosed GitHub expression) is skipped and reported, and later lines are still read. It is not
130
+ // split further at `;` or `&&`, because finding those separators needs the quote and substitution
131
+ // parsing that just failed; a guessed split could read quoted text as a command.
132
+ function shellCommands(command) {
133
+ const { text, lines } = neutralise(command);
134
+ const commands = [];
135
+ const skipped = [];
136
+ const heredocs = [];
137
+ let quote = null;
138
+ let logical = '';
139
+ let start = 0;
140
+ for (const [index, line] of text.split(/\r?\n/).entries()) {
141
+ if (heredocs.length) {
142
+ const { delimiter, tabs } = heredocs[0];
143
+ if ((tabs ? line.replace(/^\t+/, '') : line) === delimiter) heredocs.shift();
144
+ continue;
145
+ }
146
+ if (!logical) start = lines[index];
147
+ let escaped = false;
148
+ for (const char of line) {
149
+ if (escaped) { escaped = false; continue; }
150
+ if (char === '\\' && quote !== "'") { escaped = true; continue; }
151
+ if (quote) { if (char === quote) quote = null; }
152
+ else if (char === '"' || char === "'" || char === '`') quote = char;
153
+ else if (char === '#') break;
154
+ }
155
+ logical += line;
156
+ if (quote) { logical += '\n'; continue; }
157
+ let tokens;
158
+ let unparseable = logical.includes(UNTERMINATED_EXPRESSION);
159
+ if (!unparseable) {
160
+ try { tokens = parseShell(logical, (name) => `$${name}`); } catch { unparseable = true; }
161
+ }
162
+ if (unparseable) {
163
+ skipped.push(start);
164
+ // shell-quote throws only on `${` substitutions, so with every `$` removed it still finds this
165
+ // line's heredoc operators, and their bodies stay out of the evidence.
166
+ tokens = parseShell(logical.replaceAll('$', '_'));
167
+ }
168
+ logical = '';
169
+ for (let position = 0; position < tokens.length; position++) {
170
+ if (['<<', '<<-'].includes(tokens[position]?.op) && typeof tokens[position + 1] === 'string') heredocs.push({ delimiter: tokens[position + 1], tabs: tokens[position].op === '<<-' });
171
+ }
172
+ if (unparseable) continue;
173
+ let words = [];
174
+ for (const token of tokens) {
175
+ if ([';', '&&', '||', '|', '&', '|&'].includes(token?.op)) { commands.push(words); words = []; }
176
+ else if (!token?.comment) words.push(token);
177
+ }
178
+ commands.push(words);
179
+ }
180
+ return { commands, skipped };
181
+ }
182
+
183
+ function invokes(commands, via, bin) {
184
+ const executable = bin ?? via;
185
+ return commands.some((tokens) => {
186
+ let words = [...tokens];
187
+ while (typeof words[0] === 'string' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
188
+ if (!words.length) return false;
189
+ const runner = words[0];
190
+ let versioned = false;
191
+ if (runner === 'npx') {
192
+ words.shift();
193
+ while (['-y', '--yes', '--no-install', '--'].includes(words[0])) words.shift();
194
+ versioned = true;
195
+ } else if (['npm', 'pnpm', 'yarn'].includes(runner)) {
196
+ words.shift();
197
+ if (runner === 'npm' && words.shift() !== 'exec') return false;
198
+ if (runner === 'npm') versioned = true;
199
+ else if (['exec', 'dlx'].includes(words[0])) { words.shift(); versioned = true; }
200
+ if (words[0] === '--') words.shift();
201
+ }
202
+ const target = words[0];
203
+ if (typeof target !== 'string') return false;
204
+ if (target === executable || target === `./node_modules/.bin/${executable}`) return true;
205
+ return versioned && target.startsWith(`${executable}@`) && target.length > executable.length + 1;
206
+ });
207
+ }
208
+
209
+ export function checkBuiltOn(root, manifest) {
210
+ const derived = deriveBuiltOn(root);
211
+ if (derived.skipped) return derived;
212
+ const { dependencies, packages, files } = derived;
213
+ const failures = [];
214
+ const warnings = [];
215
+ const declared = manifest.builtOn.flatMap((entry, index) => (Array.isArray(entry.via) ? entry.via : [entry.via]).map((via) => ({ entry, index, via })));
216
+ const seen = new Map();
217
+ for (const { index, via } of declared) {
218
+ if (seen.has(via)) failures.push(`builtOn: ${via} appears more than once (entries ${seen.get(via)} and ${index}); declare each package or CLI only once`);
219
+ else seen.set(via, index);
220
+ }
221
+ for (const dep of dependencies.filter((dep) => !dep.dev)) {
222
+ if (!declared.some(({ via }) => via === dep.package)) failures.push(`${dep.file} ${dep.section}: ${dep.package} is missing from builtOn; add { product: "OWNER_PRODUCT_ID", via: "${dep.package}", for: "reason" }`);
223
+ }
224
+ const sources = packages.flatMap(([file, pkg]) => Object.entries(pkg.scripts ?? {}).filter(([, value]) => typeof value === 'string').map(([name, text]) => ({ source: `${file} scripts.${name}`, text })));
225
+ for (const file of files.filter((file) => (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(file) || /^\.github\/actions\/(?:[^/]+\/)+action\.ya?ml$/.test(file)))) sources.push(...workflowCommands(root, file));
226
+ const parsed = sources.map(({ source, text }) => ({ source, ...shellCommands(text) }));
227
+ const commands = parsed.flatMap((script) => script.commands);
228
+ const skipped = parsed.flatMap(({ source, skipped: lines }) => lines.map((line) => `${source} line ${line}`));
229
+ const skippedNote = skipped.length ? `; ${skipped.length} command line${skipped.length === 1 ? ' was' : 's were'} skipped as unparseable (${skipped.slice(0, 3).join(', ')}${skipped.length > 3 ? `, and ${skipped.length - 3} more` : ''})` : '';
230
+ for (const { entry, via } of declared) {
231
+ if (dependencies.some((dep) => via === dep.package)) continue;
232
+ if (entry.evidence === 'cli' && invokes(commands, via, entry.bin)) continue;
233
+ failures.push(`builtOn ${entry.product} (via ${via}) has no matching dependency; remove it or use evidence: "cli" with a CLI invoked in package.json scripts, workflow steps or composite actions and explain its use in for${entry.evidence === 'cli' ? skippedNote : ''}`);
234
+ }
235
+ const runtime = new Set(dependencies.filter((dep) => !dep.dev).map((dep) => dep.package));
236
+ for (const name of [...new Set(dependencies.filter((dep) => dep.dev && !runtime.has(dep.package)).map((dep) => dep.package))].sort()) warnings.push(`dev-only: ${name}; declare it in builtOn if the product's promise depends on it`);
237
+ return { failures, warnings, note: `${packages.length} package.json file(s) compared` };
238
+ }
package/src/bundle.mjs ADDED
@@ -0,0 +1,174 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { UsageError } from './errors.mjs';
3
+ import { headShaOrNull, isDirty, repoPrefix } from './git.mjs';
4
+ import { loadManifest, outputPathFor, splitTopicSource } from './manifest.mjs';
5
+ import { checkTopics, createDocumentReader, publishedSources } from './check.mjs';
6
+ import { analyzeMarkdown, documentTitle, extractSection, resolveLink, rewriteMarkdownLinks } from './markdown.mjs';
7
+ import { encodePath, statInRepo } from './paths.mjs';
8
+
9
+ export const DEFAULT_SITE_ORIGIN = 'https://kontourai.github.io/';
10
+
11
+ export function defaultSiteUrl(product) {
12
+ return `${DEFAULT_SITE_ORIGIN}${product}/`;
13
+ }
14
+
15
+ /** Absolute http(s) URL with exactly one trailing slash, or UsageError. */
16
+ export function normalizeBaseUrl(value, flag = '--base-url') {
17
+ let url;
18
+ try {
19
+ url = new URL(value);
20
+ } catch {
21
+ throw new UsageError(`${flag} must be an absolute URL, got "${value}"`);
22
+ }
23
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
24
+ throw new UsageError(`${flag} must be an http(s) URL, got "${value}"`);
25
+ url.search = '';
26
+ url.hash = '';
27
+ return url.href.endsWith('/') ? url.href : `${url.href}/`;
28
+ }
29
+
30
+ /**
31
+ * Digest of the canonical content: documents (landing first) and topics, in manifest order,
32
+ * without URLs, sha, or build time. Two builds of the same docs at different
33
+ * commits or base URLs share a digest. A topic's optional `parentId`, `tags`,
34
+ * and `summary` are content a reader sees, so they join its entry when present;
35
+ * a topic without them hashes exactly as before they existed.
36
+ */
37
+ export function bundleDigest(documents, topics) {
38
+ const canonical = JSON.stringify({
39
+ documents: documents.map(({ source, section, title, landing, markdown }) => ({ source, section, title, landing, markdown })),
40
+ topics: topics.map(({ id, title, source, markdown, parentId, tags, summary }) => ({
41
+ id,
42
+ title,
43
+ source,
44
+ markdown,
45
+ ...(parentId !== undefined ? { parentId } : {}),
46
+ ...(tags !== undefined ? { tags } : {}),
47
+ ...(summary !== undefined ? { summary } : {}),
48
+ })),
49
+ });
50
+ return createHash('sha256').update(canonical).digest('hex');
51
+ }
52
+
53
+ /**
54
+ * Assemble docs-bundle.json from a validated manifest. `read` is the shared
55
+ * document reader. Topic anchors must already have passed the topics gate.
56
+ *
57
+ * A bundle-only document (`publish: false`) has no page: its `url` is the
58
+ * GitHub blob at `sha` (`HEAD` outside git), and its `markdown`, and topics
59
+ * taken from it, have relative links made absolute (published targets to
60
+ * their page, anything else to GitHub) so the text stands alone for MCP
61
+ * readers; images and other embedded files (`src`, `srcset`, `poster`) go to
62
+ * raw.githubusercontent.com so they resolve to the file itself. The digest is computed over the Markdown as written, so it still
63
+ * identifies the content and not the commit or base URL.
64
+ */
65
+ export function assembleBundle({ manifest, read, sha, siteUrl, root, prefix = '', builtAt = new Date().toISOString() }) {
66
+ const published = new Set(publishedSources(manifest));
67
+ const githubUrl = (kind, repoPath) => `https://github.com/${manifest.repo}/${kind}/${sha ?? 'HEAD'}/${encodePath(`${prefix}${repoPath}`)}`;
68
+ const pageUrl = (source) => (published.has(source) ? `${siteUrl}${encodePath(outputPathFor(manifest, source))}` : githubUrl('blob', source));
69
+ // An embedded image needs the file's bytes, not GitHub's HTML page for it.
70
+ const rawUrl = (repoPath) => `https://raw.githubusercontent.com/${manifest.repo}/${sha ?? 'HEAD'}/${encodePath(`${prefix}${repoPath}`)}`;
71
+ const absolute = (href, fromSource, { image = false } = {}) => {
72
+ const target = resolveLink(href, fromSource);
73
+ if (target.kind === 'fragment') return target.rawFragment ? `${pageUrl(fromSource)}#${target.rawFragment}` : href;
74
+ if (target.kind !== 'local') return href;
75
+ const hash = target.rawFragment ? `#${target.rawFragment}` : '';
76
+ if (published.has(target.path)) return `${pageUrl(target.path)}${hash}`;
77
+ // A missing target fails the links gate; leave it as written.
78
+ const stat = statInRepo(root, target.path);
79
+ if (!stat) return href;
80
+ if (stat.kind === 'dir') return `${githubUrl('tree', target.path).replace(/\/$/, '')}${hash}`;
81
+ return `${image ? rawUrl(target.path) : githubUrl('blob', target.path)}${hash}`;
82
+ };
83
+ const bundleOnly = new Map(); // source -> { markdown, analysis } with links made absolute
84
+ const readForBundle = (source) => {
85
+ if (published.has(source)) return read(source);
86
+ if (!bundleOnly.has(source)) {
87
+ const markdown = rewriteMarkdownLinks(read(source).markdown, (href, options) => absolute(href, source, options));
88
+ bundleOnly.set(source, { markdown, analysis: analyzeMarkdown(markdown) });
89
+ }
90
+ return bundleOnly.get(source);
91
+ };
92
+ // The landing page is documents[0] with `landing: true` and no section.
93
+ const documents = [
94
+ { source: manifest.landing, section: null, title: null, landing: true },
95
+ ...manifest.documents.map(({ source, section, title }) => ({ source, section, title, landing: false })),
96
+ ].map(
97
+ ({ source, section, title, landing }) => {
98
+ // The title comes from the source as written, so it never depends on rewriting.
99
+ const { analysis, lineOffset } = read(source);
100
+ return {
101
+ source,
102
+ section,
103
+ title: documentTitle(analysis, source, title),
104
+ landing,
105
+ url: pageUrl(source),
106
+ // Where `markdown` sits in the repository: the path from the git top
107
+ // level, and the number of front-matter lines stripped before line 1.
108
+ repoPath: `${prefix}${source}`,
109
+ lineOffset,
110
+ markdown: readForBundle(source).markdown,
111
+ };
112
+ },
113
+ );
114
+ const topicSection = (id, path, anchor, reader) => {
115
+ const { markdown, analysis } = reader(path);
116
+ const section = anchor === null ? markdown : extractSection(analysis, anchor);
117
+ if (section === null) throw new UsageError(`topic "${id}": #${anchor} does not resolve in ${path}`);
118
+ return section;
119
+ };
120
+ const topics = manifest.topics.map(({ id, title, source }) => {
121
+ const { path, anchor } = splitTopicSource(source);
122
+ const markdown = topicSection(id, path, anchor, readForBundle);
123
+ return { id, title, source, url: anchor === null ? pageUrl(path) : `${pageUrl(path)}#${encodeURIComponent(anchor)}`, markdown };
124
+ });
125
+ const asWritten = {
126
+ documents: documents.map(({ source, section, title, landing }) => ({ source, section, title, landing, markdown: read(source).markdown })),
127
+ topics: manifest.topics.map(({ id, title, source }) => {
128
+ const { path, anchor } = splitTopicSource(source);
129
+ return { id, title, source, markdown: topicSection(id, path, anchor, read) };
130
+ }),
131
+ };
132
+ return {
133
+ // Only v2 manifests load, so build writes only v2 bundles; readers still accept published v1 bundles.
134
+ schemaVersion: 2,
135
+ purpose: manifest.purpose,
136
+ builtOn: manifest.builtOn,
137
+ product: manifest.product,
138
+ title: manifest.title,
139
+ tagline: manifest.tagline,
140
+ repo: manifest.repo,
141
+ sha,
142
+ builtAt,
143
+ siteUrl,
144
+ digest: bundleDigest(asWritten.documents, asWritten.topics),
145
+ documents,
146
+ topics,
147
+ // Search vocabulary, not content: only when the manifest declares it, and not part of `digest`.
148
+ ...(manifest.synonyms ? { synonyms: manifest.synonyms } : {}),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Build a bundle in memory from a checkout (used by `mcp --repo` and
154
+ * `--workspace`). Requires a valid manifest and resolvable topics; link and
155
+ * reachability gates are not required to serve docs. `sha` is null outside git;
156
+ * `dirty: true` is added when the working tree has uncommitted changes.
157
+ */
158
+ export function bundleFromCheckout(root, { siteUrl } = {}) {
159
+ const manifest = loadManifest(root);
160
+ const read = createDocumentReader(root);
161
+ const topicFailures = checkTopics(manifest, read);
162
+ if (topicFailures.length > 0) throw new UsageError(`topics do not resolve:\n${topicFailures.map((f) => ` - ${f}`).join('\n')}`);
163
+ const bundle = assembleBundle({
164
+ manifest,
165
+ read,
166
+ sha: headShaOrNull(root),
167
+ siteUrl: siteUrl ?? defaultSiteUrl(manifest.product),
168
+ root,
169
+ prefix: repoPrefix(root),
170
+ });
171
+ // A checkout with uncommitted changes serves content that `sha` alone does not identify.
172
+ if (bundle.sha !== null && isDirty(root)) bundle.dirty = true;
173
+ return bundle;
174
+ }