The doc-sniffing dog
1import { bold, cyan, gray, green, red, yellow } from "@std/fmt/colors";
2import { join, resolve } from "@std/path";
3import { analyzeDirectory } from "./core.ts";
4
5interface WorkspaceConfig {
6 workspace?: string[];
7 imports?: Record<string, string>;
8}
9
10interface WorkspaceMemberStats {
11 name: string;
12 path: string;
13 hasDenoJson: boolean;
14 hasExports: boolean;
15 exportPath?: string;
16 stats: {
17 total: number;
18 documented: number;
19 undocumented: number;
20 percentage: number;
21 };
22 byType?: Record<string, { total: number; documented: number }>;
23}
24
25interface WorkspaceReport {
26 members: WorkspaceMemberStats[];
27 aggregate: {
28 totalMembers: number;
29 totalExports: number;
30 totalDocumented: number;
31 totalUndocumented: number;
32 averagePercentage: number;
33 };
34}
35
36/**
37 * Analyzer for Deno workspace configurations.
38 *
39 * The WorkspaceAnalyzer helps Doggo analyze multi-package repositories
40 * by examining each workspace member individually and providing both
41 * individual and aggregate documentation coverage statistics.
42 *
43 * @example
44 * ```typescript
45 * import { WorkspaceAnalyzer } from "@doggo/cli";
46 *
47 * const analyzer = new WorkspaceAnalyzer("./my-workspace");
48 * const report = await analyzer.analyze();
49 *
50 * if (report) {
51 * console.log(`Total coverage: ${report.aggregate.averagePercentage}%`);
52 * console.log(`Pack members: ${report.members.length}`);
53 * }
54 * ```
55 */
56export class WorkspaceAnalyzer {
57 private rootPath: string;
58 private workspaceConfig: WorkspaceConfig | null = null;
59
60 constructor(rootPath: string = ".") {
61 this.rootPath = resolve(rootPath);
62 }
63
64 async analyze(): Promise<WorkspaceReport | null> {
65 // Load workspace configuration
66 this.workspaceConfig = await this.loadWorkspaceConfig();
67
68 if (!this.workspaceConfig?.workspace) {
69 return null;
70 }
71
72 console.log(cyan(bold("\n🏢 Doggo is analyzing the pack!\n")));
73 console.log(gray(`Root: ${this.rootPath}`));
74 console.log(
75 gray(`Pack members: ${this.workspaceConfig.workspace.length}\n`),
76 );
77
78 const members: WorkspaceMemberStats[] = [];
79
80 // Analyze each workspace member
81 for (const memberPath of this.workspaceConfig.workspace) {
82 const fullPath = join(this.rootPath, memberPath);
83 console.log(cyan(`\n🐕 Sniffing ${bold(memberPath)}...`));
84
85 try {
86 const stats = await this.analyzeMember(fullPath, memberPath);
87 members.push(stats);
88
89 // Output brief summary for this member
90 this.outputMemberSummary(stats);
91 } catch (error) {
92 console.error(red(` ✗ Doggo couldn't sniff ${memberPath}: ${error}`));
93 // Add failed member with zero stats
94 members.push({
95 name: memberPath,
96 path: fullPath,
97 hasDenoJson: false,
98 hasExports: false,
99 stats: {
100 total: 0,
101 documented: 0,
102 undocumented: 0,
103 percentage: 0,
104 },
105 });
106 }
107 }
108
109 // Calculate aggregate statistics
110 const aggregate = this.calculateAggregate(members);
111 const report: WorkspaceReport = { members, aggregate };
112
113 // Output workspace summary
114 this.outputWorkspaceSummary(report);
115
116 return report;
117 }
118
119 private async loadWorkspaceConfig(): Promise<WorkspaceConfig | null> {
120 // Try deno.json first
121 const denoJsonPath = join(this.rootPath, "deno.json");
122 try {
123 const content = await Deno.readTextFile(denoJsonPath);
124 const config = JSON.parse(content) as WorkspaceConfig;
125 if (config.workspace) {
126 console.log(gray(`Found workspace configuration in deno.json`));
127 return config;
128 }
129 } catch {
130 // Not found or parse error
131 }
132
133 // Try deno.jsonc
134 const denoJsoncPath = join(this.rootPath, "deno.jsonc");
135 try {
136 const content = await Deno.readTextFile(denoJsoncPath);
137 // Simple JSONC parsing - remove comments
138 const jsonContent = content
139 .split("\n")
140 .map((line) => {
141 const commentIndex = line.indexOf("//");
142 return commentIndex > -1 ? line.slice(0, commentIndex) : line;
143 })
144 .join("\n")
145 .replace(/\/\*[\s\S]*?\*\//g, "");
146 const config = JSON.parse(jsonContent) as WorkspaceConfig;
147 if (config.workspace) {
148 console.log(gray(`Found workspace configuration in deno.jsonc`));
149 return config;
150 }
151 } catch {
152 // Not found or parse error
153 }
154
155 return null;
156 }
157
158 private async analyzeMember(
159 memberPath: string,
160 memberName: string,
161 ): Promise<WorkspaceMemberStats> {
162 // Use the core module to analyze the member
163 const result = await analyzeDirectory(memberPath);
164
165 return {
166 name: memberName,
167 path: memberPath,
168 hasDenoJson: result.hasDenoJson,
169 hasExports: result.hasExports,
170 exportPath: result.exportPath,
171 stats: {
172 total: result.stats.total,
173 documented: result.stats.documented,
174 undocumented: result.stats.undocumented,
175 percentage: result.stats.percentage,
176 },
177 byType: result.stats.byType,
178 };
179 }
180
181 private outputMemberSummary(stats: WorkspaceMemberStats): void {
182 const { name, hasExports, exportPath, stats: s } = stats;
183
184 const percentageColor = s.percentage >= 80
185 ? green
186 : s.percentage >= 60
187 ? yellow
188 : red;
189
190 const indicator = s.percentage >= 80
191 ? "✓"
192 : s.percentage >= 60
193 ? "⚡"
194 : "✗";
195 const indicatorColor = s.percentage >= 80
196 ? green
197 : s.percentage >= 60
198 ? yellow
199 : red;
200
201 console.log(
202 ` ${indicatorColor(indicator)} ${name.padEnd(20)} ${
203 s.total.toString().padStart(3)
204 } exports, ${percentageColor(bold(`${s.percentage}%`))} documented`,
205 );
206
207 if (hasExports && exportPath) {
208 console.log(gray(` └─ Entry: ${exportPath}`));
209 }
210 }
211
212 private calculateAggregate(
213 members: WorkspaceMemberStats[],
214 ): WorkspaceReport["aggregate"] {
215 const totalMembers = members.length;
216 const totalExports = members.reduce((sum, m) => sum + m.stats.total, 0);
217 const totalDocumented = members.reduce(
218 (sum, m) => sum + m.stats.documented,
219 0,
220 );
221 const totalUndocumented = members.reduce(
222 (sum, m) => sum + m.stats.undocumented,
223 0,
224 );
225
226 // Calculate weighted average percentage
227 const averagePercentage = totalExports > 0
228 ? Math.round((totalDocumented / totalExports) * 100)
229 : 0;
230
231 return {
232 totalMembers,
233 totalExports,
234 totalDocumented,
235 totalUndocumented,
236 averagePercentage,
237 };
238 }
239
240 private outputWorkspaceSummary(report: WorkspaceReport): void {
241 const { members, aggregate } = report;
242
243 console.log("\n" + cyan(bold("📊 Pack Summary")));
244 console.log(gray("─".repeat(60)));
245
246 // Overall stats
247 console.log(`\n ${bold("Pack Statistics:")}`);
248 console.log(
249 ` Pack Members: ${bold(aggregate.totalMembers.toString())}`,
250 );
251 console.log(
252 ` Total Exports: ${bold(aggregate.totalExports.toString())}`,
253 );
254 console.log(
255 ` Documented: ${green(aggregate.totalDocumented.toString())}`,
256 );
257 console.log(
258 ` Undocumented: ${red(aggregate.totalUndocumented.toString())}`,
259 );
260
261 const percentageColor = aggregate.averagePercentage >= 80
262 ? green
263 : aggregate.averagePercentage >= 60
264 ? yellow
265 : red;
266 console.log(
267 ` Coverage: ${
268 percentageColor(bold(`${aggregate.averagePercentage}%`))
269 }`,
270 );
271
272 // Member breakdown table
273 console.log(`\n ${bold("Member Breakdown:")}\n`);
274 console.log(gray(" " + "─".repeat(56)));
275 console.log(
276 gray(" │") + " Member".padEnd(20) + gray("│") + " Exports " +
277 gray("│") + " Documented " + gray("│") + " Coverage " + gray("│"),
278 );
279 console.log(gray(" " + "─".repeat(56)));
280
281 // Sort members by coverage percentage
282 const sortedMembers = [...members].sort((a, b) =>
283 b.stats.percentage - a.stats.percentage
284 );
285
286 for (const member of sortedMembers) {
287 const percentageColor = member.stats.percentage >= 80
288 ? green
289 : member.stats.percentage >= 60
290 ? yellow
291 : red;
292
293 const name = member.name.length > 18
294 ? member.name.substring(0, 15) + "..."
295 : member.name;
296
297 console.log(
298 gray(" │") +
299 ` ${name.padEnd(18)} ` +
300 gray("│") +
301 ` ${member.stats.total.toString().padStart(7)} ` +
302 gray("│") +
303 ` ${member.stats.documented.toString().padStart(10)} ` +
304 gray("│") +
305 ` ${
306 percentageColor(
307 member.stats.percentage.toString().padStart(7) + "%",
308 )
309 } ` +
310 gray("│"),
311 );
312 }
313 console.log(gray(" " + "─".repeat(56)));
314
315 // Identify members needing attention
316 const needsWork = members.filter((m) => m.stats.percentage < 60);
317 const goodCoverage = members.filter((m) => m.stats.percentage >= 80);
318
319 if (needsWork.length > 0) {
320 console.log(`\n ${red("⚠️ Pack members needing training:")}`);
321 for (const member of needsWork) {
322 console.log(` - ${member.name} (${member.stats.percentage}%)`);
323 }
324 }
325
326 if (goodCoverage.length > 0) {
327 console.log(`\n ${green("✨ Kibble-worthy pups:")}`);
328 for (const member of goodCoverage) {
329 if (member.stats.percentage === 100) {
330 console.log(` - ${member.name} ${green("(best in show!)")}`);
331 } else {
332 console.log(` - ${member.name} (${member.stats.percentage}%)`);
333 }
334 }
335 }
336
337 // Final indicator
338 const indicator = this.getWorkspaceIndicator(aggregate.averagePercentage);
339 console.log(`\n ${indicator}\n`);
340 }
341
342 private getWorkspaceIndicator(percentage: number): string {
343 if (percentage === 100) {
344 return green("🏆 Perfect! The whole pack is doing zoomies!");
345 } else if (percentage >= 90) {
346 return green("✨ Excellent! The pack's tails are wagging!");
347 } else if (percentage >= 80) {
348 return green("👍 Good pack! Happy woofs all around");
349 } else if (percentage >= 60) {
350 return yellow("📈 The pack needs more training (and treats)");
351 } else if (percentage >= 40) {
352 return red("⚠️ Poor pack coverage (Collective sad puppy eyes)");
353 } else {
354 return red("🚨 Critical! The whole pack is hiding");
355 }
356 }
357}
358
359// CLI entry point
360async function main() {
361 const analyzer = new WorkspaceAnalyzer();
362 const report = await analyzer.analyze();
363
364 if (!report) {
365 console.log(yellow("\n⚠️ Woof! No pack configuration found."));
366 console.log(
367 gray(
368 "Doggo was looking for 'workspace' field in deno.json or deno.jsonc\n",
369 ),
370 );
371 Deno.exit(1);
372 }
373}
374
375if (import.meta.main) {
376 await main();
377}