syntaxai/tdd.md · main · src / c14_go_graph_depth.ts
// c14 — adapter: builds a package-directory dependency DAG for a Go
// module rooted at a given path, then computes graphDepth via the
// pure helper in b32_graph_depth_polyglot.ts.
//
// Module-granularity per /sama/v2 §5 (operational) — see also the
// comment at the top of b32_graph_depth_polyglot.ts. The TS metric
// works at file level because TS module ≈ file. Go's natural unit
// is the PACKAGE DIRECTORY: multiple .go files in one directory
// share their package (and therefore their imports). graphDepth
// here = longest path through the package-directory dependency
// graph, restricted to intra-module edges (imports that begin with
// the module's import path).
//
// Algorithm:
// 1. Read <root>/go.mod, parse `module <path>` to get module path.
// 2. Walk every non-test .go file under <root>, skipping .git/,
// vendor/, node_modules/.
// 3. For each file, parse its `import "..."` or `import (...)`
// block; collect all imported package paths.
// 4. For each import that starts with `<module-path>/`, derive
// the imported directory (repo-relative) by stripping the
// module path prefix.
// 5. The importing file's package directory = its repo-relative
// directory. Add edge (importing-dir → imported-dir).
// 6. Deduplicate edges (multiple files in one dir importing the
// same target = one edge in the package DAG).
// 7. Pass to computeGraphDepth.
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, resolve } from "node:path";
import {
computeGraphDepth,
type GraphDepthResult,
} from "./b32_graph_depth_polyglot.ts";
const SKIPPED_DIRS: ReadonlySet<string> = new Set([
".git",
"vendor",
"node_modules",
]);
interface GoFile {
// Repo-relative path, e.g. "dive/filetree/comparer.go".
path: string;
content: string;
}
const collectGoFiles = (root: string): GoFile[] => {
const out: GoFile[] = [];
const walk = (absDir: string, relDir: string): void => {
let entries: ReturnType<typeof readdirSync>;
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (e.isDirectory()) {
if (e.name.startsWith(".")) continue;
if (SKIPPED_DIRS.has(e.name)) continue;
const sub = resolve(absDir, e.name);
const subRel = relDir === "" ? e.name : `${relDir}/${e.name}`;
walk(sub, subRel);
continue;
}
if (!e.isFile()) continue;
if (!e.name.endsWith(".go")) continue;
if (e.name.endsWith("_test.go")) continue;
const abs = resolve(absDir, e.name);
const repoPath = relDir === "" ? e.name : `${relDir}/${e.name}`;
out.push({ path: repoPath, content: readFileSync(abs, "utf8") });
}
};
walk(resolve(root), "");
return out;
};
// Parse `module <path>` from go.mod content.
export const parseGoModulePath = (gomod: string): string => {
// go.mod is line-oriented; the directive is `module <path>` near
// the top, possibly preceded by comments.
const lines = gomod.split("\n");
for (const raw of lines) {
const line = raw.replace(/\/\/.*$/, "").trim();
if (line.startsWith("module ")) {
// "module github.com/wagoodman/dive" or "module \"...\""
const rest = line.slice(7).trim();
if (rest.startsWith('"') && rest.endsWith('"')) {
return rest.slice(1, -1);
}
return rest;
}
}
throw new Error("go.mod does not declare a `module` path");
};
// Extract every import path string from one .go file. Handles both
// single-line `import "x"` and the block form `import (...)` with
// possibly an alias before the string. Strips line comments first
// (// ...) so a commented-out import doesn't false-positive.
export const collectGoImports = (content: string): string[] => {
// Drop // line comments and /* block comments */.
const stripped = content
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/[^\n]*/g, "");
const out: string[] = [];
// Single-line: import "..." or import alias "..."
const singleRe = /^\s*import\s+(?:[A-Za-z_][\w]*\s+)?"([^"]+)"\s*$/gm;
let m: RegExpExecArray | null;
while ((m = singleRe.exec(stripped)) !== null) {
if (m[1]) out.push(m[1]);
}
// Block form: import ( ... )
const blockRe = /^\s*import\s*\(\s*([\s\S]*?)\s*\)/gm;
while ((m = blockRe.exec(stripped)) !== null) {
const body = m[1] ?? "";
const lineRe = /^\s*(?:[A-Za-z_][\w]*\s+)?"([^"]+)"\s*$/gm;
let lm: RegExpExecArray | null;
while ((lm = lineRe.exec(body)) !== null) {
if (lm[1]) out.push(lm[1]);
}
}
return out;
};
export interface GoGraphDepthResult extends GraphDepthResult {
language: "go";
modulePath: string;
}
export const computeGoGraphDepth = (repoRoot: string): GoGraphDepthResult => {
const root = resolve(repoRoot);
const rootStat = statSync(root);
if (!rootStat.isDirectory()) {
throw new Error(`expected a directory, got: ${repoRoot}`);
}
const gomodPath = resolve(root, "go.mod");
const gomod = readFileSync(gomodPath, "utf8");
const modulePath = parseGoModulePath(gomod);
const files = collectGoFiles(root);
// Edges: (importing-dir, imported-dir). Deduplicate per-package.
const nodeSet = new Set<string>();
const edgeSet = new Set<string>(); // key = "from→to"
const edges: Array<[string, string]> = [];
for (const f of files) {
const fromDir = dirname(f.path);
nodeSet.add(fromDir);
for (const imp of collectGoImports(f.content)) {
if (imp === modulePath) {
// Root-package import (rare). Represented as repo-root dir "".
const toDir = "";
nodeSet.add(toDir);
const key = `${fromDir} ${toDir}`;
if (fromDir !== toDir && !edgeSet.has(key)) {
edgeSet.add(key);
edges.push([fromDir, toDir]);
}
continue;
}
if (imp.startsWith(modulePath + "/")) {
const toDir = imp.slice(modulePath.length + 1);
nodeSet.add(toDir);
const key = `${fromDir} ${toDir}`;
if (fromDir !== toDir && !edgeSet.has(key)) {
edgeSet.add(key);
edges.push([fromDir, toDir]);
}
}
}
}
// A pure helper that has no IMPORTED-only directories will declare
// them too via the nodeSet (we added every imported intra-module
// package). Good — the depth count includes leaves that don't
// themselves import anything intra-module.
const result = computeGraphDepth({
nodes: [...nodeSet],
edges,
});
return {
...result,
language: "go",
modulePath,
};
};