@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/src/cli.mjs ADDED
@@ -0,0 +1,226 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { DocsKitError, EXIT_OK, EXIT_USAGE, UsageError } from './errors.mjs';
5
+
6
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
7
+
8
+ export const HELP = `Corpus ${VERSION}: check, build, locate, and retrieve source-bound docs over MCP
9
+
10
+ Usage: corpus <command> [options]
11
+
12
+ Commands:
13
+ check Run the docs gates: manifest, purpose, links, reachability, topics,
14
+ redirects, paths, builtOn, covers, decisions (when declared), and with
15
+ --base freshness and the ADR freeze
16
+ --root <dir> repository root (default: .)
17
+ --base <git-ref> also check freshness against the changes in <git-ref>...HEAD
18
+ --advisory report stale documents as warnings and exit 0
19
+ --only <gates> run only these comma-separated gates (and the manifest);
20
+ a selected gate that cannot run is a usage error
21
+ --json print the result as one JSON object on stdout
22
+ build Run check (without freshness), render the site, write docs-bundle.json,
23
+ and verify every internal link of the generated site
24
+ --root <dir> repository root (default: .)
25
+ --out <dir> output directory, replaced on each build (default: dist-pages)
26
+ --base-url <url> published site URL (default: https://kontourai.github.io/<product>/)
27
+ locate Locate a quote in source-bound docs
28
+ --root <dir> repository root (default: .)
29
+ --quote <text> passage to locate
30
+ --source <path> optionally restrict to one document
31
+ mcp Serve docs bundles over stdio to MCP clients; at most one source
32
+ --repo <dir> build one bundle in memory from a checkout
33
+ --workspace <dir> serve every immediate subdirectory that has corpus.json
34
+ --hub <url> fetch <url>/products.json and each bundle (default: https://kontourai.github.io/)
35
+ --bundle <file> serve a prebuilt docs-bundle.json as is; repeat for more products
36
+ hub Build the organization hub page from products.json
37
+ --products <file> product list (default: products.json)
38
+ --out <dir> output directory (default: dist-hub)
39
+ decisions check Run only the decisions gates (records, index, and with --base
40
+ the ADR freeze); needs a "decisions" block in corpus.json
41
+ --root <dir> repository root (default: .)
42
+ --base <git-ref> also refuse files added under decisions.adr.dir since <git-ref>
43
+ decisions index Write the decisions index file, and nothing else
44
+ --root <dir> repository root (default: .)
45
+ content impact Map changed files to related content, including GitHub file URLs
46
+ --manifest <file> relationship/review manifest (default: corpus-content.json)
47
+ --root <dir> repository root (default: .)
48
+ --base <git-ref> committed changes from the merge base
49
+ --changed-file <path> explicit changed file; repeat instead of --base
50
+ content check Check reviewed input/output bytes, resolving GitHub refs once per run
51
+ --manifest <file> review manifest (default: corpus-content.json)
52
+ --root <dir> repository root (default: .)
53
+ --checkout <owner/repo=dir> optional local working bytes; repeat for more repos
54
+ init Scaffold a v2 corpus.json from README.md and docs/**/*.md; never overwrites a
55
+ corpus.json, and refuses while a retired v1 docs-kit.json is present
56
+ --root <dir> repository root (default: .)
57
+
58
+ Options:
59
+ -h, --help show this help
60
+ -v, --version print the version
61
+
62
+ Exit codes: 0 ok, 1 gate failure, 2 usage or configuration error.
63
+ Manifest reference: https://github.com/kontourai/corpus#manifest-reference`;
64
+
65
+ const COMMAND_OPTIONS = {
66
+ check: { root: { type: 'string' }, base: { type: 'string' }, advisory: { type: 'boolean' }, only: { type: 'string' }, json: { type: 'boolean' } },
67
+ build: { root: { type: 'string' }, out: { type: 'string' }, 'base-url': { type: 'string' } },
68
+ locate: { root: { type: 'string' }, quote: { type: 'string' }, source: { type: 'string' } },
69
+ mcp: { repo: { type: 'string' }, workspace: { type: 'string' }, hub: { type: 'string' }, bundle: { type: 'string', multiple: true } },
70
+ hub: { products: { type: 'string' }, out: { type: 'string' } },
71
+ init: { root: { type: 'string' } },
72
+ 'decisions check': { root: { type: 'string' }, base: { type: 'string' } },
73
+ 'decisions index': { root: { type: 'string' } },
74
+ 'content impact': { root: { type: 'string' }, manifest: { type: 'string' }, base: { type: 'string' }, 'changed-file': { type: 'string', multiple: true } },
75
+ 'content check': { root: { type: 'string' }, manifest: { type: 'string' }, checkout: { type: 'string', multiple: true } },
76
+ };
77
+ const DECISIONS_SUBCOMMANDS = ['check', 'index'];
78
+
79
+ function parse(command, args) {
80
+ try {
81
+ return parseArgs({
82
+ args,
83
+ options: { ...COMMAND_OPTIONS[command], help: { type: 'boolean', short: 'h' } },
84
+ strict: true,
85
+ allowPositionals: false,
86
+ }).values;
87
+ } catch (error) {
88
+ throw new UsageError(`${command}: ${error.message}`);
89
+ }
90
+ }
91
+
92
+ function requireValue(values, name) {
93
+ // A `multiple` option parses to an array; every occurrence needs a value.
94
+ if ([values[name] ?? []].flat().some((value) => value.trim() === '')) throw new UsageError(`--${name} needs a value`);
95
+ }
96
+
97
+ /** `check --json` when the run did not reach a gate result: a usage or configuration error, or a crash. */
98
+ function checkErrorJson(message, exitCode, { base, advisory, only }) {
99
+ return { schemaVersion: 1, tool: 'corpus', command: 'check', verdict: 'fail', exitCode, base, advisory, only, error: message };
100
+ }
101
+
102
+ /** Run the CLI and return the exit code. Output goes to stdout; diagnostics to stderr. */
103
+ export async function main(argv, { stdout = process.stdout, stderr = process.stderr } = {}) {
104
+ const say = (text) => stdout.write(`${text}\n`);
105
+ const warn = (text) => stderr.write(`${text}\n`);
106
+ let args = argv;
107
+ // `npx -y github:kontourai/corpus#<sha> corpus <cmd>` runs this bin with
108
+ // "docs-kit" as its first argument, so tolerate one leading program name.
109
+ if (args[0] === 'docs-kit' || args[0] === 'corpus') args = args.slice(1);
110
+ let [command, ...rest] = args;
111
+
112
+ if (command === undefined) {
113
+ warn(HELP);
114
+ return EXIT_USAGE;
115
+ }
116
+ if (command === '-h' || command === '--help' || command === 'help') {
117
+ say(HELP);
118
+ return EXIT_OK;
119
+ }
120
+ if (command === '-v' || command === '--version') {
121
+ say(VERSION);
122
+ return EXIT_OK;
123
+ }
124
+
125
+ // `check --json`: stdout must hold one JSON object on every exit, so any
126
+ // failure before or outside the gates is printed as a fail object too.
127
+ const jsonCheck = command === 'check' && rest.includes('--json');
128
+ const checkRun = { base: null, advisory: false, only: null };
129
+ try {
130
+ if (command === 'decisions' || command === 'content') {
131
+ const [sub, ...subArgs] = rest;
132
+ if (sub === '-h' || sub === '--help') {
133
+ say(HELP);
134
+ return EXIT_OK;
135
+ }
136
+ const choices = command === 'content' ? ['check', 'impact'] : DECISIONS_SUBCOMMANDS;
137
+ if (!choices.includes(sub))
138
+ throw new UsageError(`${command} needs a subcommand: ${choices.join(' or ')}${sub === undefined ? '' : ` (got "${sub}")`}`);
139
+ command = `${command} ${sub}`;
140
+ rest = subArgs;
141
+ }
142
+ if (!Object.hasOwn(COMMAND_OPTIONS, command)) throw new UsageError(`unknown command "${command}"\n\n${HELP}`);
143
+ const values = parse(command, rest);
144
+ if (values.help) {
145
+ if (jsonCheck) throw new UsageError('--help prints text and cannot be combined with --json; run "corpus check --help"');
146
+ say(HELP);
147
+ return EXIT_OK;
148
+ }
149
+ for (const name of Object.keys(COMMAND_OPTIONS[command])) if (COMMAND_OPTIONS[command][name].type === 'string') requireValue(values, name);
150
+ const root = path.resolve(values.root ?? '.');
151
+
152
+ switch (command) {
153
+ case 'content check':
154
+ case 'content impact': {
155
+ const { runContent } = await import('./content.mjs');
156
+ const result = await runContent({ root, manifest: values.manifest, mode: command.split(' ')[1], base: values.base, changed: values['changed-file'], checkouts: values.checkout });
157
+ say(JSON.stringify(result, null, 2));
158
+ return result.exitCode;
159
+ }
160
+ case 'check': {
161
+ const { runChecks, formatCheckReport, parseOnly, checkReportJson } = await import('./check.mjs');
162
+ checkRun.base = values.base ?? null;
163
+ checkRun.advisory = Boolean(values.advisory);
164
+ if (values.advisory && values.base === undefined) throw new UsageError('--advisory only applies to the freshness gate; pass --base <git-ref>');
165
+ if (values.only !== undefined) checkRun.only = parseOnly(values.only);
166
+ const result = runChecks({ root, ...checkRun });
167
+ if (values.json) say(JSON.stringify(checkReportJson(result, checkRun), null, 2));
168
+ else (result.exitCode === EXIT_OK ? say : warn)(formatCheckReport(result, root));
169
+ return result.exitCode;
170
+ }
171
+ case 'build': {
172
+ const { runBuild } = await import('./build.mjs');
173
+ const result = runBuild({ root, out: path.resolve(values.out ?? 'dist-pages'), baseUrl: values['base-url'] });
174
+ (result.exitCode === EXIT_OK ? say : warn)(result.report);
175
+ return result.exitCode;
176
+ }
177
+ case 'locate': {
178
+ if (!values.quote) throw new UsageError('locate requires --quote <text>');
179
+ const { bundleFromCheckout } = await import('./bundle.mjs');
180
+ const { locateInBundle } = await import('./locate.mjs');
181
+ const result = locateInBundle(bundleFromCheckout(root), values.quote, { source: values.source });
182
+ say(JSON.stringify(result, null, 2));
183
+ return result.error ? 1 : EXIT_OK;
184
+ }
185
+ case 'mcp': {
186
+ const { runMcp } = await import('./mcp.mjs');
187
+ await runMcp({ repo: values.repo, workspace: values.workspace, hub: values.hub, bundle: values.bundle });
188
+ return EXIT_OK;
189
+ }
190
+ case 'hub': {
191
+ const { runHub } = await import('./hub.mjs');
192
+ say(runHub({ products: path.resolve(values.products ?? 'products.json'), out: path.resolve(values.out ?? 'dist-hub') }));
193
+ return EXIT_OK;
194
+ }
195
+ case 'decisions check': {
196
+ const { runDecisionChecks, formatCheckReport } = await import('./check.mjs');
197
+ const result = runDecisionChecks({ root, base: values.base ?? null });
198
+ (result.exitCode === EXIT_OK ? say : warn)(formatCheckReport(result, root, { title: 'corpus decisions check' }));
199
+ return result.exitCode;
200
+ }
201
+ case 'decisions index': {
202
+ const { loadManifest, manifestFile } = await import('./manifest.mjs');
203
+ const { writeDecisionIndex } = await import('./decisions.mjs');
204
+ // A documents entry for the index may name the file this command is about to create.
205
+ const manifest = loadManifest(root, { allowMissingDecisionIndex: true });
206
+ if (!manifest.decisions)
207
+ throw new UsageError(`${manifestFile(root)} has no "decisions" block; add "decisions": {} to enable the decisions gate`);
208
+ say(writeDecisionIndex(root, manifest.decisions));
209
+ return EXIT_OK;
210
+ }
211
+ case 'init': {
212
+ const { runInit } = await import('./init.mjs');
213
+ say(runInit({ root }));
214
+ return EXIT_OK;
215
+ }
216
+ }
217
+ } catch (error) {
218
+ const known = error instanceof DocsKitError;
219
+ // Not a gate result: never let an unexpected crash read as "docs failed" (1).
220
+ const exitCode = known ? error.exitCode : EXIT_USAGE;
221
+ if (jsonCheck) say(JSON.stringify(checkErrorJson(known ? error.message : `internal error: ${error?.message ?? error}`, exitCode, checkRun), null, 2));
222
+ warn(known ? `docs-kit: ${error.message}` : `docs-kit: internal error: ${error?.stack ?? error}`);
223
+ return exitCode;
224
+ }
225
+ return EXIT_USAGE;
226
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { contentDigest, readContentManifest } from './content.mjs';
4
+ import { readFileAtMergeBase } from './git.mjs';
5
+ import { UsageError } from './errors.mjs';
6
+ import { statInRepo } from './paths.mjs';
7
+
8
+ const MANIFEST = 'corpus-content.json';
9
+
10
+ function currentFile(root, entry) {
11
+ // Freshness stays offline: remote reviews belong to `content check`.
12
+ if (!entry.path || statInRepo(root, entry.path)?.kind !== 'file') return false;
13
+ return contentDigest(readFileSync(path.resolve(root, entry.path)), entry.pointer) === entry.sha256;
14
+ }
15
+
16
+ /** Local, newly recorded byte reviews can justify leaving covering prose unchanged. */
17
+ export function reviewedContentDocuments(root, base, documents, changed) {
18
+ const approved = new Set();
19
+ if (!existsSync(path.resolve(root, MANIFEST))) return approved;
20
+ const manifest = readContentManifest(root);
21
+ if (!changed.includes(MANIFEST) || !manifest.reviews?.length) return approved;
22
+ const previousBytes = readFileAtMergeBase(root, base, MANIFEST);
23
+ let previous;
24
+ try { previous = previousBytes === null ? null : JSON.parse(previousBytes); }
25
+ catch { throw new UsageError('content review manifest at the merge base is not valid JSON'); }
26
+ const previousReviews = new Map((previous?.reviews ?? []).map((review) => [review.id, review]));
27
+ for (const review of manifest.reviews) {
28
+ if (JSON.stringify(previousReviews.get(review.id)) === JSON.stringify(review)) continue;
29
+ if (!review.inputs.every((entry) => currentFile(root, entry)) ||
30
+ !review.outputs.every((entry) => currentFile(root, entry))) continue;
31
+ const inputs = new Set(review.inputs.filter((entry) => entry.pointer === undefined).map((entry) => entry.path));
32
+ for (const { source, covered } of documents) {
33
+ if (covered.every((file) => inputs.has(file)) && review.outputs.some((entry) => entry.path === source && entry.pointer === undefined)) approved.add(source);
34
+ }
35
+ }
36
+ return approved;
37
+ }
@@ -0,0 +1,222 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import picomatch from 'picomatch';
5
+ import { UsageError } from './errors.mjs';
6
+ import { changedFiles } from './git.mjs';
7
+ import { repoGlobError, repoPathError } from './paths.mjs';
8
+
9
+ const SHA = /^[a-f0-9]{64}$/;
10
+ const COMMIT = /^[a-f0-9]{40}$/;
11
+ const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
12
+ const fail = (message) => { throw new UsageError(`content: NOT_VERIFIED: ${message}`); };
13
+ const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
14
+ const validPointer = (value) => typeof value === 'string' && (value === '' || value.startsWith('/')) && !/~(?:[^01]|$)/.test(value);
15
+ function ordered(value) {
16
+ if (typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value)))) fail('selected JSON number is outside the finite safe range');
17
+ if (Array.isArray(value)) return value.map(ordered);
18
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, ordered(value[key])]));
19
+ return value;
20
+ }
21
+
22
+ /** Whole-file bytes, or canonical JSON for one explicitly selected value. */
23
+ export function contentDigest(bytes, pointer) {
24
+ if (pointer === undefined) return hash(bytes);
25
+ if (!validPointer(pointer)) fail('invalid JSON Pointer');
26
+ let value;
27
+ try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
28
+ catch { fail('selected content is not valid UTF-8 JSON'); }
29
+ for (const encoded of pointer === '' ? [] : pointer.slice(1).split('/')) {
30
+ const key = encoded.replaceAll('~1', '/').replaceAll('~0', '~');
31
+ if (value === null || typeof value !== 'object' || !Object.hasOwn(value, key) ||
32
+ (Array.isArray(value) && !/^(?:0|[1-9][0-9]*)$/.test(key))) fail(`JSON Pointer does not resolve: ${pointer}`);
33
+ value = value[key];
34
+ }
35
+ return hash(JSON.stringify(ordered(value)));
36
+ }
37
+
38
+ function keys(value, allowed, required, label) {
39
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
40
+ Object.keys(value).some((key) => !allowed.includes(key)) ||
41
+ required.some((key) => !Object.hasOwn(value, key))) fail(`${label}: invalid or unknown fields`);
42
+ }
43
+
44
+ /** GitHub blob URLs identify a repository, ref and file independently of checkout layout. */
45
+ export function githubContentReference(value) {
46
+ let url;
47
+ try { url = new URL(value); } catch { fail(`invalid GitHub file URL: ${value}`); }
48
+ if (url.protocol !== 'https:' || url.hostname !== 'github.com' || url.port || url.username || url.password || url.search || url.hash)
49
+ fail(`expected a GitHub HTTPS blob URL without credentials, query or fragment: ${value}`);
50
+ const parts = url.pathname.slice(1).split('/').map((part) => {
51
+ try { return decodeURIComponent(part); } catch { fail(`invalid URL encoding: ${value}`); }
52
+ });
53
+ const [owner, repo, kind, ref, ...file] = parts;
54
+ const repository = `${owner}/${repo}`;
55
+ const source = file.join('/');
56
+ if (!REPO.test(repository) || kind !== 'blob' || !ref || /[\\\s?#]/.test(ref) || repoPathError(source) || source.startsWith('.git/'))
57
+ fail(`expected https://github.com/owner/repo/blob/ref/path: ${value}`);
58
+ return { repository, ref, path: source };
59
+ }
60
+
61
+ function nonemptyArray(value, label) {
62
+ if (!Array.isArray(value) || !value.length) fail(`${label} must be a nonempty array`);
63
+ }
64
+
65
+ function contentTarget(value) {
66
+ let url;
67
+ try { url = new URL(value); } catch { fail(`invalid content target: ${value}`); }
68
+ if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || url.search)
69
+ fail(`content targets must be HTTPS URLs without credentials or query: ${value}`);
70
+ if (url.hostname === 'github.com') githubContentReference(value);
71
+ }
72
+
73
+ function files(entries, label) {
74
+ nonemptyArray(entries, label);
75
+ const identities = new Set();
76
+ for (const entry of entries) {
77
+ keys(entry, ['path', 'url', 'sha256', 'revision', 'pointer'], ['sha256'], label);
78
+ if (Boolean(entry.path) === Boolean(entry.url) || !SHA.test(entry.sha256)) fail(`${label}: expected one path or URL and a SHA-256`);
79
+ if (entry.url) {
80
+ githubContentReference(entry.url);
81
+ if (!COMMIT.test(entry.revision)) fail(`${label}: remote files need the reviewed full commit revision`);
82
+ } else if (repoPathError(entry.path) || entry.path.startsWith('.git/') || entry.revision !== undefined) fail(`${label}: invalid local file`);
83
+ if (entry.pointer !== undefined && !validPointer(entry.pointer)) fail(`${label}: invalid JSON Pointer`);
84
+ const identity = JSON.stringify([entry.path ?? entry.url, entry.pointer ?? null]);
85
+ if (identities.has(identity)) fail(`${label}: duplicate file ${identity}`);
86
+ identities.add(identity);
87
+ }
88
+ }
89
+
90
+ export function readContentManifest(root, manifest = 'corpus-content.json') {
91
+ let raw;
92
+ try { raw = JSON.parse(readFileSync(path.resolve(root, manifest), 'utf8')); }
93
+ catch (error) { fail(`cannot read ${manifest}: ${error.message}`); }
94
+ keys(raw, ['schemaVersion', 'repository', 'relationships', 'reviews'], ['schemaVersion', 'repository'], 'manifest');
95
+ if (raw.schemaVersion !== 1 || !REPO.test(raw.repository)) fail('expected schemaVersion 1 and an owner/repo repository');
96
+ if (raw.relationships === undefined && raw.reviews === undefined) fail('manifest needs relationships or reviews');
97
+ if ((raw.relationships !== undefined && !Array.isArray(raw.relationships)) ||
98
+ (raw.reviews !== undefined && !Array.isArray(raw.reviews))) fail('relationships and reviews must be arrays');
99
+ const ids = new Set();
100
+ for (const relation of raw.relationships ?? []) {
101
+ keys(relation, ['id', 'covers', 'targets'], ['id', 'covers', 'targets'], 'relationship');
102
+ if (typeof relation.id !== 'string' || !relation.id || ids.has(relation.id)) fail('relationship IDs must be nonempty and unique');
103
+ ids.add(relation.id);
104
+ nonemptyArray(relation.covers, 'covers');
105
+ if (relation.covers.some((glob) => repoGlobError(glob))) fail(`${relation.id}: invalid covers glob`);
106
+ nonemptyArray(relation.targets, 'targets');
107
+ for (const url of relation.targets) contentTarget(url);
108
+ }
109
+ ids.clear();
110
+ for (const review of raw.reviews ?? []) {
111
+ keys(review, ['id', 'reviewedAt', 'note', 'limits', 'inputs', 'outputs'], ['id', 'reviewedAt', 'note', 'limits', 'inputs', 'outputs'], 'review');
112
+ if (typeof review.id !== 'string' || !review.id || ids.has(review.id)) fail('review IDs must be nonempty and unique');
113
+ ids.add(review.id);
114
+ if (typeof review.reviewedAt !== 'string' || !Number.isFinite(Date.parse(review.reviewedAt)) ||
115
+ typeof review.note !== 'string' || !review.note.trim() || typeof review.limits !== 'string' || !review.limits.trim()) fail(`${review.id}: date, review note and limits are required`);
116
+ files(review.inputs, `${review.id} inputs`);
117
+ files(review.outputs, `${review.id} outputs`);
118
+ }
119
+ return raw;
120
+ }
121
+
122
+ export function contentImpact(manifest, changed) {
123
+ if (!Array.isArray(manifest.relationships) || !manifest.relationships.length) fail('impact needs declared relationships');
124
+ if (!Array.isArray(changed) || changed.some((file) => repoPathError(file))) fail('changed files must be repository-relative paths');
125
+ return manifest.relationships.flatMap(({ id, covers, targets }) => {
126
+ const matches = picomatch(covers.map((glob) => glob.endsWith('/') ? `${glob}**` : glob), { dot: true });
127
+ const changedBy = [...new Set(changed)].filter((file) => matches(file)).sort();
128
+ return changedBy.length ? [{ id, targets, changedBy }] : [];
129
+ });
130
+ }
131
+
132
+ function localBytes(root, file) {
133
+ if (repoPathError(file)) fail(`invalid local path ${file}`);
134
+ try {
135
+ const base = realpathSync(root);
136
+ const target = realpathSync(path.resolve(base, file));
137
+ const within = path.relative(base, target);
138
+ if (within === '..' || within.startsWith(`..${path.sep}`) || path.isAbsolute(within)) fail(`file escapes checkout: ${file}`);
139
+ return readFileSync(target);
140
+ } catch (error) { fail(`cannot read ${file}: ${error.message}`); }
141
+ }
142
+
143
+ async function githubJson(endpoint) {
144
+ const headers = { Accept: 'application/vnd.github+json' };
145
+ // Credentials go only to GitHub's API, never to file URLs or redirects.
146
+ if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
147
+ const response = await fetch(`https://api.github.com/${endpoint}`, { headers, redirect: 'error', signal: AbortSignal.timeout(20_000) });
148
+ if (!response.ok) fail(`GitHub lookup returned ${response.status}: ${endpoint}`);
149
+ return response.json();
150
+ }
151
+
152
+ function checkoutMap(values) {
153
+ const entries = new Map();
154
+ for (const value of values) {
155
+ const split = value.indexOf('=');
156
+ const repository = value.slice(0, split);
157
+ const directory = value.slice(split + 1);
158
+ if (split < 1 || !REPO.test(repository) || !directory || entries.has(repository)) fail('checkout must be a unique owner/repo=/directory mapping');
159
+ entries.set(repository, path.resolve(directory));
160
+ }
161
+ return entries;
162
+ }
163
+
164
+ /** Check declared byte freshness. Checkout mappings explicitly select working bytes, without network. */
165
+ export async function checkContentReviews(root, manifest, checkouts = []) {
166
+ nonemptyArray(manifest.reviews, 'check reviews');
167
+ const local = checkoutMap(checkouts);
168
+ const revisions = new Map();
169
+ const bytes = new Map();
170
+ const observedRevisions = {};
171
+ const read = async (entry) => {
172
+ if (entry.path) return localBytes(root, entry.path);
173
+ const source = githubContentReference(entry.url);
174
+ if (local.has(source.repository)) {
175
+ observedRevisions[source.repository] = 'local working bytes (explicit checkout mapping)';
176
+ return localBytes(local.get(source.repository), source.path);
177
+ }
178
+ const key = `${source.repository}@${source.ref}`;
179
+ if (!revisions.has(key)) {
180
+ const result = await githubJson(`repos/${source.repository}/commits/${encodeURIComponent(source.ref)}`);
181
+ if (!COMMIT.test(result.sha)) fail(`GitHub returned an invalid revision for ${key}`);
182
+ revisions.set(key, result.sha);
183
+ observedRevisions[key] = result.sha;
184
+ }
185
+ const revision = revisions.get(key);
186
+ const fileKey = `${source.repository}@${revision}/${source.path}`;
187
+ if (!bytes.has(fileKey)) {
188
+ const result = await githubJson(`repos/${source.repository}/contents/${source.path.split('/').map(encodeURIComponent).join('/')}?ref=${revision}`);
189
+ if (result.type !== 'file' || result.encoding !== 'base64' || typeof result.content !== 'string') fail(`GitHub did not return file bytes for ${entry.url}`);
190
+ bytes.set(fileKey, Buffer.from(result.content, 'base64'));
191
+ }
192
+ return bytes.get(fileKey);
193
+ };
194
+ const failures = [];
195
+ for (const review of manifest.reviews) {
196
+ for (const [kind, entries] of [['input', review.inputs], ['output', review.outputs]]) {
197
+ for (const entry of entries) {
198
+ if (contentDigest(await read(entry), entry.pointer) !== entry.sha256) failures.push(`${review.id}: changed ${kind}: ${entry.url ?? entry.path}${entry.pointer === undefined ? '' : `#${entry.pointer}`}`);
199
+ }
200
+ }
201
+ }
202
+ return {
203
+ schemaVersion: 1, command: 'content check', verdict: failures.length ? 'fail' : 'pass',
204
+ exitCode: failures.length ? 1 : 0, failures, observedRevisions,
205
+ limits: 'Freshness of declared file bytes or selected JSON values only; not semantic correctness, complete dependency coverage, legal classification, release availability or deployment. Re-review claims before changing hashes.',
206
+ };
207
+ }
208
+
209
+ export async function runContent({ root, manifest, mode, base, changed = [], checkouts = [] }) {
210
+ const reviewed = readContentManifest(root, manifest);
211
+ if (mode === 'check') {
212
+ try { return await checkContentReviews(root, reviewed, checkouts); }
213
+ catch (error) { fail(error.message); }
214
+ }
215
+ if (base !== undefined && changed.length) fail('choose --base or --changed-file, not both');
216
+ if (base === undefined && !changed.length) fail('impact needs --base or --changed-file');
217
+ return {
218
+ schemaVersion: 1, command: 'content impact', verdict: 'advisory', exitCode: 0,
219
+ repository: reviewed.repository, surfaces: contentImpact(reviewed, base === undefined ? changed : changedFiles(root, base)),
220
+ limits: 'Declared relationships are review leads, not proof that claims changed or a complete dependency graph.',
221
+ };
222
+ }