@prabhat-singh/docdrift 0.0.0-stage → 0.1.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/check.js ADDED
@@ -0,0 +1,228 @@
1
+ import { relative, sep } from 'node:path';
2
+
3
+ import { scanJava } from './scan.js';
4
+ import { scanDocs } from './docs.js';
5
+ import { compare, normalizePath } from './match.js';
6
+
7
+ const FRAMEWORK_PREFIXES = [
8
+ '/actuator',
9
+ '/swagger-ui',
10
+ '/v3/api-docs',
11
+ '/v2/api-docs',
12
+ '/swagger-resources',
13
+ '/h2-console',
14
+ '/error',
15
+ ];
16
+
17
+ /**
18
+ * Run the whole drift check for a project directory.
19
+ *
20
+ * @param {string} codeDir directory with .java sources
21
+ * @param {string} [docsDir] directory with README.md/docs, defaults to codeDir
22
+ * @param {object | null} [style] optional createStyle() result for colored output
23
+ * @param {{ strict?: boolean, codeEndpoints?: object[] }} [options] strict reports framework endpoints as stale
24
+ * @returns {{ report: string, exitCode: number, code: object[], docs: object[], result: object }}
25
+ */
26
+ export function check(codeDir, docsDir = codeDir, style = null, options = {}) {
27
+ const code = Array.isArray(options.codeEndpoints)
28
+ ? options.codeEndpoints
29
+ : scanJava(codeDir);
30
+ const docs = scanDocs(docsDir);
31
+
32
+ if (docs.length === 0) {
33
+ const message = `No endpoints are documented in ${docsDir}, nothing to compare (${code.length} endpoints found in code).`;
34
+ return {
35
+ code,
36
+ docs,
37
+ result: { matched: 0, stale: [], undocumented: [] },
38
+ report: style?.enabled ? style.warning(message) : message,
39
+ exitCode: 0,
40
+ };
41
+ }
42
+
43
+ const result = compare(code, docs);
44
+ const strict = options.strict === true;
45
+ const stale = strict
46
+ ? result.stale
47
+ : result.stale.filter((endpoint) => !isFrameworkPath(endpoint.path));
48
+ const ignored = strict
49
+ ? []
50
+ : result.stale.filter((endpoint) => isFrameworkPath(endpoint.path));
51
+ const undocumented = result.undocumented;
52
+ const drift = stale.length + undocumented.length;
53
+ const documentedCount = new Set(
54
+ docs.map(
55
+ (endpoint) => `${endpoint.method} ${normalizePath(endpoint.path)}`,
56
+ ),
57
+ ).size;
58
+ const matchedCount = Math.min(countMatched(code, docs), code.length);
59
+ const ignoredText = ignored.length > 0 ? frameworkLine(ignored) : null;
60
+ const lines = [];
61
+
62
+ if (style?.enabled) {
63
+ lines.push(
64
+ `${style.muted('docdrift check:')} ${style.bold(style.success(String(code.length)))} code endpoints, ${style.bold(style.success(String(documentedCount)))} documented endpoints, ${style.bold(style.success(String(matchedCount)))} matched`,
65
+ );
66
+ if (ignoredText) lines.push(ignoredText);
67
+ lines.push('');
68
+
69
+ if (drift > 0) {
70
+ const methodWidth = Math.max(
71
+ 0,
72
+ ...stale.map((endpoint) => endpoint.method.length),
73
+ ...undocumented.map((endpoint) => endpoint.method.length),
74
+ );
75
+
76
+ if (stale.length > 0) {
77
+ lines.push(style.danger('STALE (documented, not in code)'));
78
+ lines.push(...formatEndpointList(stale, docsDir, methodWidth, style));
79
+ lines.push('');
80
+ }
81
+
82
+ if (undocumented.length > 0) {
83
+ lines.push(style.danger('UNDOCUMENTED (in code, not documented)'));
84
+ lines.push(
85
+ ...formatEndpointList(
86
+ undocumented,
87
+ codeDir,
88
+ methodWidth,
89
+ style,
90
+ ),
91
+ );
92
+ lines.push('');
93
+ }
94
+ }
95
+
96
+ lines.push(
97
+ drift === 0
98
+ ? style.success('✔ No drift found')
99
+ : style.danger(
100
+ `✖ ${stale.length} stale, ${undocumented.length} undocumented`,
101
+ ),
102
+ );
103
+ } else {
104
+ lines.push(
105
+ `docdrift check: ${code.length} code endpoints, ${documentedCount} documented endpoints, ${matchedCount} matched`,
106
+ );
107
+ if (ignoredText) lines.push(ignoredText);
108
+ lines.push('');
109
+
110
+ if (drift === 0) {
111
+ lines.push('No drift found.');
112
+ } else {
113
+ const methodWidth = Math.max(
114
+ 0,
115
+ ...stale.map((endpoint) => endpoint.method.length),
116
+ ...undocumented.map((endpoint) => endpoint.method.length),
117
+ );
118
+
119
+ if (stale.length > 0) {
120
+ lines.push('STALE (documented, not in code)');
121
+ lines.push(...formatEndpointList(stale, docsDir, methodWidth));
122
+ lines.push('');
123
+ }
124
+
125
+ if (undocumented.length > 0) {
126
+ lines.push('UNDOCUMENTED (in code, not documented)');
127
+ lines.push(
128
+ ...formatEndpointList(undocumented, codeDir, methodWidth),
129
+ );
130
+ lines.push('');
131
+ }
132
+ }
133
+ }
134
+
135
+ return {
136
+ code,
137
+ docs,
138
+ result,
139
+ report: lines.join('\n').trimEnd(),
140
+ exitCode: drift === 0 ? 0 : 1,
141
+ };
142
+ }
143
+
144
+ // Count each documented (METHOD, normalised path) once, so a duplicate entry
145
+ // does not inflate the header, and never exceed the number of code endpoints.
146
+ function countMatched(codeEndpoints, docEndpoints) {
147
+ const matchedKeys = new Set();
148
+
149
+ for (const docEndpoint of docEndpoints) {
150
+ if (
151
+ codeEndpoints.some((codeEndpoint) =>
152
+ codeCovers(codeEndpoint, docEndpoint),
153
+ )
154
+ ) {
155
+ matchedKeys.add(
156
+ `${docEndpoint.method} ${normalizePath(docEndpoint.path)}`,
157
+ );
158
+ }
159
+ }
160
+
161
+ return matchedKeys.size;
162
+ }
163
+
164
+ function codeCovers(codeEndpoint, docEndpoint) {
165
+ if (normalizePath(codeEndpoint.path) !== normalizePath(docEndpoint.path)) {
166
+ return false;
167
+ }
168
+
169
+ const methods = String(codeEndpoint.method ?? '')
170
+ .split('|')
171
+ .map((method) => method.trim());
172
+
173
+ return methods.includes('ANY') || methods.includes(docEndpoint.method);
174
+ }
175
+
176
+ function isFrameworkPath(path) {
177
+ const normalized = normalizePath(path);
178
+ return FRAMEWORK_PREFIXES.some((prefix) => normalized.startsWith(prefix));
179
+ }
180
+
181
+ function frameworkLine(endpoints) {
182
+ const labels = [...endpoints]
183
+ .sort(
184
+ (a, b) =>
185
+ compareText(normalizePath(a.path), normalizePath(b.path)) ||
186
+ compareText(a.method, b.method),
187
+ )
188
+ .map((endpoint) => `${endpoint.method} ${endpoint.path}`);
189
+
190
+ return `Ignored ${endpoints.length} framework endpoint(s) documented but provided by libraries: ${labels.join(', ')}`;
191
+ }
192
+
193
+ /**
194
+ * Format endpoints as "METHOD PATH (relative/file.java:line)", sorted by path
195
+ * and then method, with the method column padded to align the paths.
196
+ */
197
+ export function formatEndpointList(
198
+ endpoints,
199
+ baseDir,
200
+ methodWidth = 0,
201
+ style = null,
202
+ ) {
203
+ return [...endpoints]
204
+ .sort(
205
+ (a, b) =>
206
+ compareText(a.path, b.path) || compareText(a.method, b.method),
207
+ )
208
+ .map((endpoint) => {
209
+ const method = endpoint.method.padEnd(methodWidth);
210
+ const where = `${relativeFile(baseDir, endpoint.file)}:${endpoint.line}`;
211
+
212
+ if (style?.enabled) {
213
+ return ` ${style.success(method)} ${endpoint.path} (${style.muted(where)})`;
214
+ }
215
+
216
+ return ` ${method} ${endpoint.path} (${where})`;
217
+ });
218
+ }
219
+
220
+ function relativeFile(baseDir, file) {
221
+ return relative(baseDir, file).split(sep).join('/');
222
+ }
223
+
224
+ function compareText(a, b) {
225
+ if (a < b) return -1;
226
+ if (a > b) return 1;
227
+ return 0;
228
+ }
package/src/config.js ADDED
@@ -0,0 +1,75 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const REQUIRED = ['DOCDRIFT_BASE_URL', 'DOCDRIFT_MODEL'];
5
+ const API_KEY = 'DOCDRIFT_API_KEY';
6
+
7
+ /**
8
+ * Load config from the environment, falling back to a .env file in `cwd`.
9
+ * Real environment variables win over .env values.
10
+ *
11
+ * @param {{ env?: NodeJS.ProcessEnv, cwd?: string }} [options]
12
+ * @returns {{ baseUrl: string, apiKey: string, model: string } | { error: string }}
13
+ */
14
+ export function loadConfig({ env = process.env, cwd = process.cwd() } = {}) {
15
+ const fileValues = readDotEnv(join(cwd, '.env'));
16
+
17
+ const read = (name) => {
18
+ const fromEnv = env[name];
19
+ if (fromEnv) return fromEnv;
20
+ return fileValues[name] ?? '';
21
+ };
22
+
23
+ const baseUrl = read('DOCDRIFT_BASE_URL');
24
+ const model = read('DOCDRIFT_MODEL');
25
+ const apiKey = read(API_KEY);
26
+
27
+ const missing = REQUIRED.filter((name) => !read(name));
28
+ if (!apiKey && !isLocal(baseUrl)) missing.push(API_KEY);
29
+
30
+ if (missing.length > 0) {
31
+ return {
32
+ error: `Missing ${missing.join(', ')}. Set these as environment variables or in .env (see .env.example).`,
33
+ };
34
+ }
35
+
36
+ return { baseUrl, apiKey, model };
37
+ }
38
+
39
+ function readDotEnv(path) {
40
+ if (!existsSync(path)) return {};
41
+
42
+ const values = {};
43
+ for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
44
+ const trimmed = line.trim();
45
+ if (!trimmed || trimmed.startsWith('#')) continue;
46
+
47
+ const separator = trimmed.indexOf('=');
48
+ if (separator === -1) continue;
49
+
50
+ const key = trimmed.slice(0, separator).trim();
51
+ let value = trimmed.slice(separator + 1).trim();
52
+ if (
53
+ (value.startsWith('"') && value.endsWith('"')) ||
54
+ (value.startsWith("'") && value.endsWith("'"))
55
+ ) {
56
+ value = value.slice(1, -1);
57
+ }
58
+ if (key) values[key] = value;
59
+ }
60
+
61
+ return values;
62
+ }
63
+
64
+ function isLocal(baseUrl) {
65
+ if (!baseUrl) return false;
66
+
67
+ try {
68
+ const { hostname } = new URL(baseUrl);
69
+ return hostname === 'localhost' || hostname === '127.0.0.1';
70
+ } catch {
71
+ return /^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1)(?::\d+)?(?:\/|$)/.test(
72
+ baseUrl,
73
+ );
74
+ }
75
+ }
package/src/docs.js ADDED
@@ -0,0 +1,99 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const SKIP_DIRS = new Set(['node_modules', '.git']);
5
+
6
+ // METHOD, then separators, then a path that starts with "/". The path stops at
7
+ // any character that ends an endpoint in prose, tables or inline code.
8
+ const ENDPOINT_RE =
9
+ /\b(GET|POST|PUT|DELETE|PATCH)[ \t`|*]+\/[^\s`|)\]"'<>;,?*]*/g;
10
+
11
+ /**
12
+ * Scan Markdown docs for documented REST endpoints.
13
+ *
14
+ * @param {string} dir project root containing README.md and/or docs/
15
+ * @returns {{ method: string, path: string, file: string, line: number }[]}
16
+ */
17
+ export function scanDocs(dir) {
18
+ const endpoints = [];
19
+
20
+ for (const file of collectMarkdownFiles(dir)) {
21
+ const text = readFileSync(file, 'utf8');
22
+ for (const endpoint of extractFromText(text)) {
23
+ endpoints.push({ ...endpoint, file });
24
+ }
25
+ }
26
+
27
+ return endpoints.sort(
28
+ (a, b) => compare(a.file, b.file) || a.line - b.line,
29
+ ).map(({ method, path, file, line }) => ({ method, path, file, line }));
30
+ }
31
+
32
+ /**
33
+ * Extract documented endpoints from a Markdown string.
34
+ *
35
+ * @param {string} text Markdown content
36
+ * @returns {{ method: string, path: string, line: number }[]}
37
+ */
38
+ export function extractFromText(text) {
39
+ const found = [];
40
+
41
+ ENDPOINT_RE.lastIndex = 0;
42
+ let match;
43
+ while ((match = ENDPOINT_RE.exec(String(text ?? ''))) !== null) {
44
+ const method = match[1];
45
+ let path = match[0].slice(method.length).replace(/^[ \t`|*]+/, '');
46
+ path = path.split('?')[0].replace(/[.:]+$/, '');
47
+ if (!path.startsWith('/')) continue;
48
+
49
+ found.push({ method, path, line: lineOf(text, match.index) });
50
+ }
51
+
52
+ return found;
53
+ }
54
+
55
+ function collectMarkdownFiles(dir) {
56
+ const files = [];
57
+
58
+ if (!existsSync(dir)) return files;
59
+
60
+ const entries = readdirSync(dir, { withFileTypes: true });
61
+ for (const entry of entries.sort((a, b) => compare(a.name, b.name))) {
62
+ if (entry.isFile() && entry.name.toLowerCase() === 'readme.md') {
63
+ files.push(join(dir, entry.name));
64
+ }
65
+ }
66
+
67
+ const docsDir = join(dir, 'docs');
68
+ if (existsSync(docsDir) && statSync(docsDir).isDirectory()) {
69
+ walkMarkdown(docsDir, files);
70
+ }
71
+
72
+ return files;
73
+ }
74
+
75
+ function walkMarkdown(current, files) {
76
+ const entries = readdirSync(current, { withFileTypes: true });
77
+ for (const entry of entries.sort((a, b) => compare(a.name, b.name))) {
78
+ const full = join(current, entry.name);
79
+ if (entry.isDirectory()) {
80
+ if (!SKIP_DIRS.has(entry.name)) walkMarkdown(full, files);
81
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
82
+ files.push(full);
83
+ }
84
+ }
85
+ }
86
+
87
+ function compare(a, b) {
88
+ if (a < b) return -1;
89
+ if (a > b) return 1;
90
+ return 0;
91
+ }
92
+
93
+ function lineOf(text, index) {
94
+ let line = 1;
95
+ for (let i = 0; i < index; i += 1) {
96
+ if (text[i] === '\n') line += 1;
97
+ }
98
+ return line;
99
+ }
package/src/fix.js ADDED
@@ -0,0 +1,299 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ import { compare, normalizePath } from './match.js';
5
+ import { extractFromText } from './docs.js';
6
+
7
+ /**
8
+ * Build the writer conversation for a fix attempt.
9
+ *
10
+ * @param {{ codeEndpoints: object[], docEndpoints: object[], readmeText: string }} input
11
+ * @returns {{ role: string, content: string }[]}
12
+ */
13
+ export function buildWriterMessages({ codeEndpoints, docEndpoints, readmeText }) {
14
+ const { stale, undocumented } = compare(codeEndpoints, docEndpoints);
15
+
16
+ const system = [
17
+ 'You update API documentation.',
18
+ 'Return ONLY the full updated README in Markdown, with no commentary and no code fences around it.',
19
+ 'Mention only endpoints from the lists below, plus endpoints already present in the README that also exist in the code.',
20
+ 'Never invent endpoints, paths or HTTP methods.',
21
+ 'Remove every STALE endpoint and add every UNDOCUMENTED endpoint.',
22
+ 'Place each added endpoint in the section whose path prefix matches it, and create a new section if none exists.',
23
+ 'Keep the existing formatting, wording and order of everything else.',
24
+ ].join(' ');
25
+
26
+ const user = [
27
+ 'Current README:',
28
+ '"""',
29
+ readmeText,
30
+ '"""',
31
+ '',
32
+ 'STALE endpoints to remove (documented, not in code):',
33
+ listEndpoints(stale),
34
+ '',
35
+ 'UNDOCUMENTED endpoints to add (in code, not documented):',
36
+ listEndpoints(undocumented),
37
+ ].join('\n');
38
+
39
+ return [
40
+ { role: 'system', content: system },
41
+ { role: 'user', content: user },
42
+ ];
43
+ }
44
+
45
+ /**
46
+ * Ask the writer for a README fix and verify it with plain code.
47
+ *
48
+ * @param {{
49
+ * codeEndpoints: object[],
50
+ * docEndpoints: object[],
51
+ * readmeText: string,
52
+ * chatFn: (messages: { role: string, content: string }[]) => Promise<string>,
53
+ * maxRetries?: number,
54
+ * }} input
55
+ * @returns {Promise<{
56
+ * status: string, draft?: string, invented?: object[], missing?: object[],
57
+ * leftover?: object[], attempts?: number,
58
+ * }>}
59
+ */
60
+ export async function fixDocs({
61
+ codeEndpoints,
62
+ docEndpoints,
63
+ readmeText,
64
+ chatFn,
65
+ maxRetries = 2,
66
+ }) {
67
+ const { stale, undocumented } = compare(codeEndpoints, docEndpoints);
68
+ if (stale.length === 0 && undocumented.length === 0) {
69
+ return { status: 'NO_DRIFT' };
70
+ }
71
+
72
+ const messages = buildWriterMessages({
73
+ codeEndpoints,
74
+ docEndpoints,
75
+ readmeText,
76
+ });
77
+
78
+ let attempts = 0;
79
+ let draft = '';
80
+ let problems = { invented: [], missing: [], leftover: [] };
81
+
82
+ while (attempts <= maxRetries) {
83
+ attempts += 1;
84
+ draft = cleanDraft(await chatFn(messages));
85
+ problems = verifyDraft(draft, codeEndpoints, undocumented, stale);
86
+
87
+ if (hasNoProblems(problems)) {
88
+ return { status: 'VERIFIED', draft, ...problems, attempts };
89
+ }
90
+
91
+ if (attempts > maxRetries) break;
92
+
93
+ messages.push({ role: 'assistant', content: draft });
94
+ messages.push({ role: 'user', content: problemsMessage(problems) });
95
+ }
96
+
97
+ return { status: 'UNVERIFIED', draft, ...problems, attempts };
98
+ }
99
+
100
+ /**
101
+ * Write the draft next to the README as README.drift.md, never overwriting it.
102
+ *
103
+ * @param {string} readmePath
104
+ * @param {string} draft
105
+ * @returns {string} path of the written file
106
+ */
107
+ export function writeDriftFile(readmePath, draft) {
108
+ const driftPath = join(dirname(readmePath), 'README.drift.md');
109
+ writeFileSync(driftPath, draft);
110
+ return driftPath;
111
+ }
112
+
113
+ /**
114
+ * Trim a draft and drop a single fence wrapping the whole answer. Models often
115
+ * wrap the README in ```markdown fences; keeping it would write a broken
116
+ * README and let the fence lines confuse the verifier.
117
+ */
118
+ function cleanDraft(rawDraft) {
119
+ const trimmed = String(rawDraft ?? '').trim();
120
+ const lines = trimmed.split('\n');
121
+
122
+ if (
123
+ lines.length >= 2 &&
124
+ lines[0].trim().startsWith('```') &&
125
+ lines[lines.length - 1].trim() === '```'
126
+ ) {
127
+ lines.shift();
128
+ lines.pop();
129
+ return lines.join('\n').trim();
130
+ }
131
+
132
+ return trimmed;
133
+ }
134
+
135
+ function verifyDraft(draft, codeEndpoints, undocumented, stale) {
136
+ const draftEndpoints = extractFromText(draft);
137
+
138
+ const invented = uniqueByMethodAndPath(
139
+ draftEndpoints.filter(
140
+ (endpoint) =>
141
+ !codeEndpoints.some((code) => codeCovers(code, endpoint)),
142
+ ),
143
+ );
144
+
145
+ const missing = undocumented.filter(
146
+ (code) => !draftEndpoints.some((endpoint) => codeCovers(code, endpoint)),
147
+ );
148
+
149
+ const leftover = stale.filter((staleEndpoint) =>
150
+ draftEndpoints.some((endpoint) =>
151
+ sameMethodAndPath(staleEndpoint, {
152
+ method: endpoint.method,
153
+ path: endpoint.path,
154
+ }),
155
+ ),
156
+ );
157
+
158
+ return { invented, missing, leftover };
159
+ }
160
+
161
+ function codeCovers(codeEndpoint, docEndpoint) {
162
+ if (normalizePath(codeEndpoint.path) !== normalizePath(docEndpoint.path)) {
163
+ return false;
164
+ }
165
+
166
+ const methods = String(codeEndpoint.method ?? '')
167
+ .split('|')
168
+ .map((method) => method.trim());
169
+
170
+ return methods.includes('ANY') || methods.includes(docEndpoint.method);
171
+ }
172
+
173
+ function sameMethodAndPath(a, b) {
174
+ return (
175
+ a.method === b.method && normalizePath(a.path) === normalizePath(b.path)
176
+ );
177
+ }
178
+
179
+ function uniqueByMethodAndPath(endpoints) {
180
+ const seen = new Set();
181
+ const unique = [];
182
+ for (const endpoint of endpoints) {
183
+ const key = `${endpoint.method} ${normalizePath(endpoint.path)}`;
184
+ if (seen.has(key)) continue;
185
+ seen.add(key);
186
+ unique.push(endpoint);
187
+ }
188
+ return unique;
189
+ }
190
+
191
+ function hasNoProblems({ invented, missing, leftover }) {
192
+ return invented.length + missing.length + leftover.length === 0;
193
+ }
194
+
195
+ function problemsMessage({ invented, missing, leftover }) {
196
+ const lines = ['The draft has problems:'];
197
+
198
+ if (invented.length > 0) {
199
+ lines.push(`- Invented endpoints that are not in the code: ${labels(invented)}`);
200
+ }
201
+ if (missing.length > 0) {
202
+ lines.push(`- Documented endpoints still missing: ${labels(missing)}`);
203
+ }
204
+ if (leftover.length > 0) {
205
+ lines.push(`- Stale endpoints still present: ${labels(leftover)}`);
206
+ }
207
+
208
+ lines.push('Return ONLY the corrected full README in Markdown.');
209
+ return lines.join('\n');
210
+ }
211
+
212
+ /**
213
+ * Format the `fix` command output. Never includes the API key or base URL.
214
+ *
215
+ * @param {{
216
+ * status: string, model: string, attempts: number, staleRemoved: number,
217
+ * undocumentedAdded: number, draftPath: string | null,
218
+ * invented?: object[], missing?: object[], leftover?: object[],
219
+ * }} input
220
+ * @param {object | null} [style] optional createStyle() result for colored output
221
+ * @returns {string}
222
+ */
223
+ export function formatFixReport(input, style = null) {
224
+ const {
225
+ status,
226
+ model,
227
+ attempts,
228
+ staleRemoved,
229
+ undocumentedAdded,
230
+ draftPath,
231
+ invented = [],
232
+ missing = [],
233
+ leftover = [],
234
+ } = input;
235
+
236
+ const colored = Boolean(style?.enabled);
237
+
238
+ const statusValue = !colored
239
+ ? status
240
+ : status === 'UNVERIFIED'
241
+ ? style.danger(status)
242
+ : style.success(status);
243
+
244
+ const lines = colored
245
+ ? [
246
+ `${style.muted('Status:')} ${statusValue}`,
247
+ `${style.muted('Model:')} ${style.muted(model)}`,
248
+ `${style.muted('Attempts:')} ${style.muted(attempts)}`,
249
+ `${style.muted('Stale removed:')} ${style.bold(style.success(String(staleRemoved)))} | ${style.muted('Undocumented added:')} ${style.bold(style.success(String(undocumentedAdded)))}`,
250
+ `${style.muted('Draft:')} ${style.muted(draftPath ?? '(none)')}`,
251
+ ]
252
+ : [
253
+ `Status: ${status}`,
254
+ `Model: ${model}`,
255
+ `Attempts: ${attempts}`,
256
+ `Stale removed: ${staleRemoved} | Undocumented added: ${undocumentedAdded}`,
257
+ `Draft: ${draftPath ?? '(none)'}`,
258
+ ];
259
+
260
+ if (status === 'UNVERIFIED') {
261
+ for (const endpoint of invented) {
262
+ lines.push(problemLine(style, 'Invented:', endpoint));
263
+ }
264
+ for (const endpoint of missing) {
265
+ lines.push(problemLine(style, 'Missing:', endpoint));
266
+ }
267
+ for (const endpoint of leftover) {
268
+ lines.push(problemLine(style, 'Leftover:', endpoint));
269
+ }
270
+ }
271
+
272
+ if (colored && status === 'VERIFIED') {
273
+ lines.push(style.success('✔ Draft verified against the code'));
274
+ }
275
+ if (colored && status === 'UNVERIFIED') {
276
+ lines.push(style.danger('✖ Draft could not be verified'));
277
+ }
278
+
279
+ return lines.join('\n');
280
+ }
281
+
282
+ function problemLine(style, label, endpoint) {
283
+ const text = `${endpoint.method} ${endpoint.path}`;
284
+ if (!style?.enabled) return `${label} ${text}`;
285
+ return `${style.danger(label)} ${style.danger(text)}`;
286
+ }
287
+
288
+ function listEndpoints(endpoints) {
289
+ if (endpoints.length === 0) return '- (none)';
290
+ return endpoints
291
+ .map((endpoint) => `- ${endpoint.method} ${endpoint.path}`)
292
+ .join('\n');
293
+ }
294
+
295
+ function labels(endpoints) {
296
+ return endpoints
297
+ .map((endpoint) => `${endpoint.method} ${endpoint.path}`)
298
+ .join(', ');
299
+ }