@doccov/cli 0.10.2 → 0.12.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.
Files changed (2) hide show
  1. package/dist/cli.js +485 -464
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -179,13 +179,13 @@ ${formatIssues(issues)}`);
179
179
  var defineConfig = (config) => config;
180
180
  // src/cli.ts
181
181
  import { readFileSync as readFileSync4 } from "node:fs";
182
- import * as path9 from "node:path";
182
+ import * as path10 from "node:path";
183
183
  import { fileURLToPath } from "node:url";
184
184
  import { Command } from "commander";
185
185
 
186
186
  // src/commands/check.ts
187
187
  import * as fs2 from "node:fs";
188
- import * as path3 from "node:path";
188
+ import * as path4 from "node:path";
189
189
  import {
190
190
  applyEdits,
191
191
  categorizeDrifts,
@@ -208,6 +208,223 @@ import {
208
208
  } from "@openpkg-ts/spec";
209
209
  import chalk2 from "chalk";
210
210
 
211
+ // src/reports/diff-markdown.ts
212
+ import * as path2 from "node:path";
213
+ function bar(pct, width = 10) {
214
+ const filled = Math.round(pct / 100 * width);
215
+ return "█".repeat(filled) + "░".repeat(width - filled);
216
+ }
217
+ function formatDelta(delta) {
218
+ if (delta > 0)
219
+ return `+${delta}%`;
220
+ if (delta < 0)
221
+ return `${delta}%`;
222
+ return "0%";
223
+ }
224
+ function renderDiffMarkdown(data, options = {}) {
225
+ const limit = options.limit ?? 10;
226
+ const lines = [];
227
+ lines.push(`# DocCov Diff Report`);
228
+ lines.push("");
229
+ lines.push(`**Comparing:** \`${data.baseName}\` → \`${data.headName}\``);
230
+ lines.push("");
231
+ lines.push("## Coverage Summary");
232
+ lines.push("");
233
+ const deltaIndicator = data.coverageDelta > 0 ? "↑" : data.coverageDelta < 0 ? "↓" : "→";
234
+ lines.push(`| Metric | Base | Head | Delta |`);
235
+ lines.push(`|--------|------|------|-------|`);
236
+ lines.push(`| Coverage | ${data.oldCoverage}% | ${data.newCoverage}% | ${deltaIndicator} ${formatDelta(data.coverageDelta)} |`);
237
+ lines.push(`| Breaking Changes | - | ${data.breaking.length} | - |`);
238
+ lines.push(`| New Exports | - | ${data.nonBreaking.length} | - |`);
239
+ lines.push(`| New Undocumented | - | ${data.newUndocumented.length} | - |`);
240
+ if (data.driftIntroduced > 0 || data.driftResolved > 0) {
241
+ const driftDelta = data.driftIntroduced > 0 ? `+${data.driftIntroduced}` : data.driftResolved > 0 ? `-${data.driftResolved}` : "0";
242
+ lines.push(`| Drift | - | - | ${driftDelta} |`);
243
+ }
244
+ if (data.breaking.length > 0) {
245
+ lines.push("");
246
+ lines.push("## Breaking Changes");
247
+ lines.push("");
248
+ const categorized = data.categorizedBreaking ?? [];
249
+ const highSeverity = categorized.filter((c) => c.severity === "high");
250
+ const otherSeverity = categorized.filter((c) => c.severity !== "high");
251
+ if (highSeverity.length > 0) {
252
+ lines.push("### High Severity");
253
+ lines.push("");
254
+ lines.push("| Name | Kind | Reason |");
255
+ lines.push("|------|------|--------|");
256
+ for (const c of highSeverity.slice(0, limit)) {
257
+ lines.push(`| \`${c.name}\` | ${c.kind} | ${c.reason} |`);
258
+ }
259
+ if (highSeverity.length > limit) {
260
+ lines.push(`| ... | | ${highSeverity.length - limit} more |`);
261
+ }
262
+ lines.push("");
263
+ }
264
+ if (otherSeverity.length > 0) {
265
+ lines.push("### Medium/Low Severity");
266
+ lines.push("");
267
+ lines.push("| Name | Kind | Reason |");
268
+ lines.push("|------|------|--------|");
269
+ for (const c of otherSeverity.slice(0, limit)) {
270
+ lines.push(`| \`${c.name}\` | ${c.kind} | ${c.reason} |`);
271
+ }
272
+ if (otherSeverity.length > limit) {
273
+ lines.push(`| ... | | ${otherSeverity.length - limit} more |`);
274
+ }
275
+ lines.push("");
276
+ }
277
+ }
278
+ if (data.memberChanges && data.memberChanges.length > 0) {
279
+ lines.push("");
280
+ lines.push("## Member Changes");
281
+ lines.push("");
282
+ const byClass = groupMemberChangesByClass(data.memberChanges);
283
+ for (const [className, changes] of byClass) {
284
+ lines.push(`### ${className}`);
285
+ lines.push("");
286
+ lines.push("| Member | Change | Details |");
287
+ lines.push("|--------|--------|---------|");
288
+ for (const mc of changes.slice(0, limit)) {
289
+ const details = getChangeDetails(mc);
290
+ lines.push(`| \`${mc.memberName}()\` | ${mc.changeType} | ${details} |`);
291
+ }
292
+ if (changes.length > limit) {
293
+ lines.push(`| ... | | ${changes.length - limit} more |`);
294
+ }
295
+ lines.push("");
296
+ }
297
+ }
298
+ if (data.nonBreaking.length > 0) {
299
+ lines.push("");
300
+ lines.push("## New Exports");
301
+ lines.push("");
302
+ const undocSet = new Set(data.newUndocumented);
303
+ lines.push("| Export | Documented |");
304
+ lines.push("|--------|------------|");
305
+ for (const name of data.nonBreaking.slice(0, limit)) {
306
+ const documented = undocSet.has(name) ? "No" : "Yes";
307
+ lines.push(`| \`${name}\` | ${documented} |`);
308
+ }
309
+ if (data.nonBreaking.length > limit) {
310
+ lines.push(`| ... | ${data.nonBreaking.length - limit} more |`);
311
+ }
312
+ }
313
+ if (data.docsImpact) {
314
+ renderDocsImpactSection(lines, data.docsImpact, limit);
315
+ }
316
+ lines.push("");
317
+ lines.push("---");
318
+ lines.push("*Generated by [DocCov](https://doccov.com)*");
319
+ return lines.join(`
320
+ `);
321
+ }
322
+ function groupMemberChangesByClass(changes) {
323
+ const byClass = new Map;
324
+ for (const mc of changes) {
325
+ const list = byClass.get(mc.className) ?? [];
326
+ list.push(mc);
327
+ byClass.set(mc.className, list);
328
+ }
329
+ return byClass;
330
+ }
331
+ function getChangeDetails(mc) {
332
+ if (mc.changeType === "removed") {
333
+ return mc.suggestion ? `→ ${mc.suggestion}` : "removed";
334
+ }
335
+ if (mc.changeType === "signature-changed") {
336
+ if (mc.oldSignature && mc.newSignature) {
337
+ return `\`${mc.oldSignature}\` → \`${mc.newSignature}\``;
338
+ }
339
+ return "signature changed";
340
+ }
341
+ if (mc.changeType === "added") {
342
+ return "new";
343
+ }
344
+ return "-";
345
+ }
346
+ function renderDocsImpactSection(lines, docsImpact, limit) {
347
+ const { impactedFiles, missingDocs, stats, allUndocumented } = docsImpact;
348
+ lines.push("");
349
+ lines.push("## Documentation Impact");
350
+ lines.push("");
351
+ lines.push(`Scanned **${stats.filesScanned}** files, **${stats.codeBlocksFound}** code blocks, **${stats.referencesFound}** references.`);
352
+ lines.push("");
353
+ if (impactedFiles.length === 0 && missingDocs.length === 0) {
354
+ lines.push("No documentation updates required.");
355
+ return;
356
+ }
357
+ if (impactedFiles.length > 0) {
358
+ lines.push("### Files Requiring Updates");
359
+ lines.push("");
360
+ lines.push("| File | Issues | Details |");
361
+ lines.push("|------|--------|---------|");
362
+ for (const file of impactedFiles.slice(0, limit)) {
363
+ const filename = path2.basename(file.file);
364
+ const issueCount = file.references.length;
365
+ const firstRef = file.references[0];
366
+ const detail = firstRef ? `L${firstRef.line}: ${firstRef.memberName ?? firstRef.exportName}` : "-";
367
+ lines.push(`| \`${filename}\` | ${issueCount} | ${detail} |`);
368
+ }
369
+ if (impactedFiles.length > limit) {
370
+ lines.push(`| ... | | ${impactedFiles.length - limit} more files |`);
371
+ }
372
+ lines.push("");
373
+ }
374
+ if (missingDocs.length > 0) {
375
+ lines.push("### New Exports Missing Documentation");
376
+ lines.push("");
377
+ lines.push("| Export |");
378
+ lines.push("|--------|");
379
+ for (const name of missingDocs.slice(0, limit)) {
380
+ lines.push(`| \`${name}\` |`);
381
+ }
382
+ if (missingDocs.length > limit) {
383
+ lines.push(`| ... ${missingDocs.length - limit} more |`);
384
+ }
385
+ lines.push("");
386
+ }
387
+ if (allUndocumented && allUndocumented.length > 0 && stats.totalExports > 0) {
388
+ const docPercent = Math.round((stats.totalExports - allUndocumented.length) / stats.totalExports * 100);
389
+ lines.push("### Documentation Coverage");
390
+ lines.push("");
391
+ lines.push(`**${stats.documentedExports}/${stats.totalExports}** exports documented (${docPercent}%) \`${bar(docPercent)}\``);
392
+ lines.push("");
393
+ }
394
+ }
395
+
396
+ // src/reports/diff-html.ts
397
+ function escapeHtml(s) {
398
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
399
+ }
400
+ function renderDiffHtml(data, options = {}) {
401
+ const md = renderDiffMarkdown(data, options);
402
+ return `<!DOCTYPE html>
403
+ <html lang="en">
404
+ <head>
405
+ <meta charset="UTF-8">
406
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
407
+ <title>DocCov Diff: ${escapeHtml(data.baseName)} → ${escapeHtml(data.headName)}</title>
408
+ <style>
409
+ :root { --bg: #0d1117; --fg: #c9d1d9; --border: #30363d; --accent: #58a6ff; --success: #3fb950; --warning: #d29922; --danger: #f85149; }
410
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); max-width: 900px; margin: 0 auto; padding: 2rem; line-height: 1.6; }
411
+ h1, h2, h3 { border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
412
+ h1 { color: var(--accent); }
413
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
414
+ th, td { border: 1px solid var(--border); padding: 0.5rem 1rem; text-align: left; }
415
+ th { background: #161b22; }
416
+ code { background: #161b22; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.9em; }
417
+ a { color: var(--accent); }
418
+ .delta-positive { color: var(--success); }
419
+ .delta-negative { color: var(--danger); }
420
+ .delta-neutral { color: var(--fg); }
421
+ </style>
422
+ </head>
423
+ <body>
424
+ <pre style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(md)}</pre>
425
+ </body>
426
+ </html>`;
427
+ }
211
428
  // src/reports/github.ts
212
429
  function renderGithubSummary(stats, options = {}) {
213
430
  const coverageScore = options.coverageScore ?? stats.coverageScore;
@@ -235,7 +452,7 @@ ${status} Coverage ${coverageScore >= 80 ? "passing" : coverageScore >= 50 ? "ne
235
452
  }
236
453
  // src/reports/markdown.ts
237
454
  import { DRIFT_CATEGORY_LABELS } from "@openpkg-ts/spec";
238
- function bar(pct, width = 10) {
455
+ function bar2(pct, width = 10) {
239
456
  const filled = Math.round(pct / 100 * width);
240
457
  return "█".repeat(filled) + "░".repeat(width - filled);
241
458
  }
@@ -244,7 +461,7 @@ function renderMarkdown(stats, options = {}) {
244
461
  const lines = [];
245
462
  lines.push(`# DocCov Report: ${stats.packageName}@${stats.version}`);
246
463
  lines.push("");
247
- lines.push(`**Coverage: ${stats.coverageScore}%** \`${bar(stats.coverageScore)}\``);
464
+ lines.push(`**Coverage: ${stats.coverageScore}%** \`${bar2(stats.coverageScore)}\``);
248
465
  lines.push("");
249
466
  lines.push("| Metric | Value |");
250
467
  lines.push("|--------|-------|");
@@ -259,7 +476,7 @@ function renderMarkdown(stats, options = {}) {
259
476
  lines.push("| Signal | Coverage |");
260
477
  lines.push("|--------|----------|");
261
478
  for (const [sig, s] of Object.entries(stats.signalCoverage)) {
262
- lines.push(`| ${sig} | ${s.pct}% \`${bar(s.pct, 8)}\` |`);
479
+ lines.push(`| ${sig} | ${s.pct}% \`${bar2(s.pct, 8)}\` |`);
263
480
  }
264
481
  if (stats.byKind.length > 0) {
265
482
  lines.push("");
@@ -331,7 +548,7 @@ function renderMarkdown(stats, options = {}) {
331
548
  }
332
549
 
333
550
  // src/reports/html.ts
334
- function escapeHtml(s) {
551
+ function escapeHtml2(s) {
335
552
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
336
553
  }
337
554
  function renderHtml(stats, options = {}) {
@@ -341,7 +558,7 @@ function renderHtml(stats, options = {}) {
341
558
  <head>
342
559
  <meta charset="UTF-8">
343
560
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
344
- <title>DocCov Report: ${escapeHtml(stats.packageName)}</title>
561
+ <title>DocCov Report: ${escapeHtml2(stats.packageName)}</title>
345
562
  <style>
346
563
  :root { --bg: #0d1117; --fg: #c9d1d9; --border: #30363d; --accent: #58a6ff; }
347
564
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); max-width: 900px; margin: 0 auto; padding: 2rem; line-height: 1.6; }
@@ -354,7 +571,7 @@ function renderHtml(stats, options = {}) {
354
571
  </style>
355
572
  </head>
356
573
  <body>
357
- <pre style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(md)}</pre>
574
+ <pre style="white-space: pre-wrap; font-family: inherit;">${escapeHtml2(md)}</pre>
358
575
  </body>
359
576
  </html>`;
360
577
  }
@@ -467,18 +684,18 @@ function computeStats(spec) {
467
684
  }
468
685
  // src/reports/writer.ts
469
686
  import * as fs from "node:fs";
470
- import * as path2 from "node:path";
687
+ import * as path3 from "node:path";
471
688
  import { DEFAULT_REPORT_DIR, getReportPath } from "@doccov/sdk";
472
689
  import chalk from "chalk";
473
690
  function writeReport(options) {
474
691
  const { format, content, outputPath, cwd = process.cwd(), silent = false } = options;
475
- const reportPath = outputPath ? path2.resolve(cwd, outputPath) : path2.resolve(cwd, getReportPath(format));
476
- const dir = path2.dirname(reportPath);
692
+ const reportPath = outputPath ? path3.resolve(cwd, outputPath) : path3.resolve(cwd, getReportPath(format));
693
+ const dir = path3.dirname(reportPath);
477
694
  if (!fs.existsSync(dir)) {
478
695
  fs.mkdirSync(dir, { recursive: true });
479
696
  }
480
697
  fs.writeFileSync(reportPath, content);
481
- const relativePath = path2.relative(cwd, reportPath);
698
+ const relativePath = path3.relative(cwd, reportPath);
482
699
  if (!silent) {
483
700
  console.log(chalk.green(`✓ Wrote ${format} report to ${relativePath}`));
484
701
  }
@@ -571,6 +788,17 @@ async function parseAssertionsWithLLM(code) {
571
788
  }
572
789
  }
573
790
 
791
+ // src/utils/validation.ts
792
+ function clampPercentage(value, fallback = 80) {
793
+ if (Number.isNaN(value))
794
+ return fallback;
795
+ return Math.min(100, Math.max(0, Math.round(value)));
796
+ }
797
+ function resolveThreshold(cliValue, configValue) {
798
+ const raw = cliValue ?? configValue;
799
+ return raw !== undefined ? clampPercentage(Number(raw)) : undefined;
800
+ }
801
+
574
802
  // src/commands/check.ts
575
803
  var defaultDependencies = {
576
804
  createDocCov: (options) => new DocCov(options),
@@ -617,9 +845,9 @@ function registerCheckCommand(program, dependencies = {}) {
617
845
  }
618
846
  const config = await loadDocCovConfig(targetDir);
619
847
  const minCoverageRaw = options.minCoverage ?? config?.check?.minCoverage;
620
- const minCoverage = minCoverageRaw !== undefined ? clampCoverage(minCoverageRaw) : undefined;
848
+ const minCoverage = minCoverageRaw !== undefined ? clampPercentage(minCoverageRaw) : undefined;
621
849
  const maxDriftRaw = options.maxDrift ?? config?.check?.maxDrift;
622
- const maxDrift = maxDriftRaw !== undefined ? clampCoverage(maxDriftRaw) : undefined;
850
+ const maxDrift = maxDriftRaw !== undefined ? clampPercentage(maxDriftRaw) : undefined;
623
851
  const resolveExternalTypes = !options.skipResolve;
624
852
  let specResult;
625
853
  const doccov = createDocCov({
@@ -711,7 +939,7 @@ function registerCheckCommand(program, dependencies = {}) {
711
939
  log(chalk2.gray(` Skipping ${exp.name}: declaration file`));
712
940
  continue;
713
941
  }
714
- const filePath = path3.resolve(targetDir, exp.source.file);
942
+ const filePath = path4.resolve(targetDir, exp.source.file);
715
943
  if (!fs2.existsSync(filePath)) {
716
944
  log(chalk2.gray(` Skipping ${exp.name}: file not found`));
717
945
  continue;
@@ -755,7 +983,7 @@ function registerCheckCommand(program, dependencies = {}) {
755
983
  log(chalk2.bold("Dry run - changes that would be made:"));
756
984
  log("");
757
985
  for (const [filePath, fileEdits] of editsByFile) {
758
- const relativePath = path3.relative(targetDir, filePath);
986
+ const relativePath = path4.relative(targetDir, filePath);
759
987
  log(chalk2.cyan(` ${relativePath}:`));
760
988
  for (const { export: exp, edit, fixes } of fileEdits) {
761
989
  const lineInfo = edit.hasExisting ? `lines ${edit.startLine + 1}-${edit.endLine + 1}` : `line ${edit.startLine + 1}`;
@@ -929,12 +1157,6 @@ function registerCheckCommand(program, dependencies = {}) {
929
1157
  }
930
1158
  });
931
1159
  }
932
- function clampCoverage(value) {
933
- if (Number.isNaN(value)) {
934
- return 80;
935
- }
936
- return Math.min(100, Math.max(0, Math.round(value)));
937
- }
938
1160
  function collectDrift(exportsList) {
939
1161
  const drifts = [];
940
1162
  for (const entry of exportsList) {
@@ -957,11 +1179,14 @@ function collectDrift(exportsList) {
957
1179
 
958
1180
  // src/commands/diff.ts
959
1181
  import * as fs3 from "node:fs";
960
- import * as path4 from "node:path";
1182
+ import * as path5 from "node:path";
961
1183
  import {
962
1184
  diffSpecWithDocs,
1185
+ ensureSpecCoverage,
1186
+ getDiffReportPath,
963
1187
  getDocsImpactSummary,
964
1188
  hasDocsImpact,
1189
+ hashString,
965
1190
  parseMarkdownFiles
966
1191
  } from "@doccov/sdk";
967
1192
  import chalk3 from "chalk";
@@ -1033,126 +1258,151 @@ var defaultDependencies2 = {
1033
1258
  log: console.log,
1034
1259
  error: console.error
1035
1260
  };
1036
- var VALID_STRICT_OPTIONS = [
1037
- "regression",
1038
- "drift",
1039
- "docs-impact",
1040
- "breaking",
1041
- "undocumented",
1042
- "all"
1043
- ];
1044
- function parseStrictOptions(value) {
1045
- if (!value)
1261
+ var STRICT_PRESETS = {
1262
+ ci: new Set(["breaking", "regression"]),
1263
+ release: new Set(["breaking", "regression", "drift", "docs-impact", "undocumented"]),
1264
+ quality: new Set(["drift", "undocumented"])
1265
+ };
1266
+ function getStrictChecks(preset) {
1267
+ if (!preset)
1046
1268
  return new Set;
1047
- const options = value.split(",").map((s) => s.trim().toLowerCase());
1048
- const result = new Set;
1049
- for (const opt of options) {
1050
- if (opt === "all") {
1051
- for (const o of VALID_STRICT_OPTIONS) {
1052
- if (o !== "all")
1053
- result.add(o);
1054
- }
1055
- } else if (VALID_STRICT_OPTIONS.includes(opt)) {
1056
- result.add(opt);
1057
- }
1269
+ const checks = STRICT_PRESETS[preset];
1270
+ if (!checks) {
1271
+ throw new Error(`Unknown --strict preset: ${preset}. Valid: ci, release, quality`);
1058
1272
  }
1059
- return result;
1273
+ return checks;
1060
1274
  }
1061
1275
  function registerDiffCommand(program, dependencies = {}) {
1062
1276
  const { readFileSync: readFileSync2, log, error } = {
1063
1277
  ...defaultDependencies2,
1064
1278
  ...dependencies
1065
1279
  };
1066
- program.command("diff <base> <head>").description("Compare two OpenPkg specs and report coverage delta").option("--format <format>", "Output format: text, json, github, report", "text").option("--strict <options>", "Fail on conditions (comma-separated): regression, drift, docs-impact, breaking, undocumented, all").option("--docs <glob>", "Glob pattern for markdown docs to check for impact", collect, []).option("--ai", "Use AI for deeper analysis and fix suggestions").option("--output <format>", "DEPRECATED: Use --format instead").option("--fail-on-regression", "DEPRECATED: Use --strict regression").option("--fail-on-drift", "DEPRECATED: Use --strict drift").option("--fail-on-docs-impact", "DEPRECATED: Use --strict docs-impact").action(async (base, head, options) => {
1280
+ program.command("diff [base] [head]").description("Compare two OpenPkg specs and detect breaking changes").option("--base <file>", 'Base spec file (the "before" state)').option("--head <file>", 'Head spec file (the "after" state)').option("--format <format>", "Output format: text, json, markdown, html, github", "text").option("--stdout", "Output to stdout instead of writing to .doccov/").option("-o, --output <file>", "Custom output path").option("--cwd <dir>", "Working directory", process.cwd()).option("--limit <n>", "Max items to show in terminal/reports", "10").option("--min-coverage <n>", "Minimum coverage % for HEAD spec (0-100)").option("--max-drift <n>", "Maximum drift % for HEAD spec (0-100)").option("--strict <preset>", "Fail on conditions: ci, release, quality").option("--docs <glob>", "Glob pattern for markdown docs to check for impact", collect, []).option("--ai", "Use AI for deeper analysis and fix suggestions").option("--no-cache", "Bypass cache and force regeneration").action(async (baseArg, headArg, options) => {
1067
1281
  try {
1068
- const baseSpec = loadSpec(base, readFileSync2);
1069
- const headSpec = loadSpec(head, readFileSync2);
1070
- let markdownFiles;
1071
- let docsPatterns = options.docs;
1072
- if (!docsPatterns || docsPatterns.length === 0) {
1073
- const configResult = await loadDocCovConfig(process.cwd());
1074
- if (configResult?.docs?.include) {
1075
- docsPatterns = configResult.docs.include;
1076
- log(chalk3.gray(`Using docs patterns from config: ${docsPatterns.join(", ")}`));
1077
- }
1282
+ const baseFile = options.base ?? baseArg;
1283
+ const headFile = options.head ?? headArg;
1284
+ if (!baseFile || !headFile) {
1285
+ throw new Error(`Both base and head specs are required.
1286
+ ` + `Usage: doccov diff <base> <head>
1287
+ ` + " or: doccov diff --base main.json --head feature.json");
1078
1288
  }
1079
- if (docsPatterns && docsPatterns.length > 0) {
1080
- markdownFiles = await loadMarkdownFiles(docsPatterns);
1289
+ const baseSpec = loadSpec(baseFile, readFileSync2);
1290
+ const headSpec = loadSpec(headFile, readFileSync2);
1291
+ const config = await loadDocCovConfig(options.cwd);
1292
+ const baseHash = hashString(JSON.stringify(baseSpec));
1293
+ const headHash = hashString(JSON.stringify(headSpec));
1294
+ const cacheEnabled = options.cache !== false;
1295
+ const cachedReportPath = path5.resolve(options.cwd, getDiffReportPath(baseHash, headHash, "json"));
1296
+ let diff;
1297
+ let fromCache = false;
1298
+ if (cacheEnabled && fs3.existsSync(cachedReportPath)) {
1299
+ try {
1300
+ const cached = JSON.parse(fs3.readFileSync(cachedReportPath, "utf-8"));
1301
+ diff = cached;
1302
+ fromCache = true;
1303
+ } catch {
1304
+ diff = await generateDiff(baseSpec, headSpec, options, config, log);
1305
+ }
1306
+ } else {
1307
+ diff = await generateDiff(baseSpec, headSpec, options, config, log);
1081
1308
  }
1082
- const diff = diffSpecWithDocs(baseSpec, headSpec, { markdownFiles });
1083
- const format = options.format ?? options.output ?? "text";
1084
- const strictOptions = parseStrictOptions(options.strict);
1085
- if (options.failOnRegression)
1086
- strictOptions.add("regression");
1087
- if (options.failOnDrift)
1088
- strictOptions.add("drift");
1089
- if (options.failOnDocsImpact)
1090
- strictOptions.add("docs-impact");
1309
+ const minCoverage = resolveThreshold(options.minCoverage, config?.check?.minCoverage);
1310
+ const maxDrift = resolveThreshold(options.maxDrift, config?.check?.maxDrift);
1311
+ const format = options.format ?? "text";
1312
+ const limit = parseInt(options.limit, 10) || 10;
1313
+ const checks = getStrictChecks(options.strict);
1314
+ const baseName = path5.basename(baseFile);
1315
+ const headName = path5.basename(headFile);
1316
+ const reportData = {
1317
+ baseName,
1318
+ headName,
1319
+ ...diff
1320
+ };
1091
1321
  switch (format) {
1092
- case "json":
1093
- log(JSON.stringify(diff, null, 2));
1322
+ case "text":
1323
+ printSummary(diff, baseName, headName, fromCache, log);
1324
+ if (options.ai && diff.docsImpact && hasDocsImpact(diff)) {
1325
+ await printAISummary(diff, log);
1326
+ }
1327
+ if (!options.stdout) {
1328
+ const jsonPath = getDiffReportPath(baseHash, headHash, "json");
1329
+ if (!fromCache) {
1330
+ writeReport({
1331
+ format: "json",
1332
+ content: JSON.stringify(diff, null, 2),
1333
+ cwd: options.cwd,
1334
+ outputPath: jsonPath,
1335
+ silent: true
1336
+ });
1337
+ }
1338
+ const cacheNote = fromCache ? chalk3.cyan(" (cached)") : "";
1339
+ log(chalk3.dim(`Report: ${jsonPath}`) + cacheNote);
1340
+ }
1094
1341
  break;
1095
- case "github":
1096
- printGitHubAnnotations(diff, log);
1342
+ case "json": {
1343
+ const content = JSON.stringify(diff, null, 2);
1344
+ if (options.stdout) {
1345
+ log(content);
1346
+ } else {
1347
+ const outputPath = options.output ?? getDiffReportPath(baseHash, headHash, "json");
1348
+ writeReport({
1349
+ format: "json",
1350
+ content,
1351
+ outputPath,
1352
+ cwd: options.cwd
1353
+ });
1354
+ }
1097
1355
  break;
1098
- case "report":
1099
- log(generateHTMLReport(diff));
1356
+ }
1357
+ case "markdown": {
1358
+ const content = renderDiffMarkdown(reportData, { limit });
1359
+ if (options.stdout) {
1360
+ log(content);
1361
+ } else {
1362
+ const outputPath = options.output ?? getDiffReportPath(baseHash, headHash, "markdown");
1363
+ writeReport({
1364
+ format: "markdown",
1365
+ content,
1366
+ outputPath,
1367
+ cwd: options.cwd
1368
+ });
1369
+ }
1100
1370
  break;
1101
- default:
1102
- printTextDiff(diff, log, error);
1103
- if (options.ai && diff.docsImpact && hasDocsImpact(diff)) {
1104
- if (!isAIDocsAnalysisAvailable()) {
1105
- log(chalk3.yellow(`
1106
- AI analysis unavailable (set OPENAI_API_KEY or ANTHROPIC_API_KEY)`));
1107
- } else {
1108
- log(chalk3.gray(`
1109
- Generating AI summary...`));
1110
- const impacts = diff.docsImpact.impactedFiles.flatMap((f) => f.references.map((r) => ({
1111
- file: f.file,
1112
- exportName: r.exportName,
1113
- changeType: r.changeType,
1114
- context: r.context
1115
- })));
1116
- const summary = await generateImpactSummary(impacts);
1117
- if (summary) {
1118
- log("");
1119
- log(chalk3.bold("AI Summary"));
1120
- log(chalk3.cyan(` ${summary}`));
1121
- }
1122
- }
1371
+ }
1372
+ case "html": {
1373
+ const content = renderDiffHtml(reportData, { limit });
1374
+ if (options.stdout) {
1375
+ log(content);
1376
+ } else {
1377
+ const outputPath = options.output ?? getDiffReportPath(baseHash, headHash, "html");
1378
+ writeReport({
1379
+ format: "html",
1380
+ content,
1381
+ outputPath,
1382
+ cwd: options.cwd
1383
+ });
1123
1384
  }
1124
1385
  break;
1386
+ }
1387
+ case "github":
1388
+ printGitHubAnnotations(diff, log);
1389
+ break;
1125
1390
  }
1126
- if (strictOptions.has("regression") && diff.coverageDelta < 0) {
1127
- error(chalk3.red(`
1128
- Coverage regressed by ${Math.abs(diff.coverageDelta)}%`));
1129
- process.exitCode = 1;
1130
- return;
1131
- }
1132
- if (strictOptions.has("drift") && diff.driftIntroduced > 0) {
1133
- error(chalk3.red(`
1134
- ${diff.driftIntroduced} new drift issue(s) introduced`));
1135
- process.exitCode = 1;
1136
- return;
1137
- }
1138
- if (strictOptions.has("docs-impact") && hasDocsImpact(diff)) {
1139
- const summary = getDocsImpactSummary(diff);
1140
- error(chalk3.red(`
1141
- ${summary.totalIssues} docs issue(s) require attention`));
1142
- process.exitCode = 1;
1143
- return;
1144
- }
1145
- if (strictOptions.has("breaking") && diff.breaking.length > 0) {
1146
- error(chalk3.red(`
1147
- ${diff.breaking.length} breaking change(s) detected`));
1148
- process.exitCode = 1;
1149
- return;
1150
- }
1151
- if (strictOptions.has("undocumented") && diff.newUndocumented.length > 0) {
1152
- error(chalk3.red(`
1153
- ${diff.newUndocumented.length} new undocumented export(s)`));
1391
+ const failures = validateDiff(diff, headSpec, {
1392
+ minCoverage,
1393
+ maxDrift,
1394
+ checks
1395
+ });
1396
+ if (failures.length > 0) {
1397
+ log(chalk3.red(`
1398
+ ✗ Check failed`));
1399
+ for (const f of failures) {
1400
+ log(chalk3.red(` - ${f}`));
1401
+ }
1154
1402
  process.exitCode = 1;
1155
- return;
1403
+ } else if (options.strict || minCoverage !== undefined || maxDrift !== undefined) {
1404
+ log(chalk3.green(`
1405
+ ✓ All checks passed`));
1156
1406
  }
1157
1407
  } catch (commandError) {
1158
1408
  error(chalk3.red("Error:"), commandError instanceof Error ? commandError.message : commandError);
@@ -1176,193 +1426,117 @@ async function loadMarkdownFiles(patterns) {
1176
1426
  }
1177
1427
  return parseMarkdownFiles(files);
1178
1428
  }
1429
+ async function generateDiff(baseSpec, headSpec, options, config, log) {
1430
+ let markdownFiles;
1431
+ let docsPatterns = options.docs;
1432
+ if (!docsPatterns || docsPatterns.length === 0) {
1433
+ if (config?.docs?.include) {
1434
+ docsPatterns = config.docs.include;
1435
+ log(chalk3.gray(`Using docs patterns from config: ${docsPatterns.join(", ")}`));
1436
+ }
1437
+ }
1438
+ if (docsPatterns && docsPatterns.length > 0) {
1439
+ markdownFiles = await loadMarkdownFiles(docsPatterns);
1440
+ }
1441
+ return diffSpecWithDocs(baseSpec, headSpec, { markdownFiles });
1442
+ }
1179
1443
  function loadSpec(filePath, readFileSync2) {
1180
- const resolvedPath = path4.resolve(filePath);
1444
+ const resolvedPath = path5.resolve(filePath);
1181
1445
  if (!fs3.existsSync(resolvedPath)) {
1182
1446
  throw new Error(`File not found: ${filePath}`);
1183
1447
  }
1184
1448
  try {
1185
1449
  const content = readFileSync2(resolvedPath, "utf-8");
1186
- return JSON.parse(content);
1450
+ const spec = JSON.parse(content);
1451
+ return ensureSpecCoverage(spec);
1187
1452
  } catch (parseError) {
1188
1453
  throw new Error(`Failed to parse ${filePath}: ${parseError instanceof Error ? parseError.message : parseError}`);
1189
1454
  }
1190
1455
  }
1191
- function printTextDiff(diff, log, _error) {
1456
+ function printSummary(diff, baseName, headName, fromCache, log) {
1192
1457
  log("");
1193
- log(chalk3.bold("DocCov Diff Report"));
1458
+ const cacheIndicator = fromCache ? chalk3.cyan(" (cached)") : "";
1459
+ log(chalk3.bold(`Comparing: ${baseName} → ${headName}`) + cacheIndicator);
1194
1460
  log("─".repeat(40));
1195
- printCoverage(diff, log);
1196
- printAPIChanges(diff, log);
1197
- if (diff.docsImpact) {
1198
- printDocsRequiringUpdates(diff, log);
1199
- }
1200
1461
  log("");
1201
- }
1202
- function printCoverage(diff, log) {
1203
1462
  const coverageColor = diff.coverageDelta > 0 ? chalk3.green : diff.coverageDelta < 0 ? chalk3.red : chalk3.gray;
1204
- const coverageSymbol = diff.coverageDelta > 0 ? "" : diff.coverageDelta < 0 ? "" : "→";
1205
- const deltaStr = diff.coverageDelta > 0 ? `+${diff.coverageDelta}` : String(diff.coverageDelta);
1206
- log("");
1207
- log(chalk3.bold("Coverage"));
1208
- log(` ${diff.oldCoverage}% ${coverageSymbol} ${diff.newCoverage}% ${coverageColor(`(${deltaStr}%)`)}`);
1209
- }
1210
- function printAPIChanges(diff, log) {
1211
- const hasChanges = diff.breaking.length > 0 || diff.nonBreaking.length > 0 || diff.memberChanges && diff.memberChanges.length > 0;
1212
- if (!hasChanges)
1213
- return;
1214
- log("");
1215
- log(chalk3.bold("API Changes"));
1216
- const membersByClass = groupMemberChangesByClass(diff.memberChanges ?? []);
1217
- const classesWithMembers = new Set(membersByClass.keys());
1218
- for (const [className, changes] of membersByClass) {
1219
- const categorized = diff.categorizedBreaking?.find((c) => c.id === className);
1220
- const isHighSeverity = categorized?.severity === "high";
1221
- const label = isHighSeverity ? chalk3.red(" [BREAKING]") : chalk3.yellow(" [CHANGED]");
1222
- log(chalk3.cyan(` ${className}`) + label);
1223
- const removed = changes.filter((c) => c.changeType === "removed");
1224
- for (const mc of removed) {
1225
- const suggestion = mc.suggestion ? chalk3.gray(` → ${mc.suggestion}`) : "";
1226
- log(chalk3.red(` ✖ ${mc.memberName}()`) + suggestion);
1227
- }
1228
- const changed = changes.filter((c) => c.changeType === "signature-changed");
1229
- for (const mc of changed) {
1230
- log(chalk3.yellow(` ~ ${mc.memberName}() signature changed`));
1231
- if (mc.oldSignature && mc.newSignature && mc.oldSignature !== mc.newSignature) {
1232
- log(chalk3.gray(` was: ${mc.oldSignature}`));
1233
- log(chalk3.gray(` now: ${mc.newSignature}`));
1234
- }
1235
- }
1236
- const added = changes.filter((c) => c.changeType === "added");
1237
- if (added.length > 0) {
1238
- const addedNames = added.map((a) => `${a.memberName}()`).join(", ");
1239
- log(chalk3.green(` + ${addedNames}`));
1240
- }
1241
- }
1242
- const nonClassBreaking = (diff.categorizedBreaking ?? []).filter((c) => !classesWithMembers.has(c.id));
1243
- const typeChanges = nonClassBreaking.filter((c) => c.kind === "interface" || c.kind === "type" || c.kind === "enum");
1244
- const functionChanges = nonClassBreaking.filter((c) => c.kind === "function");
1245
- const otherChanges = nonClassBreaking.filter((c) => !["interface", "type", "enum", "function"].includes(c.kind));
1246
- if (functionChanges.length > 0) {
1247
- log("");
1248
- log(chalk3.red(` Function Changes (${functionChanges.length}):`));
1249
- for (const fc of functionChanges.slice(0, 3)) {
1250
- const reason = fc.reason === "removed" ? "removed" : "signature changed";
1251
- log(chalk3.red(` ✖ ${fc.name} (${reason})`));
1252
- }
1253
- if (functionChanges.length > 3) {
1254
- log(chalk3.gray(` ... and ${functionChanges.length - 3} more`));
1255
- }
1256
- }
1257
- if (typeChanges.length > 0) {
1258
- log("");
1259
- log(chalk3.yellow(` Type/Interface Changes (${typeChanges.length}):`));
1260
- const typeNames = typeChanges.slice(0, 5).map((t) => t.name);
1261
- log(chalk3.yellow(` ~ ${typeNames.join(", ")}${typeChanges.length > 5 ? ", ..." : ""}`));
1262
- }
1263
- if (otherChanges.length > 0) {
1264
- log("");
1265
- log(chalk3.gray(` Other Changes (${otherChanges.length}):`));
1266
- const otherNames = otherChanges.slice(0, 3).map((o) => o.name);
1267
- log(chalk3.gray(` ${otherNames.join(", ")}${otherChanges.length > 3 ? ", ..." : ""}`));
1268
- }
1269
- if (diff.nonBreaking.length > 0) {
1270
- const undocCount = diff.newUndocumented.length;
1271
- const undocSuffix = undocCount > 0 ? chalk3.yellow(` (${undocCount} undocumented)`) : "";
1272
- log("");
1273
- log(chalk3.green(` New Exports (${diff.nonBreaking.length})`) + undocSuffix);
1274
- const exportNames = diff.nonBreaking.slice(0, 3);
1275
- log(chalk3.green(` + ${exportNames.join(", ")}${diff.nonBreaking.length > 3 ? ", ..." : ""}`));
1463
+ const coverageSign = diff.coverageDelta > 0 ? "+" : "";
1464
+ log(` Coverage: ${diff.oldCoverage}% ${diff.newCoverage}% ${coverageColor(`(${coverageSign}${diff.coverageDelta}%)`)}`);
1465
+ const breakingCount = diff.breaking.length;
1466
+ const highSeverity = diff.categorizedBreaking?.filter((c) => c.severity === "high").length ?? 0;
1467
+ if (breakingCount > 0) {
1468
+ const severityNote = highSeverity > 0 ? chalk3.red(` (${highSeverity} high severity)`) : "";
1469
+ log(` Breaking: ${chalk3.red(breakingCount)} changes${severityNote}`);
1470
+ } else {
1471
+ log(` Breaking: ${chalk3.green("0")} changes`);
1472
+ }
1473
+ const newCount = diff.nonBreaking.length;
1474
+ const undocCount = diff.newUndocumented.length;
1475
+ if (newCount > 0) {
1476
+ const undocNote = undocCount > 0 ? chalk3.yellow(` (${undocCount} undocumented)`) : "";
1477
+ log(` New: ${chalk3.green(newCount)} exports${undocNote}`);
1276
1478
  }
1277
1479
  if (diff.driftIntroduced > 0 || diff.driftResolved > 0) {
1278
- log("");
1279
1480
  const parts = [];
1280
- if (diff.driftIntroduced > 0) {
1281
- parts.push(chalk3.red(`+${diff.driftIntroduced} drift`));
1282
- }
1283
- if (diff.driftResolved > 0) {
1284
- parts.push(chalk3.green(`-${diff.driftResolved} resolved`));
1285
- }
1286
- log(` Drift: ${parts.join(", ")}`);
1481
+ if (diff.driftIntroduced > 0)
1482
+ parts.push(chalk3.red(`+${diff.driftIntroduced}`));
1483
+ if (diff.driftResolved > 0)
1484
+ parts.push(chalk3.green(`-${diff.driftResolved}`));
1485
+ log(` Drift: ${parts.join(", ")}`);
1287
1486
  }
1487
+ log("");
1288
1488
  }
1289
- function printDocsRequiringUpdates(diff, log) {
1290
- if (!diff.docsImpact)
1489
+ async function printAISummary(diff, log) {
1490
+ if (!isAIDocsAnalysisAvailable()) {
1491
+ log(chalk3.yellow(`
1492
+ ⚠ AI analysis unavailable (set OPENAI_API_KEY or ANTHROPIC_API_KEY)`));
1291
1493
  return;
1292
- const { impactedFiles, missingDocs, stats } = diff.docsImpact;
1293
- log("");
1294
- log(chalk3.bold("Docs Requiring Updates"));
1295
- log(chalk3.gray(` Scanned ${stats.filesScanned} files, ${stats.codeBlocksFound} code blocks`));
1296
- if (impactedFiles.length === 0 && missingDocs.length === 0) {
1297
- log(chalk3.green(" ✓ No updates needed"));
1494
+ }
1495
+ if (!diff.docsImpact)
1298
1496
  return;
1497
+ log(chalk3.gray(`
1498
+ Generating AI summary...`));
1499
+ const impacts = diff.docsImpact.impactedFiles.flatMap((f) => f.references.map((r) => ({
1500
+ file: f.file,
1501
+ exportName: r.exportName,
1502
+ changeType: r.changeType,
1503
+ context: r.context
1504
+ })));
1505
+ const summary = await generateImpactSummary(impacts);
1506
+ if (summary) {
1507
+ log("");
1508
+ log(chalk3.bold("AI Summary"));
1509
+ log(chalk3.cyan(` ${summary}`));
1299
1510
  }
1300
- const sortedFiles = [...impactedFiles].sort((a, b) => b.references.length - a.references.length);
1301
- const actionableFiles = [];
1302
- const instantiationOnlyFiles = [];
1303
- for (const file of sortedFiles) {
1304
- const hasActionableRefs = file.references.some((r) => r.memberName && !r.isInstantiation || !r.memberName && !r.isInstantiation);
1305
- if (hasActionableRefs) {
1306
- actionableFiles.push(file);
1307
- } else {
1308
- instantiationOnlyFiles.push(file);
1309
- }
1511
+ }
1512
+ function validateDiff(diff, headSpec, options) {
1513
+ const { minCoverage, maxDrift, checks } = options;
1514
+ const failures = [];
1515
+ const headExportsWithDrift = new Set((headSpec.exports ?? []).filter((e) => e.docs?.drift?.length).map((e) => e.name)).size;
1516
+ const headDriftScore = headSpec.exports?.length ? Math.round(headExportsWithDrift / headSpec.exports.length * 100) : 0;
1517
+ if (minCoverage !== undefined && diff.newCoverage < minCoverage) {
1518
+ failures.push(`Coverage ${diff.newCoverage}% below minimum ${minCoverage}%`);
1310
1519
  }
1311
- for (const file of actionableFiles.slice(0, 6)) {
1312
- const filename = path4.basename(file.file);
1313
- const issueCount = file.references.length;
1314
- log("");
1315
- log(chalk3.yellow(` ${filename}`) + chalk3.gray(` (${issueCount} issue${issueCount > 1 ? "s" : ""})`));
1316
- const actionableRefs = file.references.filter((r) => !r.isInstantiation);
1317
- for (const ref of actionableRefs.slice(0, 4)) {
1318
- if (ref.memberName) {
1319
- const action = ref.changeType === "method-removed" ? "→" : "~";
1320
- const hint = ref.replacementSuggestion ?? (ref.changeType === "method-changed" ? "signature changed" : "removed");
1321
- log(chalk3.gray(` L${ref.line}: ${ref.memberName}() ${action} ${hint}`));
1322
- } else {
1323
- const action = ref.changeType === "removed" ? "→" : "~";
1324
- const hint = ref.changeType === "removed" ? "removed" : ref.changeType === "signature-changed" ? "signature changed" : "changed";
1325
- log(chalk3.gray(` L${ref.line}: ${ref.exportName} ${action} ${hint}`));
1326
- }
1327
- }
1328
- if (actionableRefs.length > 4) {
1329
- log(chalk3.gray(` ... and ${actionableRefs.length - 4} more`));
1330
- }
1520
+ if (maxDrift !== undefined && headDriftScore > maxDrift) {
1521
+ failures.push(`Drift ${headDriftScore}% exceeds maximum ${maxDrift}%`);
1331
1522
  }
1332
- if (actionableFiles.length > 6) {
1333
- log(chalk3.gray(` ... and ${actionableFiles.length - 6} more files with method changes`));
1523
+ if (checks.has("regression") && diff.coverageDelta < 0) {
1524
+ failures.push(`Coverage regressed by ${Math.abs(diff.coverageDelta)}%`);
1334
1525
  }
1335
- if (instantiationOnlyFiles.length > 0) {
1336
- log("");
1337
- const fileNames = instantiationOnlyFiles.slice(0, 4).map((f) => path4.basename(f.file));
1338
- const suffix = instantiationOnlyFiles.length > 4 ? ", ..." : "";
1339
- log(chalk3.gray(` ${instantiationOnlyFiles.length} file(s) with class instantiation to review:`));
1340
- log(chalk3.gray(` ${fileNames.join(", ")}${suffix}`));
1526
+ if (checks.has("breaking") && diff.breaking.length > 0) {
1527
+ failures.push(`${diff.breaking.length} breaking change(s)`);
1341
1528
  }
1342
- const { allUndocumented } = diff.docsImpact;
1343
- if (missingDocs.length > 0) {
1344
- log("");
1345
- log(chalk3.yellow(` New exports missing docs (${missingDocs.length}):`));
1346
- const names = missingDocs.slice(0, 4);
1347
- log(chalk3.gray(` ${names.join(", ")}${missingDocs.length > 4 ? ", ..." : ""}`));
1529
+ if (checks.has("drift") && diff.driftIntroduced > 0) {
1530
+ failures.push(`${diff.driftIntroduced} new drift issue(s)`);
1348
1531
  }
1349
- if (allUndocumented && allUndocumented.length > 0) {
1350
- const existingUndocumented = allUndocumented.filter((name) => !missingDocs.includes(name));
1351
- log("");
1352
- log(chalk3.gray(` Total undocumented exports: ${allUndocumented.length}/${stats.totalExports} (${Math.round((1 - allUndocumented.length / stats.totalExports) * 100)}% documented)`));
1353
- if (existingUndocumented.length > 0 && existingUndocumented.length <= 10) {
1354
- log(chalk3.gray(` ${existingUndocumented.slice(0, 6).join(", ")}${existingUndocumented.length > 6 ? ", ..." : ""}`));
1355
- }
1532
+ if (checks.has("undocumented") && diff.newUndocumented.length > 0) {
1533
+ failures.push(`${diff.newUndocumented.length} undocumented export(s)`);
1356
1534
  }
1357
- }
1358
- function groupMemberChangesByClass(memberChanges) {
1359
- const byClass = new Map;
1360
- for (const mc of memberChanges) {
1361
- const list = byClass.get(mc.className) ?? [];
1362
- list.push(mc);
1363
- byClass.set(mc.className, list);
1535
+ if (checks.has("docs-impact") && hasDocsImpact(diff)) {
1536
+ const summary = getDocsImpactSummary(diff);
1537
+ failures.push(`${summary.totalIssues} docs issue(s)`);
1364
1538
  }
1365
- return byClass;
1539
+ return failures;
1366
1540
  }
1367
1541
  function printGitHubAnnotations(diff, log) {
1368
1542
  if (diff.coverageDelta !== 0) {
@@ -1410,159 +1584,6 @@ function printGitHubAnnotations(diff, log) {
1410
1584
  log(`::warning title=Drift Detected::${diff.driftIntroduced} new drift issue(s) introduced`);
1411
1585
  }
1412
1586
  }
1413
- function generateHTMLReport(diff) {
1414
- const coverageClass = diff.coverageDelta > 0 ? "positive" : diff.coverageDelta < 0 ? "negative" : "neutral";
1415
- const coverageSign = diff.coverageDelta > 0 ? "+" : "";
1416
- let html = `<!DOCTYPE html>
1417
- <html lang="en">
1418
- <head>
1419
- <meta charset="UTF-8">
1420
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1421
- <title>DocCov Diff Report</title>
1422
- <style>
1423
- * { box-sizing: border-box; margin: 0; padding: 0; }
1424
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 2rem; line-height: 1.5; }
1425
- .container { max-width: 900px; margin: 0 auto; }
1426
- h1 { font-size: 1.5rem; margin-bottom: 1.5rem; color: #f0f6fc; }
1427
- h2 { font-size: 1.1rem; margin: 1.5rem 0 0.75rem; color: #f0f6fc; border-bottom: 1px solid #30363d; padding-bottom: 0.5rem; }
1428
- .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin-bottom: 1rem; }
1429
- .metric { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; }
1430
- .metric-label { color: #8b949e; }
1431
- .metric-value { font-weight: 600; }
1432
- .positive { color: #3fb950; }
1433
- .negative { color: #f85149; }
1434
- .neutral { color: #8b949e; }
1435
- .warning { color: #d29922; }
1436
- .badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 12px; font-size: 0.75rem; font-weight: 500; }
1437
- .badge-breaking { background: #f8514933; color: #f85149; }
1438
- .badge-changed { background: #d2992233; color: #d29922; }
1439
- .badge-added { background: #3fb95033; color: #3fb950; }
1440
- .file-item { padding: 0.5rem; margin: 0.25rem 0; background: #0d1117; border-radius: 4px; }
1441
- .file-name { font-family: monospace; font-size: 0.9rem; }
1442
- .ref-list { margin-top: 0.5rem; padding-left: 1rem; font-size: 0.85rem; color: #8b949e; }
1443
- .ref-item { margin: 0.25rem 0; }
1444
- ul { list-style: none; }
1445
- li { padding: 0.25rem 0; }
1446
- code { font-family: monospace; background: #0d1117; padding: 0.125rem 0.375rem; border-radius: 3px; font-size: 0.9rem; }
1447
- </style>
1448
- </head>
1449
- <body>
1450
- <div class="container">
1451
- <h1>\uD83D\uDCCA DocCov Diff Report</h1>
1452
-
1453
- <div class="card">
1454
- <div class="metric">
1455
- <span class="metric-label">Coverage</span>
1456
- <span class="metric-value ${coverageClass}">${diff.oldCoverage}% → ${diff.newCoverage}% (${coverageSign}${diff.coverageDelta}%)</span>
1457
- </div>
1458
- <div class="metric">
1459
- <span class="metric-label">Breaking Changes</span>
1460
- <span class="metric-value ${diff.breaking.length > 0 ? "negative" : "neutral"}">${diff.breaking.length}</span>
1461
- </div>
1462
- <div class="metric">
1463
- <span class="metric-label">New Exports</span>
1464
- <span class="metric-value positive">${diff.nonBreaking.length}</span>
1465
- </div>
1466
- <div class="metric">
1467
- <span class="metric-label">Undocumented</span>
1468
- <span class="metric-value ${diff.newUndocumented.length > 0 ? "warning" : "neutral"}">${diff.newUndocumented.length}</span>
1469
- </div>
1470
- </div>`;
1471
- if (diff.breaking.length > 0) {
1472
- html += `
1473
- <h2>Breaking Changes</h2>
1474
- <div class="card">
1475
- <ul>`;
1476
- for (const item of diff.categorizedBreaking ?? []) {
1477
- const badgeClass = item.severity === "high" ? "badge-breaking" : "badge-changed";
1478
- html += `
1479
- <li><code>${item.name}</code> <span class="badge ${badgeClass}">${item.reason}</span></li>`;
1480
- }
1481
- html += `
1482
- </ul>
1483
- </div>`;
1484
- }
1485
- if (diff.memberChanges && diff.memberChanges.length > 0) {
1486
- html += `
1487
- <h2>Member Changes</h2>
1488
- <div class="card">
1489
- <ul>`;
1490
- for (const mc of diff.memberChanges) {
1491
- const badgeClass = mc.changeType === "removed" ? "badge-breaking" : mc.changeType === "added" ? "badge-added" : "badge-changed";
1492
- const suggestion = mc.suggestion ? ` → ${mc.suggestion}` : "";
1493
- html += `
1494
- <li><code>${mc.className}.${mc.memberName}()</code> <span class="badge ${badgeClass}">${mc.changeType}</span>${suggestion}</li>`;
1495
- }
1496
- html += `
1497
- </ul>
1498
- </div>`;
1499
- }
1500
- if (diff.docsImpact && diff.docsImpact.impactedFiles.length > 0) {
1501
- html += `
1502
- <h2>Documentation Impact</h2>
1503
- <div class="card">`;
1504
- for (const file of diff.docsImpact.impactedFiles.slice(0, 10)) {
1505
- const filename = path4.basename(file.file);
1506
- html += `
1507
- <div class="file-item">
1508
- <div class="file-name">\uD83D\uDCC4 ${filename} <span class="neutral">(${file.references.length} issue${file.references.length > 1 ? "s" : ""})</span></div>
1509
- <div class="ref-list">`;
1510
- for (const ref of file.references.slice(0, 5)) {
1511
- const name = ref.memberName ? `${ref.memberName}()` : ref.exportName;
1512
- const change = ref.changeType === "removed" || ref.changeType === "method-removed" ? "removed" : "signature changed";
1513
- html += `
1514
- <div class="ref-item">Line ${ref.line}: <code>${name}</code> ${change}</div>`;
1515
- }
1516
- if (file.references.length > 5) {
1517
- html += `
1518
- <div class="ref-item neutral">... and ${file.references.length - 5} more</div>`;
1519
- }
1520
- html += `
1521
- </div>
1522
- </div>`;
1523
- }
1524
- html += `
1525
- </div>`;
1526
- }
1527
- const hasNewUndocumented = diff.newUndocumented.length > 0;
1528
- const hasAllUndocumented = diff.docsImpact?.allUndocumented && diff.docsImpact.allUndocumented.length > 0;
1529
- if (hasNewUndocumented || hasAllUndocumented) {
1530
- html += `
1531
- <h2>Missing Documentation</h2>
1532
- <div class="card">`;
1533
- if (hasNewUndocumented) {
1534
- html += `
1535
- <p class="warning">New exports missing docs (${diff.newUndocumented.length}):</p>
1536
- <ul>`;
1537
- for (const name of diff.newUndocumented.slice(0, 10)) {
1538
- html += `
1539
- <li><code>${name}</code></li>`;
1540
- }
1541
- if (diff.newUndocumented.length > 10) {
1542
- html += `
1543
- <li class="neutral">... and ${diff.newUndocumented.length - 10} more</li>`;
1544
- }
1545
- html += `
1546
- </ul>`;
1547
- }
1548
- if (diff.docsImpact?.stats) {
1549
- const { stats, allUndocumented } = diff.docsImpact;
1550
- const docPercent = Math.round((1 - (allUndocumented?.length ?? 0) / stats.totalExports) * 100);
1551
- html += `
1552
- <div class="metric" style="margin-top: 1rem; border-top: 1px solid #30363d; padding-top: 1rem;">
1553
- <span class="metric-label">Total Documentation Coverage</span>
1554
- <span class="metric-value ${docPercent >= 80 ? "positive" : docPercent >= 50 ? "warning" : "negative"}">${stats.documentedExports}/${stats.totalExports} (${docPercent}%)</span>
1555
- </div>`;
1556
- }
1557
- html += `
1558
- </div>`;
1559
- }
1560
- html += `
1561
- </div>
1562
- </body>
1563
- </html>`;
1564
- return html;
1565
- }
1566
1587
 
1567
1588
  // src/commands/info.ts
1568
1589
  import { DocCov as DocCov2, enrichSpec as enrichSpec2, NodeFileSystem as NodeFileSystem2, resolveTarget as resolveTarget2 } from "@doccov/sdk";
@@ -1603,7 +1624,7 @@ function registerInfoCommand(program) {
1603
1624
 
1604
1625
  // src/commands/init.ts
1605
1626
  import * as fs4 from "node:fs";
1606
- import * as path5 from "node:path";
1627
+ import * as path6 from "node:path";
1607
1628
  import chalk5 from "chalk";
1608
1629
  var defaultDependencies3 = {
1609
1630
  fileExists: fs4.existsSync,
@@ -1618,7 +1639,7 @@ function registerInitCommand(program, dependencies = {}) {
1618
1639
  ...dependencies
1619
1640
  };
1620
1641
  program.command("init").description("Create a DocCov configuration file").option("--cwd <dir>", "Working directory", process.cwd()).option("--format <format>", "Config format: auto, mjs, js, cjs", "auto").action((options) => {
1621
- const cwd = path5.resolve(options.cwd);
1642
+ const cwd = path6.resolve(options.cwd);
1622
1643
  const formatOption = String(options.format ?? "auto").toLowerCase();
1623
1644
  if (!isValidFormat(formatOption)) {
1624
1645
  error(chalk5.red(`Invalid format "${formatOption}". Use auto, mjs, js, or cjs.`));
@@ -1627,7 +1648,7 @@ function registerInitCommand(program, dependencies = {}) {
1627
1648
  }
1628
1649
  const existing = findExistingConfig(cwd, fileExists2);
1629
1650
  if (existing) {
1630
- error(chalk5.red(`A DocCov config already exists at ${path5.relative(cwd, existing) || "./doccov.config.*"}.`));
1651
+ error(chalk5.red(`A DocCov config already exists at ${path6.relative(cwd, existing) || "./doccov.config.*"}.`));
1631
1652
  process.exitCode = 1;
1632
1653
  return;
1633
1654
  }
@@ -1637,7 +1658,7 @@ function registerInitCommand(program, dependencies = {}) {
1637
1658
  log(chalk5.yellow('Package is not marked as "type": "module"; creating doccov.config.js may require enabling ESM.'));
1638
1659
  }
1639
1660
  const fileName = `doccov.config.${targetFormat}`;
1640
- const outputPath = path5.join(cwd, fileName);
1661
+ const outputPath = path6.join(cwd, fileName);
1641
1662
  if (fileExists2(outputPath)) {
1642
1663
  error(chalk5.red(`Cannot create ${fileName}; file already exists.`));
1643
1664
  process.exitCode = 1;
@@ -1645,18 +1666,18 @@ function registerInitCommand(program, dependencies = {}) {
1645
1666
  }
1646
1667
  const template = buildTemplate(targetFormat);
1647
1668
  writeFileSync3(outputPath, template, { encoding: "utf8" });
1648
- log(chalk5.green(`✓ Created ${path5.relative(process.cwd(), outputPath)}`));
1669
+ log(chalk5.green(`✓ Created ${path6.relative(process.cwd(), outputPath)}`));
1649
1670
  });
1650
1671
  }
1651
1672
  var isValidFormat = (value) => {
1652
1673
  return value === "auto" || value === "mjs" || value === "js" || value === "cjs";
1653
1674
  };
1654
1675
  var findExistingConfig = (cwd, fileExists2) => {
1655
- let current = path5.resolve(cwd);
1656
- const { root } = path5.parse(current);
1676
+ let current = path6.resolve(cwd);
1677
+ const { root } = path6.parse(current);
1657
1678
  while (true) {
1658
1679
  for (const candidate of DOCCOV_CONFIG_FILENAMES) {
1659
- const candidatePath = path5.join(current, candidate);
1680
+ const candidatePath = path6.join(current, candidate);
1660
1681
  if (fileExists2(candidatePath)) {
1661
1682
  return candidatePath;
1662
1683
  }
@@ -1664,7 +1685,7 @@ var findExistingConfig = (cwd, fileExists2) => {
1664
1685
  if (current === root) {
1665
1686
  break;
1666
1687
  }
1667
- current = path5.dirname(current);
1688
+ current = path6.dirname(current);
1668
1689
  }
1669
1690
  return null;
1670
1691
  };
@@ -1686,17 +1707,17 @@ var detectPackageType = (cwd, fileExists2, readFileSync3) => {
1686
1707
  return;
1687
1708
  };
1688
1709
  var findNearestPackageJson = (cwd, fileExists2) => {
1689
- let current = path5.resolve(cwd);
1690
- const { root } = path5.parse(current);
1710
+ let current = path6.resolve(cwd);
1711
+ const { root } = path6.parse(current);
1691
1712
  while (true) {
1692
- const candidate = path5.join(current, "package.json");
1713
+ const candidate = path6.join(current, "package.json");
1693
1714
  if (fileExists2(candidate)) {
1694
1715
  return candidate;
1695
1716
  }
1696
1717
  if (current === root) {
1697
1718
  break;
1698
1719
  }
1699
- current = path5.dirname(current);
1720
+ current = path6.dirname(current);
1700
1721
  }
1701
1722
  return null;
1702
1723
  };
@@ -1758,7 +1779,7 @@ var buildTemplate = (format) => {
1758
1779
  import * as fs6 from "node:fs";
1759
1780
  import * as fsPromises from "node:fs/promises";
1760
1781
  import * as os from "node:os";
1761
- import * as path7 from "node:path";
1782
+ import * as path8 from "node:path";
1762
1783
  import {
1763
1784
  buildCloneUrl,
1764
1785
  buildDisplayUrl,
@@ -1783,7 +1804,7 @@ import { simpleGit } from "simple-git";
1783
1804
 
1784
1805
  // src/utils/llm-build-plan.ts
1785
1806
  import * as fs5 from "node:fs";
1786
- import * as path6 from "node:path";
1807
+ import * as path7 from "node:path";
1787
1808
  import { createAnthropic as createAnthropic3 } from "@ai-sdk/anthropic";
1788
1809
  import { createOpenAI as createOpenAI3 } from "@ai-sdk/openai";
1789
1810
  import { generateObject as generateObject3 } from "ai";
@@ -1819,7 +1840,7 @@ function getModel3() {
1819
1840
  async function gatherContextFiles(repoDir) {
1820
1841
  const sections = [];
1821
1842
  for (const fileName of CONTEXT_FILES) {
1822
- const filePath = path6.join(repoDir, fileName);
1843
+ const filePath = path7.join(repoDir, fileName);
1823
1844
  if (fs5.existsSync(filePath)) {
1824
1845
  try {
1825
1846
  let content = fs5.readFileSync(filePath, "utf-8");
@@ -1894,7 +1915,7 @@ function registerScanCommand(program, dependencies = {}) {
1894
1915
  log(chalk6.bold(`Scanning ${displayUrl}`));
1895
1916
  log(chalk6.gray(`Branch/tag: ${parsed.ref}`));
1896
1917
  log("");
1897
- tempDir = path7.join(os.tmpdir(), `doccov-scan-${Date.now()}-${Math.random().toString(36).slice(2)}`);
1918
+ tempDir = path8.join(os.tmpdir(), `doccov-scan-${Date.now()}-${Math.random().toString(36).slice(2)}`);
1898
1919
  fs6.mkdirSync(tempDir, { recursive: true });
1899
1920
  process.stdout.write(chalk6.cyan(`> Cloning ${parsed.owner}/${parsed.repo}...
1900
1921
  `));
@@ -2030,7 +2051,7 @@ function registerScanCommand(program, dependencies = {}) {
2030
2051
  error("");
2031
2052
  throw new Error(`Package not found: ${options.package}`);
2032
2053
  }
2033
- targetDir = path7.join(tempDir, pkg.path);
2054
+ targetDir = path8.join(tempDir, pkg.path);
2034
2055
  packageName = pkg.name;
2035
2056
  log(chalk6.gray(`Analyzing package: ${packageName}`));
2036
2057
  }
@@ -2079,7 +2100,7 @@ function registerScanCommand(program, dependencies = {}) {
2079
2100
  `));
2080
2101
  const llmEntry = await runLlmFallback("WASM project detected");
2081
2102
  if (llmEntry) {
2082
- entryPath = path7.join(targetDir, llmEntry);
2103
+ entryPath = path8.join(targetDir, llmEntry);
2083
2104
  if (buildFailed) {
2084
2105
  process.stdout.write(chalk6.green(`✓ Entry point: ${llmEntry} (using pre-committed declarations)
2085
2106
  `));
@@ -2089,20 +2110,20 @@ function registerScanCommand(program, dependencies = {}) {
2089
2110
  `));
2090
2111
  }
2091
2112
  } else {
2092
- entryPath = path7.join(targetDir, entry.path);
2113
+ entryPath = path8.join(targetDir, entry.path);
2093
2114
  process.stdout.write(chalk6.green(`✓ Entry point: ${entry.path} (from ${entry.source})
2094
2115
  `));
2095
2116
  log(chalk6.yellow(" ⚠ WASM project detected but no API key - analysis may be limited"));
2096
2117
  }
2097
2118
  } else {
2098
- entryPath = path7.join(targetDir, entry.path);
2119
+ entryPath = path8.join(targetDir, entry.path);
2099
2120
  process.stdout.write(chalk6.green(`✓ Entry point: ${entry.path} (from ${entry.source})
2100
2121
  `));
2101
2122
  }
2102
2123
  } catch (entryError) {
2103
2124
  const llmEntry = await runLlmFallback("Heuristics failed");
2104
2125
  if (llmEntry) {
2105
- entryPath = path7.join(targetDir, llmEntry);
2126
+ entryPath = path8.join(targetDir, llmEntry);
2106
2127
  process.stdout.write(chalk6.green(`✓ Entry point: ${llmEntry} (from LLM fallback)
2107
2128
  `));
2108
2129
  } else {
@@ -2127,7 +2148,7 @@ function registerScanCommand(program, dependencies = {}) {
2127
2148
  }
2128
2149
  const spec = result.spec;
2129
2150
  if (options.saveSpec) {
2130
- const specPath = path7.resolve(process.cwd(), options.saveSpec);
2151
+ const specPath = path8.resolve(process.cwd(), options.saveSpec);
2131
2152
  fs6.writeFileSync(specPath, JSON.stringify(spec, null, 2));
2132
2153
  log(chalk6.green(`✓ Saved spec to ${options.saveSpec}`));
2133
2154
  }
@@ -2252,7 +2273,7 @@ function printTextResult(result, log) {
2252
2273
 
2253
2274
  // src/commands/spec.ts
2254
2275
  import * as fs7 from "node:fs";
2255
- import * as path8 from "node:path";
2276
+ import * as path9 from "node:path";
2256
2277
  import { DocCov as DocCov4, NodeFileSystem as NodeFileSystem4, resolveTarget as resolveTarget3 } from "@doccov/sdk";
2257
2278
  import { normalize, validateSpec } from "@openpkg-ts/spec";
2258
2279
  import chalk8 from "chalk";
@@ -2300,7 +2321,7 @@ function getArrayLength(value) {
2300
2321
  }
2301
2322
  function formatDiagnosticOutput(prefix, diagnostic, baseDir) {
2302
2323
  const location = diagnostic.location;
2303
- const relativePath = location?.file ? path8.relative(baseDir, location.file) || location.file : undefined;
2324
+ const relativePath = location?.file ? path9.relative(baseDir, location.file) || location.file : undefined;
2304
2325
  const locationText = location && relativePath ? chalk8.gray(`${relativePath}:${location.line ?? 1}:${location.column ?? 1}`) : null;
2305
2326
  const locationPrefix = locationText ? `${locationText} ` : "";
2306
2327
  return `${prefix} ${locationPrefix}${diagnostic.message}`;
@@ -2329,7 +2350,7 @@ function registerSpecCommand(program, dependencies = {}) {
2329
2350
  try {
2330
2351
  config = await loadDocCovConfig(targetDir);
2331
2352
  if (config?.filePath) {
2332
- log(chalk8.gray(`Loaded configuration from ${path8.relative(targetDir, config.filePath)}`));
2353
+ log(chalk8.gray(`Loaded configuration from ${path9.relative(targetDir, config.filePath)}`));
2333
2354
  }
2334
2355
  } catch (configError) {
2335
2356
  error(chalk8.red("Failed to load DocCov config:"), configError instanceof Error ? configError.message : configError);
@@ -2385,7 +2406,7 @@ function registerSpecCommand(program, dependencies = {}) {
2385
2406
  }
2386
2407
  process.exit(1);
2387
2408
  }
2388
- const outputPath = path8.resolve(process.cwd(), options.output);
2409
+ const outputPath = path9.resolve(process.cwd(), options.output);
2389
2410
  writeFileSync5(outputPath, JSON.stringify(normalized, null, 2));
2390
2411
  log(chalk8.green(`> Wrote ${options.output}`));
2391
2412
  log(chalk8.gray(` ${getArrayLength(normalized.exports)} exports`));
@@ -2407,8 +2428,8 @@ function registerSpecCommand(program, dependencies = {}) {
2407
2428
 
2408
2429
  // src/cli.ts
2409
2430
  var __filename2 = fileURLToPath(import.meta.url);
2410
- var __dirname2 = path9.dirname(__filename2);
2411
- var packageJson = JSON.parse(readFileSync4(path9.join(__dirname2, "../package.json"), "utf-8"));
2431
+ var __dirname2 = path10.dirname(__filename2);
2432
+ var packageJson = JSON.parse(readFileSync4(path10.join(__dirname2, "../package.json"), "utf-8"));
2412
2433
  var program = new Command;
2413
2434
  program.name("doccov").description("DocCov - Documentation coverage and drift detection for TypeScript").version(packageJson.version);
2414
2435
  registerCheckCommand(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doccov/cli",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
4
4
  "description": "DocCov CLI - Documentation coverage and drift detection for TypeScript",
5
5
  "keywords": [
6
6
  "typescript",
@@ -48,7 +48,7 @@
48
48
  "dependencies": {
49
49
  "@ai-sdk/anthropic": "^1.0.0",
50
50
  "@ai-sdk/openai": "^1.0.0",
51
- "@doccov/sdk": "^0.10.0",
51
+ "@doccov/sdk": "^0.12.0",
52
52
  "@inquirer/prompts": "^7.8.0",
53
53
  "@openpkg-ts/spec": "^0.8.0",
54
54
  "ai": "^4.0.0",