From c0d150704e44e340a169c642e777f6fdfcb4cef5 Mon Sep 17 00:00:00 2001 From: clockblocker Date: Sun, 22 Feb 2026 05:52:13 +0100 Subject: [PATCH] Add --json/--output flags, export pure functions, and add unit tests for knowledge-silo - Add --json flag for machine-readable JSON output (CI integration) - Add --output= flag for writing directly to a file - Export classifyModule, computeBusFactor, detectSilos, aggregateByModule as named exports for testability - Guard main() with import.meta.main so imports don't trigger execution - Add 20 unit tests covering all four pure functions - Generate and commit .nightshift/knowledge-silo-report.md Nightshift-Task: knowledge-silo Nightshift-Ref: https://github.com/marcus/nightshift Co-Authored-By: Claude Opus 4.6 --- .nightshift/knowledge-silo-report.md | 185 ++++++++++++++++++ scripts/knowledge-silo.ts | 85 +++++++-- tests/unit/knowledge-silo.test.ts | 275 +++++++++++++++++++++++++++ 3 files changed, 524 insertions(+), 21 deletions(-) create mode 100644 .nightshift/knowledge-silo-report.md create mode 100644 tests/unit/knowledge-silo.test.ts diff --git a/.nightshift/knowledge-silo-report.md b/.nightshift/knowledge-silo-report.md new file mode 100644 index 000000000..e8d22b22c --- /dev/null +++ b/.nightshift/knowledge-silo-report.md @@ -0,0 +1,185 @@ +# Knowledge Silo Analysis + +> Generated: 2026-02-22 | Silo threshold: 80% | Recency window: 90 days + +## Per-Module Bus Factor + +| Module | Bus Factor | Top Author | Top % | Commits | Lines | +|--------|----------:|-----------:|------:|--------:|------:| +| src/commanders/textfresser | 1 | clockblocker | 100.0% | 500 | 20550 | +| src/documentaion | 1 | clockblocker | 100.0% | 139 | 12863 | +| src/main.ts | 1 | clockblocker | 100.0% | 249 | 10446 | +| src/types.ts | 1 | clockblocker | 100.0% | 32 | 265 | +| src/linguistics | 1 | clockblocker | 100.0% | 118 | 3441 | +| src/prompt-smith/codegen | 1 | clockblocker | 100.0% | 181 | 5136 | +| src/prompt-smith/prompt-parts | 1 | clockblocker | 100.0% | 361 | 5574 | +| src/prompt-smith/schemas | 1 | clockblocker | 100.0% | 77 | 757 | +| src/prompt-smith/index.ts | 1 | clockblocker | 100.0% | 18 | 237 | +| src/commanders/librarian | 1 | clockblocker | 100.0% | 1315 | 57537 | +| src/stateless-helpers | 1 | clockblocker | 100.0% | 85 | 2853 | +| src/managers/obsidian | 1 | clockblocker | 100.0% | 554 | 21034 | +| src/types | 1 | clockblocker | 100.0% | 227 | 10617 | +| src/utils | 1 | clockblocker | 100.0% | 17 | 590 | +| src/managers/overlay-manager | 1 | clockblocker | 100.0% | 144 | 3284 | +| src/main-stripped.ts | 1 | clockblocker | 100.0% | 5 | 31 | +| src/prompt-smith/types.ts | 1 | clockblocker | 100.0% | 5 | 39 | +| src/global-state | 1 | clockblocker | 100.0% | 11 | 127 | +| src/todo.md | 1 | clockblocker | 100.0% | 7 | 121 | + +## Identified Knowledge Silos + +### 🔴 src/commanders/textfresser — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 500 +- **Last other-author commit**: never + +### 🔴 src/documentaion — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 139 +- **Last other-author commit**: never + +### 🔴 src/main.ts — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 249 +- **Last other-author commit**: never + +### 🔴 src/types.ts — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 32 +- **Last other-author commit**: never + +### 🔴 src/linguistics — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 118 +- **Last other-author commit**: never + +### 🔴 src/prompt-smith/codegen — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 181 +- **Last other-author commit**: never + +### 🔴 src/prompt-smith/prompt-parts — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 361 +- **Last other-author commit**: never + +### 🔴 src/prompt-smith/schemas — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 77 +- **Last other-author commit**: never + +### 🔴 src/prompt-smith/index.ts — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 18 +- **Last other-author commit**: never + +### 🔴 src/commanders/librarian — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 1315 +- **Last other-author commit**: never + +### 🔴 src/stateless-helpers — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 85 +- **Last other-author commit**: never + +### 🔴 src/managers/obsidian — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 554 +- **Last other-author commit**: never + +### 🔴 src/types — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 227 +- **Last other-author commit**: never + +### 🔴 src/utils — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 17 +- **Last other-author commit**: never + +### 🔴 src/managers/overlay-manager — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 144 +- **Last other-author commit**: never + +### 🔴 src/main-stripped.ts — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 5 +- **Last other-author commit**: never + +### 🔴 src/prompt-smith/types.ts — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 5 +- **Last other-author commit**: never + +### 🔴 src/global-state — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 11 +- **Last other-author commit**: never + +### 🔴 src/todo.md — HIGH + +- **Top author**: clockblocker (100.0% of commits) +- **Bus factor**: 1 +- **Total commits**: 7 +- **Last other-author commit**: never + +## Recommended Cross-Training Areas + +**Priority 1 — Immediate attention:** +- `src/commanders/textfresser`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/documentaion`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/main.ts`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/types.ts`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/linguistics`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/prompt-smith/codegen`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/prompt-smith/prompt-parts`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/prompt-smith/schemas`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/prompt-smith/index.ts`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/commanders/librarian`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/stateless-helpers`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/managers/obsidian`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/types`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/utils`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/managers/overlay-manager`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/main-stripped.ts`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/prompt-smith/types.ts`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/global-state`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. +- `src/todo.md`: Only clockblocker has meaningful ownership. Pair-program or do code reviews with a second contributor. diff --git a/scripts/knowledge-silo.ts b/scripts/knowledge-silo.ts index b8d5bdf59..a7c6f5dda 100644 --- a/scripts/knowledge-silo.ts +++ b/scripts/knowledge-silo.ts @@ -6,25 +6,32 @@ * Analyzes git history to detect modules where a single contributor dominates * ownership, indicating a "knowledge silo" risk (low bus factor). * - * Usage: bun scripts/knowledge-silo.ts [--days=N] [--threshold=N] + * Usage: bun scripts/knowledge-silo.ts [--days=N] [--threshold=N] [--json] [--output=] * --days Recency window for silo detection (default: 90) * --threshold Ownership % above which a single author is flagged (default: 80) + * --json Output machine-readable JSON instead of markdown + * --output Write output to a file instead of stdout */ -import { existsSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; import { $ } from "bun"; // ── Config ────────────────────────────────────────────────────────────────── -interface CliArgs { +export interface CliArgs { recencyDays: number; siloThreshold: number; + json: boolean; + output: string | null; } function parseArgs(): CliArgs { const args = process.argv.slice(2); let recencyDays = 90; let siloThreshold = 80; + let json = false; + let output: string | null = null; for (const arg of args) { const daysMatch = arg.match(/^--days=(\d+)$/); @@ -37,30 +44,39 @@ function parseArgs(): CliArgs { siloThreshold = Number(thresholdMatch[1]); continue; } + if (arg === "--json") { + json = true; + continue; + } + const outputMatch = arg.match(/^--output=(.+)$/); + if (outputMatch) { + output = outputMatch[1]!; + continue; + } } - return { recencyDays, siloThreshold }; + return { json, output, recencyDays, siloThreshold }; } // ── Types ─────────────────────────────────────────────────────────────────── -interface AuthorStats { +export interface AuthorStats { linesAdded: number; linesDeleted: number; commits: number; lastCommitDate: Date; } -interface FileStats { +export interface FileStats { authors: Map; } -interface ModuleStats { +export interface ModuleStats { files: number; authors: Map; } -interface SiloReport { +export interface SiloReport { module: string; busFactor: number; topAuthor: string; @@ -75,7 +91,7 @@ interface SiloReport { // ── Module Classification ─────────────────────────────────────────────────── /** Maps a file path to its logical module name (2-level deep for key dirs). */ -function classifyModule(filePath: string): string | null { +export function classifyModule(filePath: string): string | null { if (!filePath.startsWith("src/")) return null; const parts = filePath.replace("src/", "").split("/"); @@ -165,7 +181,7 @@ async function parseGitLog(): Promise> { // ── Aggregation ───────────────────────────────────────────────────────────── -function aggregateByModule( +export function aggregateByModule( fileStats: Map, ): Map { const modules = new Map(); @@ -210,7 +226,7 @@ function aggregateByModule( * Bus factor = minimum number of authors whose combined commit share * exceeds 50% of the module's total commits. */ -function computeBusFactor(authors: Map): number { +export function computeBusFactor(authors: Map): number { const totalCommits = [...authors.values()].reduce( (sum, a) => sum + a.commits, 0, @@ -232,11 +248,11 @@ function computeBusFactor(authors: Map): number { // ── Silo Detection ───────────────────────────────────────────────────────── -function detectSilos( +export function detectSilos( modules: Map, - config: CliArgs, + config: Pick, + now: Date = new Date(), ): SiloReport[] { - const now = new Date(); const reports: SiloReport[] = []; for (const [moduleName, mod] of modules) { @@ -408,6 +424,19 @@ function formatReport(reports: SiloReport[], config: CliArgs): string { return lines.join("\n"); } +// ── JSON Report ───────────────────────────────────────────────────────────── + +function formatJson(reports: SiloReport[]): string { + return JSON.stringify( + reports.map((r) => ({ + ...r, + lastOtherAuthorDate: r.lastOtherAuthorDate?.toISOString() ?? null, + })), + null, + 2, + ); +} + // ── Main ──────────────────────────────────────────────────────────────────── async function main() { @@ -424,12 +453,26 @@ async function main() { } const reports = detectSilos(modules, config); - const markdown = formatReport(reports, config); - - console.log(markdown); + const output = config.json + ? formatJson(reports) + : formatReport(reports, config); + + if (config.output) { + const dir = dirname(config.output); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(config.output, output, "utf-8"); + console.log(`Report written to ${config.output}`); + } else { + console.log(output); + } } -main().catch((err) => { - console.error("Failed to run knowledge silo analysis:", err); - process.exit(1); -}); +// Only run main when executed directly (not imported for tests) +if (import.meta.main) { + main().catch((err) => { + console.error("Failed to run knowledge silo analysis:", err); + process.exit(1); + }); +} diff --git a/tests/unit/knowledge-silo.test.ts b/tests/unit/knowledge-silo.test.ts new file mode 100644 index 000000000..0b2753805 --- /dev/null +++ b/tests/unit/knowledge-silo.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "bun:test"; +import { + type AuthorStats, + aggregateByModule, + classifyModule, + computeBusFactor, + detectSilos, + type FileStats, + type ModuleStats, +} from "../../scripts/knowledge-silo"; + +// ── helpers ───────────────────────────────────────────────────────────────── + +function makeAuthor( + commits: number, + opts?: Partial, +): AuthorStats { + return { + commits, + lastCommitDate: opts?.lastCommitDate ?? new Date("2025-06-01"), + linesAdded: opts?.linesAdded ?? commits * 10, + linesDeleted: opts?.linesDeleted ?? commits * 2, + }; +} + +// ── classifyModule ────────────────────────────────────────────────────────── + +describe("classifyModule", () => { + it("returns null for non-src paths", () => { + expect(classifyModule("lib/foo/bar.ts")).toBeNull(); + expect(classifyModule("tests/unit/foo.test.ts")).toBeNull(); + }); + + it("returns top-level module for generic src files", () => { + expect(classifyModule("src/utils/helpers.ts")).toBe("src/utils"); + expect(classifyModule("src/types/index.ts")).toBe("src/types"); + }); + + it("returns single-level for files directly under src/", () => { + expect(classifyModule("src/main.ts")).toBe("src/main.ts"); + }); + + it("uses two levels for commanders/", () => { + expect(classifyModule("src/commanders/librarian/index.ts")).toBe( + "src/commanders/librarian", + ); + expect(classifyModule("src/commanders/textfresser/foo.ts")).toBe( + "src/commanders/textfresser", + ); + }); + + it("uses two levels for managers/", () => { + expect(classifyModule("src/managers/obsidian/vault.ts")).toBe( + "src/managers/obsidian", + ); + }); + + it("uses two levels for prompt-smith known subdirs", () => { + expect(classifyModule("src/prompt-smith/codegen/gen.ts")).toBe( + "src/prompt-smith/codegen", + ); + expect(classifyModule("src/prompt-smith/schemas/foo.ts")).toBe( + "src/prompt-smith/schemas", + ); + expect(classifyModule("src/prompt-smith/prompt-parts/bar.ts")).toBe( + "src/prompt-smith/prompt-parts", + ); + }); + + it("uses two levels for unknown prompt-smith subdirs", () => { + expect(classifyModule("src/prompt-smith/other/baz.ts")).toBe( + "src/prompt-smith/other", + ); + }); +}); + +// ── computeBusFactor ──────────────────────────────────────────────────────── + +describe("computeBusFactor", () => { + it("returns 0 for empty author map", () => { + expect(computeBusFactor(new Map())).toBe(0); + }); + + it("returns 1 for a single author", () => { + const authors = new Map([["alice", makeAuthor(50)]]); + expect(computeBusFactor(authors)).toBe(1); + }); + + it("returns 1 when one author dominates", () => { + const authors = new Map([ + ["alice", makeAuthor(90)], + ["bob", makeAuthor(10)], + ]); + expect(computeBusFactor(authors)).toBe(1); + }); + + it("returns 2 when two authors are needed for >50%", () => { + const authors = new Map([ + ["alice", makeAuthor(40)], + ["bob", makeAuthor(35)], + ["carol", makeAuthor(25)], + ]); + // sorted: alice(40), bob(35), carol(25) — alice alone = 40% ≤ 50%, alice+bob = 75% > 50% + expect(computeBusFactor(authors)).toBe(2); + }); + + it("returns 1 when top author has exactly >50%", () => { + const authors = new Map([ + ["alice", makeAuthor(51)], + ["bob", makeAuthor(49)], + ]); + expect(computeBusFactor(authors)).toBe(1); + }); +}); + +// ── aggregateByModule ─────────────────────────────────────────────────────── + +describe("aggregateByModule", () => { + it("aggregates files into their module", () => { + const fileStats = new Map([ + [ + "src/utils/a.ts", + { authors: new Map([["alice", makeAuthor(5)]]) }, + ], + [ + "src/utils/b.ts", + { authors: new Map([["alice", makeAuthor(3)]]) }, + ], + ]); + + const modules = aggregateByModule(fileStats); + const utilsMod = modules.get("src/utils"); + expect(utilsMod).toBeDefined(); + expect(utilsMod!.files).toBe(2); + expect(utilsMod!.authors.get("alice")!.commits).toBe(8); + }); + + it("skips non-src files", () => { + const fileStats = new Map([ + [ + "tests/foo.ts", + { authors: new Map([["alice", makeAuthor(5)]]) }, + ], + ]); + + const modules = aggregateByModule(fileStats); + expect(modules.size).toBe(0); + }); + + it("merges multiple authors across files", () => { + const fileStats = new Map([ + [ + "src/types/a.ts", + { authors: new Map([["alice", makeAuthor(10)]]) }, + ], + [ + "src/types/b.ts", + { + authors: new Map([ + ["alice", makeAuthor(5)], + ["bob", makeAuthor(3)], + ]), + }, + ], + ]); + + const modules = aggregateByModule(fileStats); + const typesMod = modules.get("src/types"); + expect(typesMod!.authors.size).toBe(2); + expect(typesMod!.authors.get("alice")!.commits).toBe(15); + expect(typesMod!.authors.get("bob")!.commits).toBe(3); + }); +}); + +// ── detectSilos ───────────────────────────────────────────────────────────── + +describe("detectSilos", () => { + const defaultConfig = { recencyDays: 90, siloThreshold: 80 }; + const now = new Date("2025-09-01"); + + it("skips modules with fewer than 5 commits", () => { + const modules = new Map([ + [ + "src/tiny", + { authors: new Map([["alice", makeAuthor(4)]]), files: 1 }, + ], + ]); + + const silos = detectSilos(modules, defaultConfig, now); + expect(silos).toHaveLength(0); + }); + + it("marks high risk when single author owns 100% and no other authors", () => { + const modules = new Map([ + [ + "src/solo", + { authors: new Map([["alice", makeAuthor(20)]]), files: 3 }, + ], + ]); + + const silos = detectSilos(modules, defaultConfig, now); + expect(silos).toHaveLength(1); + expect(silos[0]!.riskLevel).toBe("high"); + expect(silos[0]!.topAuthor).toBe("alice"); + expect(silos[0]!.topAuthorPct).toBe(100); + expect(silos[0]!.busFactor).toBe(1); + }); + + it("marks medium risk when top author >= threshold but other author is recent", () => { + const recentDate = new Date("2025-08-15"); // within 90 days of `now` + const modules = new Map([ + [ + "src/mostly-one", + { + authors: new Map([ + ["alice", makeAuthor(18)], + ["bob", makeAuthor(2, { lastCommitDate: recentDate })], + ]), + files: 5, + }, + ], + ]); + + const silos = detectSilos(modules, defaultConfig, now); + expect(silos).toHaveLength(1); + expect(silos[0]!.riskLevel).toBe("medium"); + }); + + it("marks low risk when no single author dominates", () => { + const modules = new Map([ + [ + "src/shared", + { + authors: new Map([ + ["alice", makeAuthor(10)], + ["bob", makeAuthor(8)], + ["carol", makeAuthor(7)], + ]), + files: 5, + }, + ], + ]); + + const silos = detectSilos(modules, defaultConfig, now); + expect(silos).toHaveLength(1); + expect(silos[0]!.riskLevel).toBe("low"); + }); + + it("sorts results by risk level then by topAuthorPct descending", () => { + const modules = new Map([ + [ + "src/low-risk", + { + authors: new Map([ + ["alice", makeAuthor(10)], + ["bob", makeAuthor(8)], + ["carol", makeAuthor(7)], + ]), + files: 5, + }, + ], + [ + "src/high-risk", + { + authors: new Map([["alice", makeAuthor(20)]]), + files: 3, + }, + ], + ]); + + const silos = detectSilos(modules, defaultConfig, now); + expect(silos[0]!.module).toBe("src/high-risk"); + expect(silos[1]!.module).toBe("src/low-risk"); + }); +});