The doc-sniffing dog
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix dups and add tests

+418 -12
+164 -11
core.ts
··· 166 166 } 167 167 168 168 // Now analyze JSDoc for all traced symbols 169 + // Collect all unique exports by their source to avoid duplicates 170 + const uniqueExports = new Map<string, TracedExport>(); 169 171 for (const [_filePath, exports] of exportMap.entries()) { 170 - await analyzeFileForJSDoc(exports, symbols, rootPath); 172 + for (const exp of exports) { 173 + const key = `${exp.sourcePath}:${exp.originalName}:${exp.exportedName}`; 174 + if (!uniqueExports.has(key)) { 175 + uniqueExports.set(key, exp); 176 + } 177 + } 171 178 } 179 + 180 + // Analyze only unique exports 181 + await analyzeFileForJSDoc( 182 + Array.from(uniqueExports.values()), 183 + symbols, 184 + rootPath, 185 + ); 172 186 } 173 187 174 188 async function traceExportsFromFile( ··· 289 303 }); 290 304 } else { 291 305 // Treat as a local export if we can't find the import 306 + // Find the actual definition line in this file 307 + const lineNum = await findSymbolInFile( 308 + filePath, 309 + originalName, 310 + ); 292 311 addExport(exportMap, filePath, { 293 312 originalName, 294 313 exportedName: exportedName || originalName, 295 314 sourcePath: filePath, 296 - line: i + 1, 315 + line: lineNum || i + 1, 297 316 }); 298 317 } 299 318 } ··· 433 452 return i + 1; 434 453 } 435 454 } 455 + 456 + // Also check for non-exported declarations (class, function, interface, type, enum, const) 457 + // that might be exported later with export { ... } 458 + const patterns = [ 459 + new RegExp(`^(?:async\\s+)?function\\s+${symbolName}\\b`), 460 + new RegExp(`^class\\s+${symbolName}\\b`), 461 + new RegExp(`^interface\\s+${symbolName}\\b`), 462 + new RegExp(`^type\\s+${symbolName}\\b`), 463 + new RegExp(`^enum\\s+${symbolName}\\b`), 464 + new RegExp(`^(?:const|let|var)\\s+${symbolName}\\b`), 465 + ]; 466 + 467 + for (const pattern of patterns) { 468 + if (pattern.test(line)) { 469 + return i + 1; 470 + } 471 + } 436 472 } 437 473 } catch { 438 474 // Ignore errors ··· 508 544 symbols: ExportedSymbol[], 509 545 rootPath: string, 510 546 ): Promise<void> { 511 - // Group exports by their source file 547 + // Group exports by their source file and deduplicate 512 548 const exportsBySource = new Map<string, TracedExport[]>(); 549 + const seenExports = new Set<string>(); 550 + 513 551 for (const exp of exports) { 552 + // Create a unique key for deduplication 553 + const key = `${exp.sourcePath}:${exp.exportedName}:${exp.originalName}`; 554 + if (seenExports.has(key)) { 555 + continue; // Skip duplicates 556 + } 557 + seenExports.add(key); 558 + 514 559 if (!exportsBySource.has(exp.sourcePath)) { 515 560 exportsBySource.set(exp.sourcePath, []); 516 561 } ··· 534 579 535 580 // Track JSDoc blocks 536 581 if (trimmed.startsWith("/**")) { 537 - currentJSDoc = [trimmed]; 582 + if (trimmed.endsWith("*/")) { 583 + const jsDocContent = trimmed; 584 + if (!jsDocContent.includes("@module")) { 585 + for (let j = i + 1; j < lines.length; j++) { 586 + const nextLine = lines[j].trim(); 587 + if (nextLine && !nextLine.startsWith("//")) { 588 + if ( 589 + isDirectExport(nextLine) || nextLine.startsWith("export ") 590 + ) { 591 + jsDocBlocks.set(j, jsDocContent); 592 + for (let k = j + 1; k <= j + 5 && k < lines.length; k++) { 593 + jsDocBlocks.set(k, jsDocContent); 594 + } 595 + } 596 + break; 597 + } 598 + } 599 + } 600 + } else { 601 + currentJSDoc = [trimmed]; 602 + } 538 603 } else if (currentJSDoc.length > 0) { 539 604 currentJSDoc.push(line); 540 605 if (trimmed.endsWith("*/")) { 541 - // JSDoc block complete, associate with next code line 542 606 const jsDocContent = currentJSDoc.join("\n"); 543 - // Skip module-level JSDoc (contains @module tag) 544 607 if (jsDocContent.includes("@module")) { 545 608 currentJSDoc = []; 546 609 continue; 547 610 } 548 - // Find the next line that starts an export declaration 549 611 for (let j = i + 1; j < lines.length; j++) { 550 612 const nextLine = lines[j].trim(); 551 613 if (nextLine && !nextLine.startsWith("//")) { 552 - // Only associate JSDoc with export declarations 553 614 if ( 554 615 isDirectExport(nextLine) || nextLine.startsWith("export ") 555 616 ) { 556 617 jsDocBlocks.set(j, jsDocContent); 557 - // Mark next 5 lines as having this JSDoc (for multi-line declarations) 558 618 for (let k = j + 1; k <= j + 5 && k < lines.length; k++) { 559 619 jsDocBlocks.set(k, jsDocContent); 560 620 } ··· 605 665 } 606 666 } 607 667 } 668 + } else { 669 + // Check for non-exported declarations that match symbols in sourceExports 670 + for (const exp of sourceExports) { 671 + const patterns = [ 672 + `class ${exp.originalName}`, 673 + `function ${exp.originalName}`, 674 + `const ${exp.originalName}`, 675 + `let ${exp.originalName}`, 676 + `var ${exp.originalName}`, 677 + `interface ${exp.originalName}`, 678 + `type ${exp.originalName}`, 679 + `enum ${exp.originalName}`, 680 + ]; 681 + 682 + for (const pattern of patterns) { 683 + if (trimmed.startsWith(pattern)) { 684 + let fullDeclaration = trimmed; 685 + const declarationStartLine = i; 686 + 687 + if (!trimmed.includes("{") && !trimmed.includes(";")) { 688 + for (let j = i + 1; j < lines.length && j < i + 10; j++) { 689 + fullDeclaration += " " + lines[j].trim(); 690 + if (lines[j].includes("{") || lines[j].includes(";")) { 691 + break; 692 + } 693 + } 694 + } 695 + 696 + const symbol = parseExportedSymbol( 697 + fullDeclaration, 698 + declarationStartLine, 699 + relativePath, 700 + jsDocBlocks, 701 + ); 702 + if (symbol) { 703 + symbol.name = exp.exportedName; 704 + symbols.push(symbol); 705 + } 706 + break; 707 + } 708 + } 709 + } 608 710 } 609 711 } 610 712 } catch { ··· 688 790 689 791 // Track JSDoc blocks 690 792 if (trimmed.startsWith("/**")) { 691 - currentJSDoc = [trimmed]; 793 + if (trimmed.endsWith("*/")) { 794 + const jsDocContent = trimmed; 795 + for (let j = i + 1; j < lines.length; j++) { 796 + if (lines[j].trim() && !lines[j].trim().startsWith("//")) { 797 + jsDocBlocks.set(j, jsDocContent); 798 + break; 799 + } 800 + } 801 + } else { 802 + currentJSDoc = [trimmed]; 803 + } 692 804 } else if (currentJSDoc.length > 0) { 693 805 currentJSDoc.push(line); 694 806 if (trimmed.endsWith("*/")) { 695 - // JSDoc block complete, associate with next code line 696 807 const jsDocContent = currentJSDoc.join("\n"); 697 808 for (let j = i + 1; j < lines.length; j++) { 698 809 if (lines[j].trim() && !lines[j].trim().startsWith("//")) { ··· 820 931 name = exports[0].split(/\s+as\s+/)[0]; 821 932 type = "variable"; // We'd need more context to determine the actual type 822 933 } 934 + } 935 + } // Parse non-exported declarations 936 + else if (trimmed.startsWith("class ")) { 937 + const match = trimmed.match(/class\s+(\w+)/); 938 + if (match) { 939 + name = match[1]; 940 + type = "class"; 941 + } 942 + } else if ( 943 + trimmed.startsWith("function ") || trimmed.startsWith("async function ") 944 + ) { 945 + const match = trimmed.match(/function\s+(\w+)/); 946 + if (match) { 947 + name = match[1]; 948 + type = "function"; 949 + } 950 + } else if (trimmed.startsWith("interface ")) { 951 + const match = trimmed.match(/interface\s+(\w+)/); 952 + if (match) { 953 + name = match[1]; 954 + type = "interface"; 955 + } 956 + } else if (trimmed.startsWith("type ")) { 957 + const match = trimmed.match(/type\s+(\w+)/); 958 + if (match) { 959 + name = match[1]; 960 + type = "type"; 961 + } 962 + } else if (trimmed.startsWith("enum ")) { 963 + const match = trimmed.match(/enum\s+(\w+)/); 964 + if (match) { 965 + name = match[1]; 966 + type = "enum"; 967 + } 968 + } else if ( 969 + trimmed.startsWith("const ") || trimmed.startsWith("let ") || 970 + trimmed.startsWith("var ") 971 + ) { 972 + const match = trimmed.match(/(?:const|let|var)\s+(\w+)/); 973 + if (match) { 974 + name = match[1]; 975 + type = trimmed.startsWith("const") ? "const" : "variable"; 823 976 } 824 977 } 825 978
+7 -1
deno.json
··· 1 1 { 2 2 "name": "@knotbin/doggo", 3 - "version": "0.1.0", 3 + "version": "0.1.1", 4 4 "exports": "./mod.ts", 5 5 "imports": { 6 + "@std/assert": "jsr:@std/assert@^1.0.15", 6 7 "@std/cli": "jsr:@std/cli@^1.0.23", 7 8 "@std/fmt": "jsr:@std/fmt@^1.0.8", 8 9 "@std/fs": "jsr:@std/fs@^1.0.19", 9 10 "@std/path": "jsr:@std/path@^1.1.2" 11 + }, 12 + "test": { 13 + "permissions": { 14 + "read": true 15 + } 10 16 } 11 17 }
+8
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 + "jsr:@std/assert@^1.0.15": "1.0.15", 4 5 "jsr:@std/cli@^1.0.23": "1.0.23", 5 6 "jsr:@std/fmt@^1.0.8": "1.0.8", 6 7 "jsr:@std/fs@^1.0.19": "1.0.19", ··· 12 13 "npm:@types/node@*": "24.2.0" 13 14 }, 14 15 "jsr": { 16 + "@std/assert@1.0.15": { 17 + "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", 18 + "dependencies": [ 19 + "jsr:@std/internal@^1.0.12" 20 + ] 21 + }, 15 22 "@std/cli@1.0.23": { 16 23 "integrity": "bf95b7a9425ba2af1ae5a6359daf58c508f2decf711a76ed2993cd352498ccca", 17 24 "dependencies": [ ··· 137 144 }, 138 145 "workspace": { 139 146 "dependencies": [ 147 + "jsr:@std/assert@^1.0.15", 140 148 "jsr:@std/cli@^1.0.23", 141 149 "jsr:@std/fmt@^1.0.8", 142 150 "jsr:@std/fs@^1.0.19",
+146
tests/core_test.ts
··· 1 + import { assertEquals } from "@std/assert"; 2 + import { analyzeDirectory } from "../core.ts"; 3 + import { join } from "@std/path"; 4 + 5 + Deno.test("analyzeDirectory - finds exported symbols", async () => { 6 + const fixturesPath = join( 7 + Deno.cwd(), 8 + "tests", 9 + "fixtures", 10 + "simple", 11 + "basic.ts", 12 + ); 13 + 14 + const result = await analyzeDirectory(fixturesPath); 15 + 16 + assertEquals(result.symbols.length, 4); 17 + assertEquals(result.hasDenoJson, false); 18 + }); 19 + 20 + Deno.test("analyzeDirectory - detects JSDoc presence", async () => { 21 + const fixturesPath = join( 22 + Deno.cwd(), 23 + "tests", 24 + "fixtures", 25 + "simple", 26 + "basic.ts", 27 + ); 28 + 29 + const result = await analyzeDirectory(fixturesPath); 30 + 31 + const undocumented = result.symbols.filter((s) => !s.hasJSDoc); 32 + assertEquals(undocumented.length, 4); 33 + }); 34 + 35 + Deno.test("analyzeDirectory - detects documented symbols", async () => { 36 + const fixturesPath = join( 37 + Deno.cwd(), 38 + "tests", 39 + "fixtures", 40 + "simple", 41 + "documented.ts", 42 + ); 43 + 44 + const result = await analyzeDirectory(fixturesPath); 45 + 46 + assertEquals(result.symbols.length, 4); 47 + 48 + const documented = result.symbols.filter((s) => s.hasJSDoc); 49 + assertEquals(documented.length, 4); 50 + 51 + assertEquals(result.stats.percentage, 100); 52 + }); 53 + 54 + Deno.test("analyzeDirectory - calculates stats correctly", async () => { 55 + const fixturesPath = join( 56 + Deno.cwd(), 57 + "tests", 58 + "fixtures", 59 + "simple", 60 + "basic.ts", 61 + ); 62 + 63 + const result = await analyzeDirectory(fixturesPath); 64 + 65 + assertEquals(result.stats.total, 4); 66 + assertEquals(result.stats.documented, 0); 67 + assertEquals(result.stats.undocumented, 4); 68 + assertEquals(result.stats.percentage, 0); 69 + }); 70 + 71 + Deno.test("analyzeDirectory - handles different export types", async () => { 72 + const fixturesPath = join( 73 + Deno.cwd(), 74 + "tests", 75 + "fixtures", 76 + "export_blocks", 77 + "export_block.ts", 78 + ); 79 + 80 + const result = await analyzeDirectory(fixturesPath); 81 + 82 + assertEquals(result.symbols.length, 3); 83 + 84 + const classSymbol = result.symbols.find((s) => s.name === "MyClass"); 85 + assertEquals(classSymbol?.type, "class"); 86 + 87 + const functionSymbol = result.symbols.find((s) => s.name === "myFunction"); 88 + assertEquals(functionSymbol?.type, "function"); 89 + 90 + const constSymbol = result.symbols.find((s) => s.name === "myConst"); 91 + assertEquals(constSymbol?.type, "const"); 92 + }); 93 + 94 + Deno.test("analyzeDirectory - uses deno.json exports field", async () => { 95 + const fixturesPath = join(Deno.cwd(), "tests", "fixtures", "with_config"); 96 + 97 + const result = await analyzeDirectory(fixturesPath); 98 + 99 + assertEquals(result.hasDenoJson, true); 100 + assertEquals(result.hasExports, true); 101 + assertEquals(result.exportPath, "./reexport.ts"); 102 + }); 103 + 104 + Deno.test("analyzeDirectory - traces re-exports", async () => { 105 + const fixturesPath = join(Deno.cwd(), "tests", "fixtures", "with_config"); 106 + 107 + const result = await analyzeDirectory(fixturesPath); 108 + 109 + const symbolNames = result.symbols.map((s) => s.name).sort(); 110 + 111 + assertEquals(symbolNames.includes("documentedFunction"), true); 112 + assertEquals(symbolNames.includes("UndocumentedClass"), true); 113 + assertEquals(symbolNames.includes("DocumentedInterface"), true); 114 + assertEquals(symbolNames.includes("undocumentedConst"), true); 115 + assertEquals(symbolNames.includes("renamedFunction"), true); 116 + }); 117 + 118 + Deno.test("analyzeDirectory - avoids duplicate exports", async () => { 119 + const fixturesPath = join(Deno.cwd(), "tests", "fixtures", "with_config"); 120 + 121 + const result = await analyzeDirectory(fixturesPath); 122 + 123 + const symbolNames = result.symbols.map((s) => s.name); 124 + const uniqueNames = new Set(symbolNames); 125 + 126 + assertEquals(symbolNames.length, uniqueNames.size); 127 + }); 128 + 129 + Deno.test("analyzeDirectory - tracks symbol types correctly", async () => { 130 + const fixturesPath = join( 131 + Deno.cwd(), 132 + "tests", 133 + "fixtures", 134 + "simple", 135 + "basic.ts", 136 + ); 137 + 138 + const result = await analyzeDirectory(fixturesPath); 139 + 140 + const byType = result.stats.byType; 141 + 142 + assertEquals(byType["function"]?.total, 1); 143 + assertEquals(byType["class"]?.total, 1); 144 + assertEquals(byType["interface"]?.total, 1); 145 + assertEquals(byType["const"]?.total, 1); 146 + });
+9
tests/fixtures/export_blocks/export_block.ts
··· 1 + export class MyClass { 2 + constructor(public value: number) {} 3 + } 4 + 5 + export function myFunction(): string { 6 + return "test"; 7 + } 8 + 9 + export const myConst = 123;
+18
tests/fixtures/simple/basic.ts
··· 1 + export function documentedFunction(x: number): number { 2 + return x * 2; 3 + } 4 + 5 + export class UndocumentedClass { 6 + constructor(public name: string) {} 7 + 8 + greet(): string { 9 + return `Hello, ${this.name}`; 10 + } 11 + } 12 + 13 + export interface DocumentedInterface { 14 + id: string; 15 + value: number; 16 + } 17 + 18 + export const undocumentedConst = 42;
+32
tests/fixtures/simple/documented.ts
··· 1 + /** 2 + * A well documented function 3 + * @param x - The input number 4 + * @returns The doubled value 5 + */ 6 + export function documentedFunction(x: number): number { 7 + return x * 2; 8 + } 9 + 10 + /** 11 + * A documented class with proper JSDoc 12 + */ 13 + export class DocumentedClass { 14 + constructor(public name: string) {} 15 + 16 + greet(): string { 17 + return `Hello, ${this.name}`; 18 + } 19 + } 20 + 21 + /** 22 + * A documented interface 23 + */ 24 + export interface DocumentedInterface { 25 + id: string; 26 + value: number; 27 + } 28 + 29 + /** 30 + * A documented constant 31 + */ 32 + export const documentedConst = 42;
+18
tests/fixtures/with_config/basic.ts
··· 1 + export function documentedFunction(x: number): number { 2 + return x * 2; 3 + } 4 + 5 + export class UndocumentedClass { 6 + constructor(public name: string) {} 7 + 8 + greet(): string { 9 + return `Hello, ${this.name}`; 10 + } 11 + } 12 + 13 + export interface DocumentedInterface { 14 + id: string; 15 + value: number; 16 + } 17 + 18 + export const undocumentedConst = 42;
+3
tests/fixtures/with_config/deno.json
··· 1 + { 2 + "exports": "./reexport.ts" 3 + }
+11
tests/fixtures/with_config/export_block.ts
··· 1 + class MyClass { 2 + constructor(public value: number) {} 3 + } 4 + 5 + function myFunction(): string { 6 + return "test"; 7 + } 8 + 9 + const myConst = 123; 10 + 11 + export { MyClass, myFunction, myConst };
+2
tests/fixtures/with_config/reexport.ts
··· 1 + export * from "./basic.ts"; 2 + export { myFunction as renamedFunction } from "./export_block.ts";