The doc-sniffing dog

DOGGO 🐶

knotbin.com 97ca149b c68b9acc

verified
+332
README.md
···
···
+
# 🐶 Doggo
+
+
> A loyal companion for your documentation journey! Doggo sniffs out undocumented exports in your Deno packages and helps you achieve 100% JSDoc coverage.
+
+
```
+
__
+
(___()'`;
+
/,___ /`
+
\\" \\ "Woof! Let's document that code!"
+
```
+
+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
+
[![Deno](https://img.shields.io/badge/Deno-1.40+-blue.svg)](https://deno.land)
+
+
## 🦴 What is Doggo?
+
+
Doggo is a good boy who helps you maintain documentation quality in your Deno projects! This CLI tool analyzes JSDoc documentation coverage of exported/public symbols in Deno packages, making sure every export has proper documentation - because well-documented code is a treat for everyone! 🍖
+
+
### 🐾 Key Features
+
+
- 🔍 **Sniffs Out Exports** - Automatically detects all exported symbols (functions, classes, interfaces, types, constants, variables, enums)
+
- 📝 **JSDoc Tracking** - Checks which exports have been properly documented with JSDoc
+
- 🎯 **Smart Fetch** - When a `deno.json` with `exports` field is found, analyzes only the actual public API (no chasing squirrels!)
+
- 🏢 **Pack Support** - Analyzes all members in a Deno workspace with aggregate statistics
+
- 📊 **Detailed Reports** - Get coverage percentages overall and by symbol type
+
- 🎨 **Pretty Output** - Color-coded terminal output that's easy on the eyes
+
- ⚡ **Fast & Lightweight** - Built with Deno's standard library (no heavy dependencies to slow us down!)
+
+
## 🚀 Quick Start
+
+
### Fetch the ball... I mean, analyze your code!
+
+
```bash
+
# Let Doggo analyze the current directory
+
deno run --allow-read https://deno.land/x/doggo/main.ts
+
+
# Point Doggo to a specific directory
+
deno run --allow-read https://deno.land/x/doggo/main.ts ./src
+
+
# Analyze a single file
+
deno run --allow-read https://deno.land/x/doggo/main.ts ./src/module.ts
+
```
+
+
### 🏠 Train Doggo (Install)
+
+
```bash
+
# Install Doggo as a good boy on your system
+
deno install --allow-read -n doggo https://deno.land/x/doggo/main.ts
+
+
# Now you can call Doggo anytime!
+
doggo
+
doggo ./src
+
doggo --help
+
```
+
+
### 🎾 Workspace Mode
+
+
```bash
+
# Doggo automatically detects workspace configuration
+
doggo
+
+
# Force workspace mode
+
doggo --workspace
+
```
+
+
### 🦴 Using with Deno Tasks
+
+
Add to your `deno.json`:
+
```json
+
{
+
"tasks": {
+
"doc:check": "doggo",
+
"doc:check:workspace": "doggo --workspace"
+
}
+
}
+
```
+
+
Then run:
+
```bash
+
deno task doc:check
+
```
+
+
## 🐕 Commands & Options
+
+
```
+
Doggo - JSDoc Coverage Analyzer for Good Boys and Girls
+
+
Usage:
+
doggo [options] [path]
+
+
Options:
+
-h, --help Show help (Doggo does tricks!)
+
-v, --version Show version (Doggo's age in dog years)
+
-p, --path Path to analyze (where should Doggo sniff?)
+
-w, --workspace Force workspace mode (analyze the whole pack!)
+
+
Examples:
+
doggo # Analyze current directory
+
doggo ./src # Analyze specific directory
+
doggo ./src/module.ts # Analyze single file
+
doggo --workspace # Analyze all workspace members
+
```
+
+
## 🎯 What Doggo Analyzes
+
+
### 🏢 Workspace Mode (The Pack)
+
When Doggo finds a `workspace` field in `deno.json`:
+
- Analyzes each pack member individually
+
- Provides per-member documentation statistics
+
- Shows aggregate statistics for the entire pack
+
- Barks at members that need documentation improvements
+
+
### 🎯 Smart Mode (Following the Scent)
+
When a `deno.json` or `deno.jsonc` file with an `exports` field is found:
+
- Traces the actual public API from the entry point(s)
+
- Only analyzes symbols that users actually see
+
- Ignores internal implementation (no digging in the backyard!)
+
- Provides accurate coverage for your public API
+
+
### 🔍 Full Analysis Mode (Sniffing Everything)
+
When no `deno.json` with exports is found:
+
- Analyzes all exported symbols in the codebase
+
- Comprehensive coverage reporting
+
- Great for internal packages or during development
+
+
### 📦 Supported Exports
+
+
Doggo can detect and analyze:
+
- **Functions** - Regular functions, async functions, arrow functions
+
- **Classes** - Class declarations with their methods
+
- **Interfaces** - TypeScript interface definitions
+
- **Types** - Type aliases and type definitions
+
- **Constants** - Exported const declarations
+
- **Variables** - Exported let/var declarations
+
- **Enums** - TypeScript enum definitions
+
- **Default Exports** - Default exported symbols
+
+
### 🚫 What Doggo Ignores
+
+
Good boys know not to dig in:
+
- `node_modules/` directory
+
- `.git/` directory
+
- `dist/`, `build/`, `coverage/` directories
+
- Test files (`*.test.*`, `*.spec.*`, `test/`, `tests/`, `*_test.*`)
+
+
## 🎨 Example Output
+
+
### Single Package (Good Boy Mode)
+
```
+
🐶 Analyzing Deno Package Documentation Coverage
+
+
Path: /path/to/your/package
+
+
Found deno.json with exports: ./mod.ts
+
+
Sniffing out public API from export entry points...
+
+
📝 Undocumented Exports (Doggo found these!):
+
+
src/utils.ts:
+
✗ formatDate [function]:67
+
+
mod.ts:
+
✗ LogLevel [type]:45
+
+
📊 Documentation Coverage Summary
+
+
──────────────────────────────────────────────────
+
Total Exports: 8
+
Documented: 6
+
Undocumented: 2
+
Coverage: 75%
+
──────────────────────────────────────────────────
+
+
📈 Documentation needs improvement (Doggo wants treats!)
+
```
+
+
### Workspace Analysis (Pack Report)
+
```
+
🏢 Analyzing Deno Workspace (The Pack!)
+
+
Root: /path/to/workspace
+
Members: 3
+
+
🐕 Analyzing common...
+
⚡ common 10 exports, 60% documented
+
└─ Entry: ./mod.ts
+
+
🐕 Analyzing bytes...
+
✗ bytes 14 exports, 50% documented
+
└─ Entry: ./mod.ts
+
+
📊 Pack Summary
+
────────────────────────────────────────────────────────────
+
Overall Statistics:
+
Total Members: 3
+
Total Exports: 37
+
Documented: 20
+
Coverage: 54%
+
+
⚠️ Pack members needing training:
+
- bytes (50%)
+
+
⚠️ The pack needs better documentation!
+
```
+
+
## 🏆 Coverage Indicators
+
+
Doggo's tail wags differently based on your coverage:
+
+
- 🏆 **100%** - Perfect! Doggo is doing zoomies!
+
- ✨ **90-99%** - Excellent! Tail wagging intensifies!
+
- 👍 **80-89%** - Good boy! Happy tail wags
+
- 📈 **60-79%** - Needs improvement (Doggo is concerned)
+
- ⚠️ **40-59%** - Poor coverage (Sad puppy eyes)
+
- 🚨 **0-39%** - Critical! (Doggo is hiding under the bed)
+
+
## 💡 JSDoc Examples
+
+
### ✅ Good Boy (Documented)
+
```typescript
+
/**
+
* Calculates the number of dog treats needed.
+
* @param dogs The number of dogs
+
* @param treatsPerDog Treats each dog should get
+
* @returns Total number of treats needed
+
*/
+
export function calculateTreats(dogs: number, treatsPerDog: number): number {
+
return dogs * treatsPerDog;
+
}
+
```
+
+
### ❌ Bad Boy (Needs Documentation)
+
```typescript
+
export function calculateTreats(dogs: number, treatsPerDog: number): number {
+
return dogs * treatsPerDog;
+
}
+
```
+
+
## 🔧 How Doggo Works
+
+
### When sniffing a workspace:
+
1. **Detect Pack** - Reads `deno.json` to find workspace members
+
2. **Analyze Each Member** - Individually checks each pack member
+
3. **Aggregate Results** - Combines statistics across all members
+
4. **Generate Report** - Shows both individual and pack-wide metrics
+
+
### When following an exports scent:
+
1. **Load Configuration** - Reads `deno.json` to find the `exports` field
+
2. **Trace Public API** - Follows all re-exports and direct exports
+
3. **Symbol Resolution** - Tracks `export { ... } from` statements
+
4. **JSDoc Detection** - Checks for JSDoc comments on source definitions
+
5. **Report Generation** - Shows only public API symbols
+
+
### When sniffing everything:
+
1. **File Discovery** - Recursively finds all source files
+
2. **Export Detection** - Identifies all exported symbols
+
3. **JSDoc Detection** - Checks for JSDoc comments above exports
+
4. **Statistics Calculation** - Computes coverage percentages
+
5. **Report Generation** - Outputs formatted report
+
+
## 🦴 Configuration
+
+
### Working with deno.json
+
+
Doggo automatically detects your configuration:
+
+
```json
+
{
+
"name": "@your-org/package",
+
"version": "1.0.0",
+
"exports": "./mod.ts"
+
}
+
```
+
+
When an `exports` field is present, Doggo only analyzes symbols exported through that entry point.
+
+
### Working with Workspaces
+
+
For multi-package repositories:
+
+
```json
+
{
+
"workspace": [
+
"packages/core",
+
"packages/utils",
+
"packages/cli"
+
]
+
}
+
```
+
+
Doggo will analyze each member independently and provide aggregate statistics.
+
+
## 🐾 Tips & Tricks
+
+
1. **Start with exports** - Use `deno.json` exports field to focus on your public API
+
2. **Document as you code** - It's easier to document while the code is fresh
+
3. **Use meaningful descriptions** - Help users understand not just what, but why
+
4. **Include examples** - Show how to use your functions
+
5. **Check regularly** - Add Doggo to your CI/CD pipeline
+
+
## 🤝 Contributing
+
+
Doggo loves new friends! Feel free to:
+
- Report bugs (Doggo doesn't like fleas!)
+
- Suggest new features (Teach Doggo new tricks!)
+
- Submit pull requests (Bring treats!)
+
- Improve documentation (Help Doggo communicate better!)
+
+
## 📄 License
+
+
MIT License - Doggo is free to roam and play!
+
+
## 🙏 Acknowledgments
+
+
- Built with ❤️ and 🦴 for the Deno community
+
- Inspired by good boys and girls everywhere
+
- Special thanks to all contributors who helped train Doggo
+
+
## 🐕 Why "Doggo"?
+
+
Because good dogs always document their code! Plus, who doesn't love a coding companion that:
+
- Never judges your code (only helps improve it)
+
- Is always excited to help
+
- Loyally guards your documentation quality
+
- Makes coding more fun!
+
+
---
+
+
*Woof! Happy documenting!* 🐶
+
+
**Remember:** A well-documented codebase is like a well-trained dog - everyone loves playing with it!
+866
core.ts
···
···
+
/**
+
* Core module for JSDoc coverage analysis
+
* @module
+
*/
+
+
import { walk } from "@std/fs";
+
import { dirname, join, relative, resolve } from "@std/path";
+
+
export interface ExportedSymbol {
+
name: string;
+
type:
+
| "function"
+
| "class"
+
| "interface"
+
| "type"
+
| "const"
+
| "variable"
+
| "enum";
+
file: string;
+
line: number;
+
hasJSDoc: boolean;
+
jsDocContent?: string;
+
exportType: "named" | "default";
+
}
+
+
export interface DocumentationStats {
+
total: number;
+
documented: number;
+
undocumented: number;
+
percentage: number;
+
byType: Record<string, { total: number; documented: number }>;
+
}
+
+
export interface AnalysisResult {
+
path: string;
+
hasDenoJson: boolean;
+
hasExports: boolean;
+
exportPath?: string;
+
symbols: ExportedSymbol[];
+
stats: DocumentationStats;
+
}
+
+
interface DenoConfig {
+
exports?: string | Record<string, string>;
+
name?: string;
+
version?: string;
+
}
+
+
interface TracedExport {
+
originalName: string;
+
exportedName: string;
+
sourcePath: string;
+
line: number;
+
}
+
+
/**
+
* Analyzes a directory or file for JSDoc coverage.
+
* @param targetPath The path to analyze
+
* @returns Analysis results including symbols and statistics
+
*/
+
export async function analyzeDirectory(
+
targetPath: string,
+
): Promise<AnalysisResult> {
+
const rootPath = resolve(targetPath);
+
const symbols: ExportedSymbol[] = [];
+
const analyzedFiles = new Set<string>();
+
const exportMap = new Map<string, TracedExport[]>();
+
+
// Check for deno.json
+
const denoConfig = await loadDenoConfig(rootPath);
+
const hasDenoJson = denoConfig !== null;
+
const hasExports = !!(denoConfig?.exports);
+
const exportPath = typeof denoConfig?.exports === "string"
+
? denoConfig.exports
+
: denoConfig?.exports
+
? Object.values(denoConfig.exports)[0]
+
: undefined;
+
+
if (denoConfig?.exports) {
+
// Use exports field as entry point
+
await analyzeFromExports(
+
rootPath,
+
denoConfig.exports,
+
symbols,
+
analyzedFiles,
+
exportMap,
+
);
+
} else {
+
// Fall back to analyzing all files
+
await analyzeAllFiles(rootPath, symbols);
+
}
+
+
// Calculate statistics
+
const stats = calculateStats(symbols);
+
+
return {
+
path: rootPath,
+
hasDenoJson,
+
hasExports,
+
exportPath,
+
symbols,
+
stats,
+
};
+
}
+
+
async function loadDenoConfig(rootPath: string): Promise<DenoConfig | null> {
+
// Check if rootPath is a file
+
const stat = await Deno.stat(rootPath);
+
if (stat.isFile) {
+
// If analyzing a single file, check parent directory for deno.json
+
rootPath = dirname(rootPath);
+
}
+
+
const configPath = join(rootPath, "deno.json");
+
try {
+
const configContent = await Deno.readTextFile(configPath);
+
const config = JSON.parse(configContent) as DenoConfig;
+
return config;
+
} catch {
+
// Try deno.jsonc
+
const configPathJsonc = join(rootPath, "deno.jsonc");
+
try {
+
const configContent = await Deno.readTextFile(configPathJsonc);
+
// Simple JSONC parsing - remove comments
+
const jsonContent = configContent
+
.split("\n")
+
.map((line) => {
+
const commentIndex = line.indexOf("//");
+
return commentIndex > -1 ? line.slice(0, commentIndex) : line;
+
})
+
.join("\n")
+
.replace(/\/\*[\s\S]*?\*\//g, "");
+
const config = JSON.parse(jsonContent) as DenoConfig;
+
return config;
+
} catch {
+
return null;
+
}
+
}
+
}
+
+
async function analyzeFromExports(
+
rootPath: string,
+
exports: string | Record<string, string>,
+
symbols: ExportedSymbol[],
+
analyzedFiles: Set<string>,
+
exportMap: Map<string, TracedExport[]>,
+
): Promise<void> {
+
// Handle string or object exports
+
const exportPaths: string[] = [];
+
+
if (typeof exports === "string") {
+
exportPaths.push(exports);
+
} else {
+
// For object exports, analyze all export paths
+
exportPaths.push(...Object.values(exports));
+
}
+
+
for (const exportPath of exportPaths) {
+
const fullPath = join(rootPath, exportPath);
+
await traceExportsFromFile(
+
fullPath,
+
analyzedFiles,
+
exportMap,
+
rootPath,
+
);
+
}
+
+
// Now analyze JSDoc for all traced symbols
+
for (const [_filePath, exports] of exportMap.entries()) {
+
await analyzeFileForJSDoc(exports, symbols, rootPath);
+
}
+
}
+
+
async function traceExportsFromFile(
+
filePath: string,
+
analyzedFiles: Set<string>,
+
exportMap: Map<string, TracedExport[]>,
+
rootPath: string,
+
): Promise<void> {
+
if (analyzedFiles.has(filePath)) {
+
return;
+
}
+
analyzedFiles.add(filePath);
+
+
try {
+
const content = await Deno.readTextFile(filePath);
+
const lines = content.split("\n");
+
const currentDir = dirname(filePath);
+
+
for (let i = 0; i < lines.length; i++) {
+
const line = lines[i];
+
const trimmed = line.trim();
+
+
// Handle re-exports: export * from "./module.ts" or export { ... } from "./module.ts" or export type { ... } from "./module.ts"
+
if (
+
trimmed.startsWith("export *") || trimmed.startsWith("export {") ||
+
trimmed.startsWith("export type {")
+
) {
+
const fromMatch = trimmed.match(/from\s+["']([^"']+)["']/);
+
if (fromMatch) {
+
// Re-export with from clause
+
const importPath = fromMatch[1];
+
const resolvedPath = await resolveImportPath(importPath, currentDir);
+
if (resolvedPath) {
+
// If it's export *, we need to find all exports from that file
+
if (trimmed.startsWith("export *")) {
+
// For export *, we need to recursively trace that file
+
await traceExportsFromFile(
+
resolvedPath,
+
analyzedFiles,
+
exportMap,
+
rootPath,
+
);
+
// Copy all exports from that file
+
const sourceExports = exportMap.get(resolvedPath) || [];
+
for (const exp of sourceExports) {
+
addExport(exportMap, filePath, {
+
...exp,
+
exportedName: exp.exportedName,
+
});
+
}
+
} else {
+
// Handle selective re-exports: export { a, b as c } from "./module" or export type { ... } from "./module"
+
const exportsMatch = trimmed.match(
+
/export\s*(?:type\s+)?{\s*([^}]+)\s*}/,
+
);
+
if (exportsMatch) {
+
const exportsList = exportsMatch[1].split(",").map((e) =>
+
e.trim()
+
);
+
+
// For selective exports, only track the specific symbols
+
for (const exportItem of exportsList) {
+
const [originalName, exportedName] = exportItem
+
.split(/\s+as\s+/)
+
.map((s) => s.trim());
+
+
// Find the line number in the source file where this symbol is defined
+
const lineNum = await findSymbolInFile(
+
resolvedPath,
+
originalName,
+
);
+
+
addExport(exportMap, filePath, {
+
originalName,
+
exportedName: exportedName || originalName,
+
sourcePath: resolvedPath,
+
line: lineNum || i + 1,
+
});
+
}
+
}
+
}
+
}
+
} else if (
+
trimmed.startsWith("export {") || trimmed.startsWith("export type {")
+
) {
+
// Export without from clause (e.g., export { foo, bar } or export type { foo })
+
// These are re-exports of previously imported symbols
+
const exportsMatch = trimmed.match(
+
/export\s*(?:type\s+)?{\s*([^}]+)\s*}/,
+
);
+
if (exportsMatch) {
+
const exportsList = exportsMatch[1].split(",").map((e) => e.trim());
+
+
for (const exportItem of exportsList) {
+
const [originalName, exportedName] = exportItem
+
.split(/\s+as\s+/)
+
.map((s) => s.trim());
+
+
// For import-then-export pattern, we need to find where these were imported from
+
const importSource = await findImportSource(
+
content,
+
originalName,
+
currentDir,
+
);
+
+
if (importSource) {
+
// Found the import source, trace that file
+
const lineNum = await findSymbolInFile(
+
importSource,
+
originalName,
+
);
+
+
addExport(exportMap, filePath, {
+
originalName,
+
exportedName: exportedName || originalName,
+
sourcePath: importSource,
+
line: lineNum || i + 1,
+
});
+
} else {
+
// Treat as a local export if we can't find the import
+
addExport(exportMap, filePath, {
+
originalName,
+
exportedName: exportedName || originalName,
+
sourcePath: filePath,
+
line: i + 1,
+
});
+
}
+
}
+
}
+
}
+
} // Handle direct exports in this file
+
else if (isDirectExport(trimmed)) {
+
const symbolName = extractExportName(trimmed);
+
if (symbolName) {
+
addExport(exportMap, filePath, {
+
originalName: symbolName,
+
exportedName: symbolName,
+
sourcePath: filePath,
+
line: i + 1,
+
});
+
}
+
}
+
}
+
} catch {
+
// Silently ignore errors for missing files
+
}
+
}
+
+
async function findImportSource(
+
fileContent: string,
+
symbolName: string,
+
currentDir: string,
+
): Promise<string | null> {
+
const lines = fileContent.split("\n");
+
+
for (const line of lines) {
+
const trimmed = line.trim();
+
+
// Look for import statements
+
if (trimmed.startsWith("import ")) {
+
// Check for named imports: import { symbolName } from "..."
+
const namedImportMatch = trimmed.match(
+
/import\s*{([^}]+)}\s*from\s*["']([^"']+)["']/,
+
);
+
+
if (namedImportMatch) {
+
const imports = namedImportMatch[1].split(",").map((i) => i.trim());
+
+
for (const imp of imports) {
+
const [imported, alias] = imp.split(/\s+as\s+/).map((s) => s.trim());
+
+
// Check if this import includes our symbol
+
if (imported === symbolName || alias === symbolName) {
+
const importPath = namedImportMatch[2];
+
return await resolveImportPath(importPath, currentDir);
+
}
+
}
+
}
+
+
// Check for default import: import symbolName from "..."
+
const defaultImportMatch = trimmed.match(
+
/import\s+(\w+)\s+from\s*["']([^"']+)["']/,
+
);
+
+
if (defaultImportMatch && defaultImportMatch[1] === symbolName) {
+
const importPath = defaultImportMatch[2];
+
return await resolveImportPath(importPath, currentDir);
+
}
+
+
// Check for namespace import: import * as symbolName from "..."
+
const namespaceImportMatch = trimmed.match(
+
/import\s*\*\s+as\s+(\w+)\s+from\s*["']([^"']+)["']/,
+
);
+
+
if (namespaceImportMatch && namespaceImportMatch[1] === symbolName) {
+
const importPath = namespaceImportMatch[2];
+
return await resolveImportPath(importPath, currentDir);
+
}
+
}
+
}
+
+
return null;
+
}
+
+
async function resolveImportPath(
+
importPath: string,
+
fromDir: string,
+
): Promise<string | null> {
+
// Handle relative imports
+
if (importPath.startsWith(".")) {
+
const basePath = join(fromDir, importPath);
+
+
// Try with common extensions
+
const extensions = [
+
".ts",
+
".tsx",
+
".js",
+
".jsx",
+
".mjs",
+
"/mod.ts",
+
"/index.ts",
+
];
+
+
// First try exact path
+
try {
+
await Deno.stat(basePath);
+
return resolve(basePath);
+
} catch {
+
// Try with extensions
+
for (const ext of extensions) {
+
try {
+
const fullPath = basePath.endsWith(".ts") || basePath.endsWith(".js")
+
? basePath
+
: basePath + ext;
+
await Deno.stat(fullPath);
+
return resolve(fullPath);
+
} catch {
+
continue;
+
}
+
}
+
}
+
}
+
+
return null;
+
}
+
+
async function findSymbolInFile(
+
filePath: string,
+
symbolName: string,
+
): Promise<number | null> {
+
try {
+
const content = await Deno.readTextFile(filePath);
+
const lines = content.split("\n");
+
+
for (let i = 0; i < lines.length; i++) {
+
const line = lines[i].trim();
+
+
// Check if this line exports the symbol we're looking for
+
if (isDirectExport(line)) {
+
const exportedName = extractExportName(line);
+
if (exportedName === symbolName) {
+
return i + 1;
+
}
+
}
+
}
+
} catch {
+
// Ignore errors
+
}
+
+
return null;
+
}
+
+
function isDirectExport(line: string): boolean {
+
return (
+
line.startsWith("export function") ||
+
line.startsWith("export async function") ||
+
line.startsWith("export class") ||
+
line.startsWith("export interface") ||
+
line.startsWith("export type") ||
+
line.startsWith("export enum") ||
+
line.startsWith("export const") ||
+
line.startsWith("export let") ||
+
line.startsWith("export var") ||
+
line.startsWith("export default")
+
);
+
}
+
+
function extractExportName(line: string): string | null {
+
// Extract the symbol name from various export patterns
+
const patterns = [
+
/export\s+(?:async\s+)?function\s+(\w+)/,
+
/export\s+class\s+(\w+)/,
+
/export\s+interface\s+(\w+)/,
+
/export\s+type\s+(\w+)/,
+
/export\s+enum\s+(\w+)/,
+
/export\s+(?:const|let|var)\s+(\w+)/,
+
];
+
+
for (const pattern of patterns) {
+
const match = line.match(pattern);
+
if (match) {
+
return match[1];
+
}
+
}
+
+
if (line.includes("export default")) {
+
return "default";
+
}
+
+
return null;
+
}
+
+
function addExport(
+
exportMap: Map<string, TracedExport[]>,
+
filePath: string,
+
exportInfo: TracedExport,
+
): void {
+
if (!exportMap.has(filePath)) {
+
exportMap.set(filePath, []);
+
}
+
+
// Avoid duplicates
+
const existing = exportMap.get(filePath)!;
+
const isDuplicate = existing.some(
+
(e) =>
+
e.exportedName === exportInfo.exportedName &&
+
e.sourcePath === exportInfo.sourcePath,
+
);
+
+
if (!isDuplicate) {
+
existing.push(exportInfo);
+
}
+
}
+
+
async function analyzeFileForJSDoc(
+
exports: TracedExport[],
+
symbols: ExportedSymbol[],
+
rootPath: string,
+
): Promise<void> {
+
// Group exports by their source file
+
const exportsBySource = new Map<string, TracedExport[]>();
+
for (const exp of exports) {
+
if (!exportsBySource.has(exp.sourcePath)) {
+
exportsBySource.set(exp.sourcePath, []);
+
}
+
exportsBySource.get(exp.sourcePath)!.push(exp);
+
}
+
+
// Analyze each source file
+
for (const [sourcePath, sourceExports] of exportsBySource.entries()) {
+
try {
+
const content = await Deno.readTextFile(sourcePath);
+
const lines = content.split("\n");
+
const relativePath = relative(rootPath, sourcePath);
+
+
// Track JSDoc blocks
+
const jsDocBlocks: Map<number, string> = new Map();
+
let currentJSDoc: string[] = [];
+
+
for (let i = 0; i < lines.length; i++) {
+
const line = lines[i];
+
const trimmed = line.trim();
+
+
// Track JSDoc blocks
+
if (trimmed.startsWith("/**")) {
+
currentJSDoc = [trimmed];
+
} else if (currentJSDoc.length > 0) {
+
currentJSDoc.push(line);
+
if (trimmed.endsWith("*/")) {
+
// JSDoc block complete, associate with next code line
+
const jsDocContent = currentJSDoc.join("\n");
+
// Skip module-level JSDoc (contains @module tag)
+
if (jsDocContent.includes("@module")) {
+
currentJSDoc = [];
+
continue;
+
}
+
// Find the next line that starts an export declaration
+
for (let j = i + 1; j < lines.length; j++) {
+
const nextLine = lines[j].trim();
+
if (nextLine && !nextLine.startsWith("//")) {
+
// Only associate JSDoc with export declarations
+
if (
+
isDirectExport(nextLine) || nextLine.startsWith("export ")
+
) {
+
jsDocBlocks.set(j, jsDocContent);
+
// Mark next 5 lines as having this JSDoc (for multi-line declarations)
+
for (let k = j + 1; k <= j + 5 && k < lines.length; k++) {
+
jsDocBlocks.set(k, jsDocContent);
+
}
+
}
+
break;
+
}
+
}
+
currentJSDoc = [];
+
}
+
}
+
+
// Check if this line starts an export declaration
+
if (isDirectExport(trimmed)) {
+
// For multi-line declarations, we need to extract the full declaration
+
let fullDeclaration = trimmed;
+
const declarationStartLine = i;
+
+
// If the line doesn't contain a complete function/class signature, gather more lines
+
if (!trimmed.includes("{") && !trimmed.includes(";")) {
+
for (let j = i + 1; j < lines.length && j < i + 10; j++) {
+
fullDeclaration += " " + lines[j].trim();
+
if (lines[j].includes("{") || lines[j].includes(";")) {
+
break;
+
}
+
}
+
}
+
+
const lineExportName = extractExportName(fullDeclaration);
+
if (lineExportName) {
+
// Find if we're tracking this specific export
+
for (const exp of sourceExports) {
+
if (
+
exp.originalName === lineExportName &&
+
exp.sourcePath === sourcePath
+
) {
+
const symbol = parseExportedSymbol(
+
fullDeclaration,
+
declarationStartLine,
+
relativePath,
+
jsDocBlocks,
+
);
+
if (symbol) {
+
// Use the exported name from our trace
+
symbol.name = exp.exportedName;
+
symbols.push(symbol);
+
}
+
break;
+
}
+
}
+
}
+
}
+
}
+
} catch {
+
// Silently ignore errors
+
}
+
}
+
}
+
+
async function analyzeAllFiles(
+
rootPath: string,
+
symbols: ExportedSymbol[],
+
): Promise<void> {
+
const files = await findSourceFiles(rootPath);
+
+
for (const file of files) {
+
await analyzeFile(file, symbols, rootPath);
+
}
+
}
+
+
async function findSourceFiles(rootPath: string): Promise<string[]> {
+
const files: string[] = [];
+
+
// Check if the path is a file or directory
+
const stat = await Deno.stat(rootPath);
+
+
if (stat.isFile) {
+
// If it's a single file, check if it's a source file
+
const validExts = [".ts", ".tsx", ".js", ".jsx", ".mjs"];
+
if (validExts.some((ext) => rootPath.endsWith(ext))) {
+
files.push(rootPath);
+
}
+
} else if (stat.isDirectory) {
+
// If it's a directory, walk through it
+
const entries = walk(rootPath, {
+
exts: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
+
skip: [
+
/node_modules/,
+
/\.git/,
+
/dist/,
+
/build/,
+
/coverage/,
+
/\.test\./,
+
/\.spec\./,
+
/test\//,
+
/tests\//,
+
/_test\./,
+
],
+
});
+
+
for await (const entry of entries) {
+
if (entry.isFile) {
+
files.push(entry.path);
+
}
+
}
+
}
+
+
return files;
+
}
+
+
async function analyzeFile(
+
filePath: string,
+
symbols: ExportedSymbol[],
+
rootPath: string,
+
): Promise<void> {
+
const content = await Deno.readTextFile(filePath);
+
const lines = content.split("\n");
+
+
// Handle both file and directory paths
+
const stat = await Deno.stat(rootPath);
+
const relativePath = stat.isFile
+
? relative(Deno.cwd(), filePath)
+
: relative(rootPath, filePath);
+
+
// Track JSDoc blocks
+
const jsDocBlocks: Map<number, string> = new Map();
+
let currentJSDoc: string[] = [];
+
+
for (let i = 0; i < lines.length; i++) {
+
const line = lines[i];
+
const trimmed = line.trim();
+
+
// Track JSDoc blocks
+
if (trimmed.startsWith("/**")) {
+
currentJSDoc = [trimmed];
+
} else if (currentJSDoc.length > 0) {
+
currentJSDoc.push(line);
+
if (trimmed.endsWith("*/")) {
+
// JSDoc block complete, associate with next code line
+
const jsDocContent = currentJSDoc.join("\n");
+
for (let j = i + 1; j < lines.length; j++) {
+
if (lines[j].trim() && !lines[j].trim().startsWith("//")) {
+
jsDocBlocks.set(j, jsDocContent);
+
break;
+
}
+
}
+
currentJSDoc = [];
+
}
+
}
+
+
// Check for exports
+
if (isExportLine(trimmed)) {
+
const symbol = parseExportedSymbol(
+
trimmed,
+
i,
+
relativePath,
+
jsDocBlocks,
+
);
+
if (symbol) {
+
symbols.push(symbol);
+
}
+
}
+
}
+
}
+
+
function isExportLine(line: string): boolean {
+
return (
+
line.startsWith("export ") ||
+
(line.includes("export {") && !line.includes("export type {")) ||
+
line.includes("export default")
+
);
+
}
+
+
function parseExportedSymbol(
+
line: string,
+
lineIndex: number,
+
filePath: string,
+
jsDocBlocks: Map<number, string>,
+
): ExportedSymbol | null {
+
const trimmed = line.trim();
+
let name = "";
+
let type: ExportedSymbol["type"] = "variable";
+
let exportType: "named" | "default" = "named";
+
+
// Check for JSDoc
+
const hasJSDoc = jsDocBlocks.has(lineIndex);
+
const jsDocContent = jsDocBlocks.get(lineIndex);
+
+
// Parse export default
+
if (trimmed.includes("export default")) {
+
exportType = "default";
+
+
if (trimmed.includes("function")) {
+
const match = trimmed.match(/function\s+(\w+)/);
+
name = match ? match[1] : "default";
+
type = "function";
+
} else if (trimmed.includes("class")) {
+
const match = trimmed.match(/class\s+(\w+)/);
+
name = match ? match[1] : "default";
+
type = "class";
+
} else {
+
name = "default";
+
type = "variable";
+
}
+
} // Parse export function
+
else if (
+
trimmed.startsWith("export function") ||
+
trimmed.startsWith("export async function")
+
) {
+
const match = trimmed.match(/function\s+(\w+)/);
+
if (match) {
+
name = match[1];
+
type = "function";
+
}
+
} // Parse export class
+
else if (trimmed.startsWith("export class")) {
+
const match = trimmed.match(/class\s+(\w+)/);
+
if (match) {
+
name = match[1];
+
type = "class";
+
}
+
} // Parse export interface
+
else if (trimmed.startsWith("export interface")) {
+
const match = trimmed.match(/interface\s+(\w+)/);
+
if (match) {
+
name = match[1];
+
type = "interface";
+
}
+
} // Parse export type
+
else if (trimmed.startsWith("export type")) {
+
const match = trimmed.match(/type\s+(\w+)/);
+
if (match) {
+
name = match[1];
+
type = "type";
+
}
+
} // Parse export enum
+
else if (trimmed.startsWith("export enum")) {
+
const match = trimmed.match(/enum\s+(\w+)/);
+
if (match) {
+
name = match[1];
+
type = "enum";
+
}
+
} // Parse export const/let/var
+
else if (
+
trimmed.startsWith("export const") ||
+
trimmed.startsWith("export let") ||
+
trimmed.startsWith("export var")
+
) {
+
const match = trimmed.match(/(?:const|let|var)\s+(\w+)/);
+
if (match) {
+
name = match[1];
+
type = trimmed.includes("const") ? "const" : "variable";
+
}
+
} // Parse export { ... }
+
else if (trimmed.includes("export {") && !trimmed.includes("from")) {
+
// Only handle direct export { ... } without from clause in this function
+
// Re-exports are handled elsewhere
+
const match = trimmed.match(/export\s*{\s*([^}]+)\s*}/);
+
if (match) {
+
const exports = match[1].split(",").map((e) => e.trim());
+
// For simplicity, we'll just track the first one
+
// In a real implementation, you'd want to handle all of them
+
if (exports.length > 0) {
+
name = exports[0].split(/\s+as\s+/)[0];
+
type = "variable"; // We'd need more context to determine the actual type
+
}
+
}
+
}
+
+
if (name) {
+
return {
+
name,
+
type,
+
file: filePath,
+
line: lineIndex + 1,
+
hasJSDoc,
+
jsDocContent,
+
exportType,
+
};
+
}
+
+
return null;
+
}
+
+
export function calculateStats(symbols: ExportedSymbol[]): DocumentationStats {
+
const stats: DocumentationStats = {
+
total: symbols.length,
+
documented: symbols.filter((s) => s.hasJSDoc).length,
+
undocumented: symbols.filter((s) => !s.hasJSDoc).length,
+
percentage: 0,
+
byType: {},
+
};
+
+
stats.percentage = stats.total > 0
+
? Math.round((stats.documented / stats.total) * 100)
+
: 100;
+
+
// Calculate stats by type
+
for (const symbol of symbols) {
+
if (!stats.byType[symbol.type]) {
+
stats.byType[symbol.type] = { total: 0, documented: 0 };
+
}
+
stats.byType[symbol.type].total++;
+
if (symbol.hasJSDoc) {
+
stats.byType[symbol.type].documented++;
+
}
+
}
+
+
return stats;
+
}
+11
deno.json
···
···
+
{
+
"name": "@knotbin/doggo",
+
"version": "0.1.0",
+
"exports": "./mod.ts",
+
"imports": {
+
"@std/cli": "jsr:@std/cli@^1.0.23",
+
"@std/fmt": "jsr:@std/fmt@^1.0.8",
+
"@std/fs": "jsr:@std/fs@^1.0.19",
+
"@std/path": "jsr:@std/path@^1.1.2"
+
}
+
}
+146
deno.lock
···
···
+
{
+
"version": "5",
+
"specifiers": {
+
"jsr:@std/cli@^1.0.23": "1.0.23",
+
"jsr:@std/fmt@^1.0.8": "1.0.8",
+
"jsr:@std/fs@^1.0.19": "1.0.19",
+
"jsr:@std/internal@^1.0.10": "1.0.12",
+
"jsr:@std/internal@^1.0.12": "1.0.12",
+
"jsr:@std/internal@^1.0.9": "1.0.12",
+
"jsr:@std/path@^1.1.1": "1.1.2",
+
"jsr:@std/path@^1.1.2": "1.1.2",
+
"npm:@types/node@*": "24.2.0"
+
},
+
"jsr": {
+
"@std/cli@1.0.23": {
+
"integrity": "bf95b7a9425ba2af1ae5a6359daf58c508f2decf711a76ed2993cd352498ccca",
+
"dependencies": [
+
"jsr:@std/internal@^1.0.12"
+
]
+
},
+
"@std/fmt@1.0.8": {
+
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
+
},
+
"@std/fs@1.0.19": {
+
"integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06",
+
"dependencies": [
+
"jsr:@std/internal@^1.0.9",
+
"jsr:@std/path@^1.1.1"
+
]
+
},
+
"@std/internal@1.0.12": {
+
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
+
},
+
"@std/path@1.1.2": {
+
"integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038",
+
"dependencies": [
+
"jsr:@std/internal@^1.0.10"
+
]
+
}
+
},
+
"npm": {
+
"@types/node@24.2.0": {
+
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
+
"dependencies": [
+
"undici-types"
+
]
+
},
+
"undici-types@7.10.0": {
+
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
+
}
+
},
+
"remote": {
+
"https://deno.land/std@0.208.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee",
+
"https://deno.land/std@0.208.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56",
+
"https://deno.land/std@0.208.0/cli/parse_args.ts": "9bea02050b3f302e706871ff87ecfa3ad82cc34249adbe0dcddfaac75bdb48ff",
+
"https://deno.land/std@0.208.0/flags/mod.ts": "567a34800a33e701942cb1726dd7e627a76e4681c33ce7346ac85cf90f691a8e",
+
"https://deno.land/std@0.208.0/fmt/colors.ts": "34b3f77432925eb72cf0bfb351616949746768620b8e5ead66da532f93d10ba2",
+
"https://deno.land/std@0.208.0/fs/_util.ts": "fbf57dcdc9f7bc8128d60301eece608246971a7836a3bb1e78da75314f08b978",
+
"https://deno.land/std@0.208.0/fs/walk.ts": "c1e6b43f72a46e89b630140308bd51a4795d416a416b4cfb7cd4bd1e25946723",
+
"https://deno.land/std@0.208.0/path/_common/assert_path.ts": "061e4d093d4ba5aebceb2c4da3318bfe3289e868570e9d3a8e327d91c2958946",
+
"https://deno.land/std@0.208.0/path/_common/basename.ts": "0d978ff818f339cd3b1d09dc914881f4d15617432ae519c1b8fdc09ff8d3789a",
+
"https://deno.land/std@0.208.0/path/_common/common.ts": "9e4233b2eeb50f8b2ae10ecc2108f58583aea6fd3e8907827020282dc2b76143",
+
"https://deno.land/std@0.208.0/path/_common/constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0",
+
"https://deno.land/std@0.208.0/path/_common/dirname.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397",
+
"https://deno.land/std@0.208.0/path/_common/format.ts": "11aa62e316dfbf22c126917f5e03ea5fe2ee707386555a8f513d27ad5756cf96",
+
"https://deno.land/std@0.208.0/path/_common/from_file_url.ts": "ef1bf3197d2efbf0297a2bdbf3a61d804b18f2bcce45548ae112313ec5be3c22",
+
"https://deno.land/std@0.208.0/path/_common/glob_to_reg_exp.ts": "5c3c2b79fc2294ec803d102bd9855c451c150021f452046312819fbb6d4dc156",
+
"https://deno.land/std@0.208.0/path/_common/normalize.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397",
+
"https://deno.land/std@0.208.0/path/_common/normalize_string.ts": "88c472f28ae49525f9fe82de8c8816d93442d46a30d6bb5063b07ff8a89ff589",
+
"https://deno.land/std@0.208.0/path/_common/relative.ts": "1af19d787a2a84b8c534cc487424fe101f614982ae4851382c978ab2216186b4",
+
"https://deno.land/std@0.208.0/path/_common/strip_trailing_separators.ts": "7ffc7c287e97bdeeee31b155828686967f222cd73f9e5780bfe7dfb1b58c6c65",
+
"https://deno.land/std@0.208.0/path/_common/to_file_url.ts": "a8cdd1633bc9175b7eebd3613266d7c0b6ae0fb0cff24120b6092ac31662f9ae",
+
"https://deno.land/std@0.208.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b",
+
"https://deno.land/std@0.208.0/path/_os.ts": "30b0c2875f360c9296dbe6b7f2d528f0f9c741cecad2e97f803f5219e91b40a2",
+
"https://deno.land/std@0.208.0/path/basename.ts": "04bb5ef3e86bba8a35603b8f3b69537112cdd19ce64b77f2522006da2977a5f3",
+
"https://deno.land/std@0.208.0/path/common.ts": "f4d061c7d0b95a65c2a1a52439edec393e906b40f1caf4604c389fae7caa80f5",
+
"https://deno.land/std@0.208.0/path/dirname.ts": "88a0a71c21debafc4da7a4cd44fd32e899462df458fbca152390887d41c40361",
+
"https://deno.land/std@0.208.0/path/extname.ts": "2da4e2490f3b48b7121d19fb4c91681a5e11bd6bd99df4f6f47d7a71bb6ecdf2",
+
"https://deno.land/std@0.208.0/path/format.ts": "3457530cc85d1b4bab175f9ae73998b34fd456c830d01883169af0681b8894fb",
+
"https://deno.land/std@0.208.0/path/from_file_url.ts": "e7fa233ea1dff9641e8d566153a24d95010110185a6f418dd2e32320926043f8",
+
"https://deno.land/std@0.208.0/path/glob_to_regexp.ts": "74d7448c471e293d03f05ccb968df4365fed6aaa508506b6325a8efdc01d8271",
+
"https://deno.land/std@0.208.0/path/is_absolute.ts": "67232b41b860571c5b7537f4954c88d86ae2ba45e883ee37d3dec27b74909d13",
+
"https://deno.land/std@0.208.0/path/is_glob.ts": "567dce5c6656bdedfc6b3ee6c0833e1e4db2b8dff6e62148e94a917f289c06ad",
+
"https://deno.land/std@0.208.0/path/join.ts": "98d3d76c819af4a11a81d5ba2dbb319f1ce9d63fc2b615597d4bcfddd4a89a09",
+
"https://deno.land/std@0.208.0/path/join_globs.ts": "9b84d5103b63d3dbed4b2cf8b12477b2ad415c7d343f1488505162dc0e5f4db8",
+
"https://deno.land/std@0.208.0/path/mod.ts": "3defabebc98279e62b392fee7a6937adc932a8f4dcd2471441e36c15b97b00e0",
+
"https://deno.land/std@0.208.0/path/normalize.ts": "aa95be9a92c7bd4f9dc0ba51e942a1973e2b93d266cd74f5ca751c136d520b66",
+
"https://deno.land/std@0.208.0/path/normalize_glob.ts": "674baa82e1c00b6cb153bbca36e06f8e0337cb8062db6d905ab5de16076ca46b",
+
"https://deno.land/std@0.208.0/path/parse.ts": "d87ff0deef3fb495bc0d862278ff96da5a06acf0625ca27769fc52ac0d3d6ece",
+
"https://deno.land/std@0.208.0/path/posix/_util.ts": "ecf49560fedd7dd376c6156cc5565cad97c1abe9824f4417adebc7acc36c93e5",
+
"https://deno.land/std@0.208.0/path/posix/basename.ts": "a630aeb8fd8e27356b1823b9dedd505e30085015407caa3396332752f6b8406a",
+
"https://deno.land/std@0.208.0/path/posix/common.ts": "e781d395dc76f6282e3f7dd8de13194abb8b04a82d109593141abc6e95755c8b",
+
"https://deno.land/std@0.208.0/path/posix/dirname.ts": "f48c9c42cc670803b505478b7ef162c7cfa9d8e751b59d278b2ec59470531472",
+
"https://deno.land/std@0.208.0/path/posix/extname.ts": "ee7f6571a9c0a37f9218fbf510c440d1685a7c13082c348d701396cc795e0be0",
+
"https://deno.land/std@0.208.0/path/posix/format.ts": "b94876f77e61bfe1f147d5ccb46a920636cd3cef8be43df330f0052b03875968",
+
"https://deno.land/std@0.208.0/path/posix/from_file_url.ts": "b97287a83e6407ac27bdf3ab621db3fccbf1c27df0a1b1f20e1e1b5acf38a379",
+
"https://deno.land/std@0.208.0/path/posix/glob_to_regexp.ts": "6ed00c71fbfe0ccc35977c35444f94e82200b721905a60bd1278b1b768d68b1a",
+
"https://deno.land/std@0.208.0/path/posix/is_absolute.ts": "159900a3422d11069d48395568217eb7fc105ceda2683d03d9b7c0f0769e01b8",
+
"https://deno.land/std@0.208.0/path/posix/is_glob.ts": "ec4fbc604b9db8487f7b56ab0e759b24a971ab6a45f7b0b698bc39b8b9f9680f",
+
"https://deno.land/std@0.208.0/path/posix/join.ts": "0c0d84bdc344876930126640011ec1b888e6facf74153ffad9ef26813aa2a076",
+
"https://deno.land/std@0.208.0/path/posix/join_globs.ts": "f4838d54b1f60a34a40625a3293f6e583135348be1b2974341ac04743cb26121",
+
"https://deno.land/std@0.208.0/path/posix/mod.ts": "f1b08a7f64294b7de87fc37190d63b6ce5b02889af9290c9703afe01951360ae",
+
"https://deno.land/std@0.208.0/path/posix/normalize.ts": "11de90a94ab7148cc46e5a288f7d732aade1d616bc8c862f5560fa18ff987b4b",
+
"https://deno.land/std@0.208.0/path/posix/normalize_glob.ts": "10a1840c628ebbab679254d5fa1c20e59106102354fb648a1765aed72eb9f3f9",
+
"https://deno.land/std@0.208.0/path/posix/parse.ts": "199208f373dd93a792e9c585352bfc73a6293411bed6da6d3bc4f4ef90b04c8e",
+
"https://deno.land/std@0.208.0/path/posix/relative.ts": "e2f230608b0f083e6deaa06e063943e5accb3320c28aef8d87528fbb7fe6504c",
+
"https://deno.land/std@0.208.0/path/posix/resolve.ts": "51579d83159d5c719518c9ae50812a63959bbcb7561d79acbdb2c3682236e285",
+
"https://deno.land/std@0.208.0/path/posix/separator.ts": "0b6573b5f3269a3164d8edc9cefc33a02dd51003731c561008c8bb60220ebac1",
+
"https://deno.land/std@0.208.0/path/posix/to_file_url.ts": "08d43ea839ee75e9b8b1538376cfe95911070a655cd312bc9a00f88ef14967b6",
+
"https://deno.land/std@0.208.0/path/posix/to_namespaced_path.ts": "c9228a0e74fd37e76622cd7b142b8416663a9b87db643302fa0926b5a5c83bdc",
+
"https://deno.land/std@0.208.0/path/relative.ts": "23d45ede8b7ac464a8299663a43488aad6b561414e7cbbe4790775590db6349c",
+
"https://deno.land/std@0.208.0/path/resolve.ts": "5b184efc87155a0af9fa305ff68a109e28de9aee81fc3e77cd01380f19daf867",
+
"https://deno.land/std@0.208.0/path/separator.ts": "40a3e9a4ad10bef23bc2cd6c610291b6c502a06237c2c4cd034a15ca78dedc1f",
+
"https://deno.land/std@0.208.0/path/to_file_url.ts": "edaafa089e0bce386e1b2d47afe7c72e379ff93b28a5829a5885e4b6c626d864",
+
"https://deno.land/std@0.208.0/path/to_namespaced_path.ts": "cf8734848aac3c7527d1689d2adf82132b1618eff3cc523a775068847416b22a",
+
"https://deno.land/std@0.208.0/path/windows/_util.ts": "f32b9444554c8863b9b4814025c700492a2b57ff2369d015360970a1b1099d54",
+
"https://deno.land/std@0.208.0/path/windows/basename.ts": "8a9dbf7353d50afbc5b221af36c02a72c2d1b2b5b9f7c65bf6a5a2a0baf88ad3",
+
"https://deno.land/std@0.208.0/path/windows/common.ts": "e781d395dc76f6282e3f7dd8de13194abb8b04a82d109593141abc6e95755c8b",
+
"https://deno.land/std@0.208.0/path/windows/dirname.ts": "5c2aa541384bf0bd9aca821275d2a8690e8238fa846198ef5c7515ce31a01a94",
+
"https://deno.land/std@0.208.0/path/windows/extname.ts": "07f4fa1b40d06a827446b3e3bcc8d619c5546b079b8ed0c77040bbef716c7614",
+
"https://deno.land/std@0.208.0/path/windows/format.ts": "343019130d78f172a5c49fdc7e64686a7faf41553268961e7b6c92a6d6548edf",
+
"https://deno.land/std@0.208.0/path/windows/from_file_url.ts": "d53335c12b0725893d768be3ac6bf0112cc5b639d2deb0171b35988493b46199",
+
"https://deno.land/std@0.208.0/path/windows/glob_to_regexp.ts": "290755e18ec6c1a4f4d711c3390537358e8e3179581e66261a0cf348b1a13395",
+
"https://deno.land/std@0.208.0/path/windows/is_absolute.ts": "245b56b5f355ede8664bd7f080c910a97e2169972d23075554ae14d73722c53c",
+
"https://deno.land/std@0.208.0/path/windows/is_glob.ts": "ec4fbc604b9db8487f7b56ab0e759b24a971ab6a45f7b0b698bc39b8b9f9680f",
+
"https://deno.land/std@0.208.0/path/windows/join.ts": "e6600bf88edeeef4e2276e155b8de1d5dec0435fd526ba2dc4d37986b2882f16",
+
"https://deno.land/std@0.208.0/path/windows/join_globs.ts": "f4838d54b1f60a34a40625a3293f6e583135348be1b2974341ac04743cb26121",
+
"https://deno.land/std@0.208.0/path/windows/mod.ts": "d7040f461465c2c21c1c68fc988ef0bdddd499912138cde3abf6ad60c7fb3814",
+
"https://deno.land/std@0.208.0/path/windows/normalize.ts": "9deebbf40c81ef540b7b945d4ccd7a6a2c5a5992f791e6d3377043031e164e69",
+
"https://deno.land/std@0.208.0/path/windows/normalize_glob.ts": "344ff5ed45430495b9a3d695567291e50e00b1b3b04ea56712a2acf07ab5c128",
+
"https://deno.land/std@0.208.0/path/windows/parse.ts": "120faf778fe1f22056f33ded069b68e12447668fcfa19540c0129561428d3ae5",
+
"https://deno.land/std@0.208.0/path/windows/relative.ts": "026855cd2c36c8f28f1df3c6fbd8f2449a2aa21f48797a74700c5d872b86d649",
+
"https://deno.land/std@0.208.0/path/windows/resolve.ts": "5ff441ab18a2346abadf778121128ee71bda4d0898513d4639a6ca04edca366b",
+
"https://deno.land/std@0.208.0/path/windows/separator.ts": "ae21f27015f10510ed1ac4a0ba9c4c9c967cbdd9d9e776a3e4967553c397bd5d",
+
"https://deno.land/std@0.208.0/path/windows/to_file_url.ts": "8e9ea9e1ff364aa06fa72999204229952d0a279dbb876b7b838b2b2fea55cce3",
+
"https://deno.land/std@0.208.0/path/windows/to_namespaced_path.ts": "e0f4d4a5e77f28a5708c1a33ff24360f35637ba6d8f103d19661255ef7bfd50d"
+
},
+
"workspace": {
+
"dependencies": [
+
"jsr:@std/cli@^1.0.23",
+
"jsr:@std/fmt@^1.0.8",
+
"jsr:@std/fs@^1.0.19",
+
"jsr:@std/path@^1.1.2"
+
]
+
}
+
}
+295
mod.ts
···
···
+
/**
+
* 🐶 Doggo - JSDoc Coverage Analyzer for Deno
+
*
+
* A loyal companion for your documentation journey! Doggo sniffs out undocumented
+
* exports in your Deno packages and helps you achieve 100% JSDoc coverage.
+
*
+
* @module
+
*
+
* @example Install Doggo
+
* ```bash
+
* deno install --global --allow-read jsr:@knotbin/doggo --name doggo
+
* ```
+
*
+
* @example Usage
+
* ```bash
+
* # Run Doggo in the current directory
+
* doggo
+
* # Run Doggo in a specific directory
+
* doggo /path/to/directory
+
* # Run Doggo for a specific file
+
* doggo /path/to/file.ts
+
* ```
+
*/
+
+
import { parseArgs } from "@std/cli";
+
import { join, resolve } from "@std/path";
+
import { bold, cyan, gray, green, red, yellow } from "@std/fmt/colors";
+
import {
+
analyzeDirectory,
+
type DocumentationStats,
+
type ExportedSymbol,
+
} from "./core.ts";
+
+
class JSDocAnalyzer {
+
private rootPath: string;
+
+
constructor(rootPath: string) {
+
this.rootPath = resolve(rootPath);
+
}
+
+
async analyze(): Promise<void> {
+
console.log(
+
cyan(bold("\n🐶 Doggo is analyzing your documentation coverage!\n")),
+
);
+
console.log(gray(`Path: ${this.rootPath}\n`));
+
+
// Use core module for analysis
+
const result = await analyzeDirectory(this.rootPath);
+
+
if (result.hasDenoJson && result.hasExports) {
+
console.log(gray(`Found deno.json with exports: ${result.exportPath}\n`));
+
console.log(
+
cyan("Sniffing out public API from export entry points...\n"),
+
);
+
}
+
+
// Output results
+
this.outputResults(result.symbols, result.stats);
+
}
+
+
private outputResults(
+
symbols: ExportedSymbol[],
+
stats: DocumentationStats,
+
): void {
+
if (symbols.length === 0) {
+
console.log(yellow("Woof! No exported symbols found. 🦴"));
+
return;
+
}
+
+
// Output undocumented symbols
+
const undocumented = symbols.filter((s) => !s.hasJSDoc);
+
+
if (undocumented.length > 0) {
+
console.log(red(bold("📝 Undocumented Exports (Doggo found these!):\n")));
+
+
// Group by file
+
const byFile = new Map<string, ExportedSymbol[]>();
+
for (const symbol of undocumented) {
+
if (!byFile.has(symbol.file)) {
+
byFile.set(symbol.file, []);
+
}
+
byFile.get(symbol.file)!.push(symbol);
+
}
+
+
// Output by file
+
for (const [file, symbols] of byFile.entries()) {
+
console.log(yellow(` ${file}:`));
+
for (const symbol of symbols.sort((a, b) => a.line - b.line)) {
+
const typeLabel = gray(`[${symbol.type}]`);
+
const lineNum = gray(`:${symbol.line}`);
+
console.log(` ${red("✗")} ${symbol.name} ${typeLabel}${lineNum}`);
+
}
+
console.log();
+
}
+
} else {
+
console.log(
+
green(bold("✨ Good boy! All exported symbols are documented!\n")),
+
);
+
}
+
+
// Output summary
+
this.outputSummary(stats);
+
}
+
+
private outputSummary(stats: DocumentationStats): void {
+
console.log(bold(cyan("📊 Documentation Coverage Summary\n")));
+
console.log(gray("─".repeat(50)));
+
+
// Overall stats
+
const percentageColor = stats.percentage >= 80
+
? green
+
: stats.percentage >= 60
+
? yellow
+
: red;
+
+
console.log(` Total Exports: ${bold(stats.total.toString())}`);
+
console.log(` Documented: ${green(stats.documented.toString())}`);
+
console.log(` Undocumented: ${red(stats.undocumented.toString())}`);
+
console.log(
+
` Coverage: ${percentageColor(bold(`${stats.percentage}%`))}`,
+
);
+
+
// Stats by type
+
if (Object.keys(stats.byType).length > 0) {
+
console.log(gray("─".repeat(50)));
+
console.log(bold("\n Coverage by Type:\n"));
+
+
for (const [type, typeStats] of Object.entries(stats.byType)) {
+
const percentage = typeStats.total > 0
+
? Math.round((typeStats.documented / typeStats.total) * 100)
+
: 100;
+
const percentageColor = percentage >= 80
+
? green
+
: percentage >= 60
+
? yellow
+
: red;
+
+
const typeLabel = type.padEnd(12);
+
const statsStr = `${typeStats.documented}/${typeStats.total}`.padEnd(7);
+
console.log(
+
` ${typeLabel} ${statsStr} ${percentageColor(`${percentage}%`)}`,
+
);
+
}
+
}
+
+
console.log(gray("─".repeat(50)));
+
+
// Coverage indicator
+
const indicator = this.getCoverageIndicator(stats.percentage);
+
console.log(`\n ${indicator}`);
+
}
+
+
private getCoverageIndicator(percentage: number): string {
+
if (percentage === 100) {
+
return green("🏆 Perfect! Doggo is doing zoomies!");
+
} else if (percentage >= 90) {
+
return green("✨ Excellent! Tail wagging intensifies!");
+
} else if (percentage >= 80) {
+
return green("👍 Good boy! Happy tail wags");
+
} else if (percentage >= 60) {
+
return yellow("📈 Doggo needs more treats (documentation)");
+
} else if (percentage >= 40) {
+
return red("⚠️ Poor coverage (Sad puppy eyes)");
+
} else {
+
return red("🚨 Critical! Doggo is hiding under the bed");
+
}
+
}
+
}
+
+
// Main CLI
+
async function main() {
+
const args = parseArgs(Deno.args, {
+
string: ["path"],
+
boolean: ["help", "version", "workspace"],
+
alias: {
+
h: "help",
+
v: "version",
+
p: "path",
+
w: "workspace",
+
},
+
default: {
+
path: ".",
+
},
+
});
+
+
if (args.help) {
+
console.log(`
+
${bold("🐶 Doggo - The Doc Sniffing Dog")}
+
+
A loyal companion for your documentation journey! Doggo sniffs out undocumented
+
exports in your Deno packages and helps you achieve 100% JSDoc coverage.
+
+
__
+
(___()'${"`"};
+
/,___ /${"`"}
+
${"\\\\"} ${"\\\\"} Woof! Let's document that code!
+
+
${bold("Usage:")}
+
doggo [options] [path]
+
+
${bold("Options:")}
+
-h, --help Show help (Doggo does tricks!)
+
-v, --version Show version (Doggo's age in dog years)
+
-p, --path Path to analyze (where should Doggo sniff?)
+
-w, --workspace Force workspace mode (analyze the whole pack!)
+
+
${bold("Examples:")}
+
doggo # Analyze current directory
+
doggo ./src # Analyze specific directory
+
doggo ./src/module.ts # Analyze single file
+
doggo --workspace # Analyze all workspace members
+
+
${bold("Notes:")}
+
🦴 If a workspace configuration is found, analyzes all pack members
+
🎯 If a deno.json 'exports' field is found, analyzes only the public API
+
🔍 Otherwise, sniffs out all exported symbols in the codebase
+
+
${bold("Remember:")} A well-documented codebase is like a well-trained dog -
+
everyone loves working with it!
+
`);
+
Deno.exit(0);
+
}
+
+
if (args.version) {
+
console.log("🐶 Doggo - The goodest documentation boy!");
+
Deno.exit(0);
+
}
+
+
// Get path from positional argument or --path flag
+
const targetPath = args._[0]?.toString() || args.path;
+
+
try {
+
// Check if we should try workspace mode
+
const workspaceConfigPath = join(resolve(targetPath), "deno.json");
+
const workspaceConfigPathJsonc = join(resolve(targetPath), "deno.jsonc");
+
+
let hasWorkspace = false;
+
+
// Check for workspace configuration
+
try {
+
const content = await Deno.readTextFile(workspaceConfigPath);
+
const config = JSON.parse(content);
+
hasWorkspace = !!config.workspace;
+
} catch {
+
// Try deno.jsonc
+
try {
+
const content = await Deno.readTextFile(workspaceConfigPathJsonc);
+
// Simple JSONC parsing
+
const jsonContent = content
+
.split("\n")
+
.map((line) => {
+
const commentIndex = line.indexOf("//");
+
return commentIndex > -1 ? line.slice(0, commentIndex) : line;
+
})
+
.join("\n")
+
.replace(/\/\*[\s\S]*?\*\//g, "");
+
const config = JSON.parse(jsonContent);
+
hasWorkspace = !!config.workspace;
+
} catch {
+
// No workspace config found
+
}
+
}
+
+
// Use workspace analyzer if workspace found or forced
+
if (hasWorkspace || args.workspace) {
+
const { WorkspaceAnalyzer } = await import("./workspace.ts");
+
const workspaceAnalyzer = new WorkspaceAnalyzer(targetPath);
+
const report = await workspaceAnalyzer.analyze();
+
+
if (!report && args.workspace) {
+
console.log(yellow("\n⚠️ Woof! No workspace configuration found."));
+
console.log(
+
gray(
+
"Doggo was looking for 'workspace' field in deno.json or deno.jsonc\n",
+
),
+
);
+
Deno.exit(1);
+
}
+
} else {
+
// Regular single-package analysis
+
const analyzer = new JSDocAnalyzer(targetPath);
+
await analyzer.analyze();
+
}
+
} catch (error) {
+
console.error(red(`Error: ${error}`));
+
Deno.exit(1);
+
}
+
}
+
+
// Export for use in workspace.ts
+
export { JSDocAnalyzer };
+
+
if (import.meta.main) {
+
await main();
+
}
+377
workspace.ts
···
···
+
import { bold, cyan, gray, green, red, yellow } from "@std/fmt/colors";
+
import { join, resolve } from "@std/path";
+
import { analyzeDirectory } from "./core.ts";
+
+
interface WorkspaceConfig {
+
workspace?: string[];
+
imports?: Record<string, string>;
+
}
+
+
interface WorkspaceMemberStats {
+
name: string;
+
path: string;
+
hasDenoJson: boolean;
+
hasExports: boolean;
+
exportPath?: string;
+
stats: {
+
total: number;
+
documented: number;
+
undocumented: number;
+
percentage: number;
+
};
+
byType?: Record<string, { total: number; documented: number }>;
+
}
+
+
interface WorkspaceReport {
+
members: WorkspaceMemberStats[];
+
aggregate: {
+
totalMembers: number;
+
totalExports: number;
+
totalDocumented: number;
+
totalUndocumented: number;
+
averagePercentage: number;
+
};
+
}
+
+
/**
+
* Analyzer for Deno workspace configurations.
+
*
+
* The WorkspaceAnalyzer helps Doggo analyze multi-package repositories
+
* by examining each workspace member individually and providing both
+
* individual and aggregate documentation coverage statistics.
+
*
+
* @example
+
* ```typescript
+
* import { WorkspaceAnalyzer } from "@doggo/cli";
+
*
+
* const analyzer = new WorkspaceAnalyzer("./my-workspace");
+
* const report = await analyzer.analyze();
+
*
+
* if (report) {
+
* console.log(`Total coverage: ${report.aggregate.averagePercentage}%`);
+
* console.log(`Pack members: ${report.members.length}`);
+
* }
+
* ```
+
*/
+
export class WorkspaceAnalyzer {
+
private rootPath: string;
+
private workspaceConfig: WorkspaceConfig | null = null;
+
+
constructor(rootPath: string = ".") {
+
this.rootPath = resolve(rootPath);
+
}
+
+
async analyze(): Promise<WorkspaceReport | null> {
+
// Load workspace configuration
+
this.workspaceConfig = await this.loadWorkspaceConfig();
+
+
if (!this.workspaceConfig?.workspace) {
+
return null;
+
}
+
+
console.log(cyan(bold("\n🏢 Doggo is analyzing the pack!\n")));
+
console.log(gray(`Root: ${this.rootPath}`));
+
console.log(
+
gray(`Pack members: ${this.workspaceConfig.workspace.length}\n`),
+
);
+
+
const members: WorkspaceMemberStats[] = [];
+
+
// Analyze each workspace member
+
for (const memberPath of this.workspaceConfig.workspace) {
+
const fullPath = join(this.rootPath, memberPath);
+
console.log(cyan(`\n🐕 Sniffing ${bold(memberPath)}...`));
+
+
try {
+
const stats = await this.analyzeMember(fullPath, memberPath);
+
members.push(stats);
+
+
// Output brief summary for this member
+
this.outputMemberSummary(stats);
+
} catch (error) {
+
console.error(red(` ✗ Doggo couldn't sniff ${memberPath}: ${error}`));
+
// Add failed member with zero stats
+
members.push({
+
name: memberPath,
+
path: fullPath,
+
hasDenoJson: false,
+
hasExports: false,
+
stats: {
+
total: 0,
+
documented: 0,
+
undocumented: 0,
+
percentage: 0,
+
},
+
});
+
}
+
}
+
+
// Calculate aggregate statistics
+
const aggregate = this.calculateAggregate(members);
+
const report: WorkspaceReport = { members, aggregate };
+
+
// Output workspace summary
+
this.outputWorkspaceSummary(report);
+
+
return report;
+
}
+
+
private async loadWorkspaceConfig(): Promise<WorkspaceConfig | null> {
+
// Try deno.json first
+
const denoJsonPath = join(this.rootPath, "deno.json");
+
try {
+
const content = await Deno.readTextFile(denoJsonPath);
+
const config = JSON.parse(content) as WorkspaceConfig;
+
if (config.workspace) {
+
console.log(gray(`Found workspace configuration in deno.json`));
+
return config;
+
}
+
} catch {
+
// Not found or parse error
+
}
+
+
// Try deno.jsonc
+
const denoJsoncPath = join(this.rootPath, "deno.jsonc");
+
try {
+
const content = await Deno.readTextFile(denoJsoncPath);
+
// Simple JSONC parsing - remove comments
+
const jsonContent = content
+
.split("\n")
+
.map((line) => {
+
const commentIndex = line.indexOf("//");
+
return commentIndex > -1 ? line.slice(0, commentIndex) : line;
+
})
+
.join("\n")
+
.replace(/\/\*[\s\S]*?\*\//g, "");
+
const config = JSON.parse(jsonContent) as WorkspaceConfig;
+
if (config.workspace) {
+
console.log(gray(`Found workspace configuration in deno.jsonc`));
+
return config;
+
}
+
} catch {
+
// Not found or parse error
+
}
+
+
return null;
+
}
+
+
private async analyzeMember(
+
memberPath: string,
+
memberName: string,
+
): Promise<WorkspaceMemberStats> {
+
// Use the core module to analyze the member
+
const result = await analyzeDirectory(memberPath);
+
+
return {
+
name: memberName,
+
path: memberPath,
+
hasDenoJson: result.hasDenoJson,
+
hasExports: result.hasExports,
+
exportPath: result.exportPath,
+
stats: {
+
total: result.stats.total,
+
documented: result.stats.documented,
+
undocumented: result.stats.undocumented,
+
percentage: result.stats.percentage,
+
},
+
byType: result.stats.byType,
+
};
+
}
+
+
private outputMemberSummary(stats: WorkspaceMemberStats): void {
+
const { name, hasExports, exportPath, stats: s } = stats;
+
+
const percentageColor = s.percentage >= 80
+
? green
+
: s.percentage >= 60
+
? yellow
+
: red;
+
+
const indicator = s.percentage >= 80
+
? "✓"
+
: s.percentage >= 60
+
? "⚡"
+
: "✗";
+
const indicatorColor = s.percentage >= 80
+
? green
+
: s.percentage >= 60
+
? yellow
+
: red;
+
+
console.log(
+
` ${indicatorColor(indicator)} ${name.padEnd(20)} ${
+
s.total.toString().padStart(3)
+
} exports, ${percentageColor(bold(`${s.percentage}%`))} documented`,
+
);
+
+
if (hasExports && exportPath) {
+
console.log(gray(` └─ Entry: ${exportPath}`));
+
}
+
}
+
+
private calculateAggregate(
+
members: WorkspaceMemberStats[],
+
): WorkspaceReport["aggregate"] {
+
const totalMembers = members.length;
+
const totalExports = members.reduce((sum, m) => sum + m.stats.total, 0);
+
const totalDocumented = members.reduce(
+
(sum, m) => sum + m.stats.documented,
+
0,
+
);
+
const totalUndocumented = members.reduce(
+
(sum, m) => sum + m.stats.undocumented,
+
0,
+
);
+
+
// Calculate weighted average percentage
+
const averagePercentage = totalExports > 0
+
? Math.round((totalDocumented / totalExports) * 100)
+
: 0;
+
+
return {
+
totalMembers,
+
totalExports,
+
totalDocumented,
+
totalUndocumented,
+
averagePercentage,
+
};
+
}
+
+
private outputWorkspaceSummary(report: WorkspaceReport): void {
+
const { members, aggregate } = report;
+
+
console.log("\n" + cyan(bold("📊 Pack Summary")));
+
console.log(gray("─".repeat(60)));
+
+
// Overall stats
+
console.log(`\n ${bold("Pack Statistics:")}`);
+
console.log(
+
` Pack Members: ${bold(aggregate.totalMembers.toString())}`,
+
);
+
console.log(
+
` Total Exports: ${bold(aggregate.totalExports.toString())}`,
+
);
+
console.log(
+
` Documented: ${green(aggregate.totalDocumented.toString())}`,
+
);
+
console.log(
+
` Undocumented: ${red(aggregate.totalUndocumented.toString())}`,
+
);
+
+
const percentageColor = aggregate.averagePercentage >= 80
+
? green
+
: aggregate.averagePercentage >= 60
+
? yellow
+
: red;
+
console.log(
+
` Coverage: ${
+
percentageColor(bold(`${aggregate.averagePercentage}%`))
+
}`,
+
);
+
+
// Member breakdown table
+
console.log(`\n ${bold("Member Breakdown:")}\n`);
+
console.log(gray(" " + "─".repeat(56)));
+
console.log(
+
gray(" │") + " Member".padEnd(20) + gray("│") + " Exports " +
+
gray("│") + " Documented " + gray("│") + " Coverage " + gray("│"),
+
);
+
console.log(gray(" " + "─".repeat(56)));
+
+
// Sort members by coverage percentage
+
const sortedMembers = [...members].sort((a, b) =>
+
b.stats.percentage - a.stats.percentage
+
);
+
+
for (const member of sortedMembers) {
+
const percentageColor = member.stats.percentage >= 80
+
? green
+
: member.stats.percentage >= 60
+
? yellow
+
: red;
+
+
const name = member.name.length > 18
+
? member.name.substring(0, 15) + "..."
+
: member.name;
+
+
console.log(
+
gray(" │") +
+
` ${name.padEnd(18)} ` +
+
gray("│") +
+
` ${member.stats.total.toString().padStart(7)} ` +
+
gray("│") +
+
` ${member.stats.documented.toString().padStart(10)} ` +
+
gray("│") +
+
` ${
+
percentageColor(
+
member.stats.percentage.toString().padStart(7) + "%",
+
)
+
} ` +
+
gray("│"),
+
);
+
}
+
console.log(gray(" " + "─".repeat(56)));
+
+
// Identify members needing attention
+
const needsWork = members.filter((m) => m.stats.percentage < 60);
+
const goodCoverage = members.filter((m) => m.stats.percentage >= 80);
+
+
if (needsWork.length > 0) {
+
console.log(`\n ${red("⚠️ Pack members needing training:")}`);
+
for (const member of needsWork) {
+
console.log(` - ${member.name} (${member.stats.percentage}%)`);
+
}
+
}
+
+
if (goodCoverage.length > 0) {
+
console.log(`\n ${green("✨ Kibble-worthy pups:")}`);
+
for (const member of goodCoverage) {
+
if (member.stats.percentage === 100) {
+
console.log(` - ${member.name} ${green("(best in show!)")}`);
+
} else {
+
console.log(` - ${member.name} (${member.stats.percentage}%)`);
+
}
+
}
+
}
+
+
// Final indicator
+
const indicator = this.getWorkspaceIndicator(aggregate.averagePercentage);
+
console.log(`\n ${indicator}\n`);
+
}
+
+
private getWorkspaceIndicator(percentage: number): string {
+
if (percentage === 100) {
+
return green("🏆 Perfect! The whole pack is doing zoomies!");
+
} else if (percentage >= 90) {
+
return green("✨ Excellent! The pack's tails are wagging!");
+
} else if (percentage >= 80) {
+
return green("👍 Good pack! Happy woofs all around");
+
} else if (percentage >= 60) {
+
return yellow("📈 The pack needs more training (and treats)");
+
} else if (percentage >= 40) {
+
return red("⚠️ Poor pack coverage (Collective sad puppy eyes)");
+
} else {
+
return red("🚨 Critical! The whole pack is hiding");
+
}
+
}
+
}
+
+
// CLI entry point
+
async function main() {
+
const analyzer = new WorkspaceAnalyzer();
+
const report = await analyzer.analyze();
+
+
if (!report) {
+
console.log(yellow("\n⚠️ Woof! No pack configuration found."));
+
console.log(
+
gray(
+
"Doggo was looking for 'workspace' field in deno.json or deno.jsonc\n",
+
),
+
);
+
Deno.exit(1);
+
}
+
}
+
+
if (import.meta.main) {
+
await main();
+
}