The doc-sniffing dog

fix dups and add tests

Changed files
+418 -12
tests
+164 -11
core.ts
···
}
// Now analyze JSDoc for all traced symbols
+
// Collect all unique exports by their source to avoid duplicates
+
const uniqueExports = new Map<string, TracedExport>();
for (const [_filePath, exports] of exportMap.entries()) {
-
await analyzeFileForJSDoc(exports, symbols, rootPath);
+
for (const exp of exports) {
+
const key = `${exp.sourcePath}:${exp.originalName}:${exp.exportedName}`;
+
if (!uniqueExports.has(key)) {
+
uniqueExports.set(key, exp);
+
}
+
}
}
+
+
// Analyze only unique exports
+
await analyzeFileForJSDoc(
+
Array.from(uniqueExports.values()),
+
symbols,
+
rootPath,
+
);
}
async function traceExportsFromFile(
···
});
} else {
// Treat as a local export if we can't find the import
+
// Find the actual definition line in this file
+
const lineNum = await findSymbolInFile(
+
filePath,
+
originalName,
+
);
addExport(exportMap, filePath, {
originalName,
exportedName: exportedName || originalName,
sourcePath: filePath,
-
line: i + 1,
+
line: lineNum || i + 1,
});
}
}
···
return i + 1;
}
}
+
+
// Also check for non-exported declarations (class, function, interface, type, enum, const)
+
// that might be exported later with export { ... }
+
const patterns = [
+
new RegExp(`^(?:async\\s+)?function\\s+${symbolName}\\b`),
+
new RegExp(`^class\\s+${symbolName}\\b`),
+
new RegExp(`^interface\\s+${symbolName}\\b`),
+
new RegExp(`^type\\s+${symbolName}\\b`),
+
new RegExp(`^enum\\s+${symbolName}\\b`),
+
new RegExp(`^(?:const|let|var)\\s+${symbolName}\\b`),
+
];
+
+
for (const pattern of patterns) {
+
if (pattern.test(line)) {
+
return i + 1;
+
}
+
}
}
} catch {
// Ignore errors
···
symbols: ExportedSymbol[],
rootPath: string,
): Promise<void> {
-
// Group exports by their source file
+
// Group exports by their source file and deduplicate
const exportsBySource = new Map<string, TracedExport[]>();
+
const seenExports = new Set<string>();
+
for (const exp of exports) {
+
// Create a unique key for deduplication
+
const key = `${exp.sourcePath}:${exp.exportedName}:${exp.originalName}`;
+
if (seenExports.has(key)) {
+
continue; // Skip duplicates
+
}
+
seenExports.add(key);
+
if (!exportsBySource.has(exp.sourcePath)) {
exportsBySource.set(exp.sourcePath, []);
}
···
// Track JSDoc blocks
if (trimmed.startsWith("/**")) {
-
currentJSDoc = [trimmed];
+
if (trimmed.endsWith("*/")) {
+
const jsDocContent = trimmed;
+
if (!jsDocContent.includes("@module")) {
+
for (let j = i + 1; j < lines.length; j++) {
+
const nextLine = lines[j].trim();
+
if (nextLine && !nextLine.startsWith("//")) {
+
if (
+
isDirectExport(nextLine) || nextLine.startsWith("export ")
+
) {
+
jsDocBlocks.set(j, jsDocContent);
+
for (let k = j + 1; k <= j + 5 && k < lines.length; k++) {
+
jsDocBlocks.set(k, jsDocContent);
+
}
+
}
+
break;
+
}
+
}
+
}
+
} else {
+
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);
}
···
}
}
}
+
} else {
+
// Check for non-exported declarations that match symbols in sourceExports
+
for (const exp of sourceExports) {
+
const patterns = [
+
`class ${exp.originalName}`,
+
`function ${exp.originalName}`,
+
`const ${exp.originalName}`,
+
`let ${exp.originalName}`,
+
`var ${exp.originalName}`,
+
`interface ${exp.originalName}`,
+
`type ${exp.originalName}`,
+
`enum ${exp.originalName}`,
+
];
+
+
for (const pattern of patterns) {
+
if (trimmed.startsWith(pattern)) {
+
let fullDeclaration = trimmed;
+
const declarationStartLine = i;
+
+
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 symbol = parseExportedSymbol(
+
fullDeclaration,
+
declarationStartLine,
+
relativePath,
+
jsDocBlocks,
+
);
+
if (symbol) {
+
symbol.name = exp.exportedName;
+
symbols.push(symbol);
+
}
+
break;
+
}
+
}
+
}
}
}
} catch {
···
// Track JSDoc blocks
if (trimmed.startsWith("/**")) {
-
currentJSDoc = [trimmed];
+
if (trimmed.endsWith("*/")) {
+
const jsDocContent = trimmed;
+
for (let j = i + 1; j < lines.length; j++) {
+
if (lines[j].trim() && !lines[j].trim().startsWith("//")) {
+
jsDocBlocks.set(j, jsDocContent);
+
break;
+
}
+
}
+
} else {
+
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("//")) {
···
name = exports[0].split(/\s+as\s+/)[0];
type = "variable"; // We'd need more context to determine the actual type
}
+
}
+
} // Parse non-exported declarations
+
else if (trimmed.startsWith("class ")) {
+
const match = trimmed.match(/class\s+(\w+)/);
+
if (match) {
+
name = match[1];
+
type = "class";
+
}
+
} else if (
+
trimmed.startsWith("function ") || trimmed.startsWith("async function ")
+
) {
+
const match = trimmed.match(/function\s+(\w+)/);
+
if (match) {
+
name = match[1];
+
type = "function";
+
}
+
} else if (trimmed.startsWith("interface ")) {
+
const match = trimmed.match(/interface\s+(\w+)/);
+
if (match) {
+
name = match[1];
+
type = "interface";
+
}
+
} else if (trimmed.startsWith("type ")) {
+
const match = trimmed.match(/type\s+(\w+)/);
+
if (match) {
+
name = match[1];
+
type = "type";
+
}
+
} else if (trimmed.startsWith("enum ")) {
+
const match = trimmed.match(/enum\s+(\w+)/);
+
if (match) {
+
name = match[1];
+
type = "enum";
+
}
+
} else if (
+
trimmed.startsWith("const ") || trimmed.startsWith("let ") ||
+
trimmed.startsWith("var ")
+
) {
+
const match = trimmed.match(/(?:const|let|var)\s+(\w+)/);
+
if (match) {
+
name = match[1];
+
type = trimmed.startsWith("const") ? "const" : "variable";
}
}
+7 -1
deno.json
···
{
"name": "@knotbin/doggo",
-
"version": "0.1.0",
+
"version": "0.1.1",
"exports": "./mod.ts",
"imports": {
+
"@std/assert": "jsr:@std/assert@^1.0.15",
"@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"
+
},
+
"test": {
+
"permissions": {
+
"read": true
+
}
}
}
+8
deno.lock
···
{
"version": "5",
"specifiers": {
+
"jsr:@std/assert@^1.0.15": "1.0.15",
"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",
···
"npm:@types/node@*": "24.2.0"
},
"jsr": {
+
"@std/assert@1.0.15": {
+
"integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b",
+
"dependencies": [
+
"jsr:@std/internal@^1.0.12"
+
]
+
},
"@std/cli@1.0.23": {
"integrity": "bf95b7a9425ba2af1ae5a6359daf58c508f2decf711a76ed2993cd352498ccca",
"dependencies": [
···
},
"workspace": {
"dependencies": [
+
"jsr:@std/assert@^1.0.15",
"jsr:@std/cli@^1.0.23",
"jsr:@std/fmt@^1.0.8",
"jsr:@std/fs@^1.0.19",
+146
tests/core_test.ts
···
+
import { assertEquals } from "@std/assert";
+
import { analyzeDirectory } from "../core.ts";
+
import { join } from "@std/path";
+
+
Deno.test("analyzeDirectory - finds exported symbols", async () => {
+
const fixturesPath = join(
+
Deno.cwd(),
+
"tests",
+
"fixtures",
+
"simple",
+
"basic.ts",
+
);
+
+
const result = await analyzeDirectory(fixturesPath);
+
+
assertEquals(result.symbols.length, 4);
+
assertEquals(result.hasDenoJson, false);
+
});
+
+
Deno.test("analyzeDirectory - detects JSDoc presence", async () => {
+
const fixturesPath = join(
+
Deno.cwd(),
+
"tests",
+
"fixtures",
+
"simple",
+
"basic.ts",
+
);
+
+
const result = await analyzeDirectory(fixturesPath);
+
+
const undocumented = result.symbols.filter((s) => !s.hasJSDoc);
+
assertEquals(undocumented.length, 4);
+
});
+
+
Deno.test("analyzeDirectory - detects documented symbols", async () => {
+
const fixturesPath = join(
+
Deno.cwd(),
+
"tests",
+
"fixtures",
+
"simple",
+
"documented.ts",
+
);
+
+
const result = await analyzeDirectory(fixturesPath);
+
+
assertEquals(result.symbols.length, 4);
+
+
const documented = result.symbols.filter((s) => s.hasJSDoc);
+
assertEquals(documented.length, 4);
+
+
assertEquals(result.stats.percentage, 100);
+
});
+
+
Deno.test("analyzeDirectory - calculates stats correctly", async () => {
+
const fixturesPath = join(
+
Deno.cwd(),
+
"tests",
+
"fixtures",
+
"simple",
+
"basic.ts",
+
);
+
+
const result = await analyzeDirectory(fixturesPath);
+
+
assertEquals(result.stats.total, 4);
+
assertEquals(result.stats.documented, 0);
+
assertEquals(result.stats.undocumented, 4);
+
assertEquals(result.stats.percentage, 0);
+
});
+
+
Deno.test("analyzeDirectory - handles different export types", async () => {
+
const fixturesPath = join(
+
Deno.cwd(),
+
"tests",
+
"fixtures",
+
"export_blocks",
+
"export_block.ts",
+
);
+
+
const result = await analyzeDirectory(fixturesPath);
+
+
assertEquals(result.symbols.length, 3);
+
+
const classSymbol = result.symbols.find((s) => s.name === "MyClass");
+
assertEquals(classSymbol?.type, "class");
+
+
const functionSymbol = result.symbols.find((s) => s.name === "myFunction");
+
assertEquals(functionSymbol?.type, "function");
+
+
const constSymbol = result.symbols.find((s) => s.name === "myConst");
+
assertEquals(constSymbol?.type, "const");
+
});
+
+
Deno.test("analyzeDirectory - uses deno.json exports field", async () => {
+
const fixturesPath = join(Deno.cwd(), "tests", "fixtures", "with_config");
+
+
const result = await analyzeDirectory(fixturesPath);
+
+
assertEquals(result.hasDenoJson, true);
+
assertEquals(result.hasExports, true);
+
assertEquals(result.exportPath, "./reexport.ts");
+
});
+
+
Deno.test("analyzeDirectory - traces re-exports", async () => {
+
const fixturesPath = join(Deno.cwd(), "tests", "fixtures", "with_config");
+
+
const result = await analyzeDirectory(fixturesPath);
+
+
const symbolNames = result.symbols.map((s) => s.name).sort();
+
+
assertEquals(symbolNames.includes("documentedFunction"), true);
+
assertEquals(symbolNames.includes("UndocumentedClass"), true);
+
assertEquals(symbolNames.includes("DocumentedInterface"), true);
+
assertEquals(symbolNames.includes("undocumentedConst"), true);
+
assertEquals(symbolNames.includes("renamedFunction"), true);
+
});
+
+
Deno.test("analyzeDirectory - avoids duplicate exports", async () => {
+
const fixturesPath = join(Deno.cwd(), "tests", "fixtures", "with_config");
+
+
const result = await analyzeDirectory(fixturesPath);
+
+
const symbolNames = result.symbols.map((s) => s.name);
+
const uniqueNames = new Set(symbolNames);
+
+
assertEquals(symbolNames.length, uniqueNames.size);
+
});
+
+
Deno.test("analyzeDirectory - tracks symbol types correctly", async () => {
+
const fixturesPath = join(
+
Deno.cwd(),
+
"tests",
+
"fixtures",
+
"simple",
+
"basic.ts",
+
);
+
+
const result = await analyzeDirectory(fixturesPath);
+
+
const byType = result.stats.byType;
+
+
assertEquals(byType["function"]?.total, 1);
+
assertEquals(byType["class"]?.total, 1);
+
assertEquals(byType["interface"]?.total, 1);
+
assertEquals(byType["const"]?.total, 1);
+
});
+9
tests/fixtures/export_blocks/export_block.ts
···
+
export class MyClass {
+
constructor(public value: number) {}
+
}
+
+
export function myFunction(): string {
+
return "test";
+
}
+
+
export const myConst = 123;
+18
tests/fixtures/simple/basic.ts
···
+
export function documentedFunction(x: number): number {
+
return x * 2;
+
}
+
+
export class UndocumentedClass {
+
constructor(public name: string) {}
+
+
greet(): string {
+
return `Hello, ${this.name}`;
+
}
+
}
+
+
export interface DocumentedInterface {
+
id: string;
+
value: number;
+
}
+
+
export const undocumentedConst = 42;
+32
tests/fixtures/simple/documented.ts
···
+
/**
+
* A well documented function
+
* @param x - The input number
+
* @returns The doubled value
+
*/
+
export function documentedFunction(x: number): number {
+
return x * 2;
+
}
+
+
/**
+
* A documented class with proper JSDoc
+
*/
+
export class DocumentedClass {
+
constructor(public name: string) {}
+
+
greet(): string {
+
return `Hello, ${this.name}`;
+
}
+
}
+
+
/**
+
* A documented interface
+
*/
+
export interface DocumentedInterface {
+
id: string;
+
value: number;
+
}
+
+
/**
+
* A documented constant
+
*/
+
export const documentedConst = 42;
+18
tests/fixtures/with_config/basic.ts
···
+
export function documentedFunction(x: number): number {
+
return x * 2;
+
}
+
+
export class UndocumentedClass {
+
constructor(public name: string) {}
+
+
greet(): string {
+
return `Hello, ${this.name}`;
+
}
+
}
+
+
export interface DocumentedInterface {
+
id: string;
+
value: number;
+
}
+
+
export const undocumentedConst = 42;
+3
tests/fixtures/with_config/deno.json
···
+
{
+
"exports": "./reexport.ts"
+
}
+11
tests/fixtures/with_config/export_block.ts
···
+
class MyClass {
+
constructor(public value: number) {}
+
}
+
+
function myFunction(): string {
+
return "test";
+
}
+
+
const myConst = 123;
+
+
export { MyClass, myFunction, myConst };
+2
tests/fixtures/with_config/reexport.ts
···
+
export * from "./basic.ts";
+
export { myFunction as renamedFunction } from "./export_block.ts";