syntaxai/tdd.md · main · src / c14_go_graph_depth.ts

c14_go_graph_depth.ts 192 lines · 6602 bytes raw
// 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,
  };
};