zz_

app/engine/ingest.ts

zzmq.dev@gmail.com/zz / app / engine / ingest.ts @ f1f62250 Download
Ladder Summary
  • file The file implements the “ingest” stage of the Ladder engine: a set of synchronous, side‑effect‑only utilities that clone a repository if needed and pull raw metadata from Git and GitHub (issues, PRs, comments, commit messages, releases, contributors, tag‑to‑commit resolution, and `package.json` dependencies) without invoking any language model. All functions are pure data‑gathering wrappers around the `git` and `gh` CLIs (or Node fs) and return typed objects for downstream analysis. **Exported members** - **Functions**: `cloneIfMissing`, `fetchIssues`, `fetchPullRequests`, `fetchThreadComments`, `fetchContributorCommitMessages`, `fetchReleases`, `fetchContributors`, `fetchDependentsSummary`, `resolveTagCommit`, `parseDependencies` - **Interfaces** (type exports): `IssueRow`, `PrRow`, `CommentRow`, `ReleaseRow`, `ContributorRow`, `DependencyRow`, `UsageSite` No classes are exported.
 // The ingest/analysis workflow, as named functions, not ad-hoc shell
 // exploration run by hand during a conversation. Everything here is
 // mechanical data-gathering only (git/gh calls, no model calls) -- the
 // actual content analysis happens in the callers (cli/commands/ladder-
 // build.ts, cli/commands/ladder-replay-commits.ts) via engine/model.ts.
 import { execFileSync } from "node:child_process";
 import { existsSync, readFileSync } from "node:fs";
 import { join } from "node:path";
 import * as git from "isomorphic-git";
 import fs from "node:fs";
 import type { Rung } from "./ladder.js";
 
 // --- cloning: a real local `git clone`, not isomorphic-git -----------
 // This step runs on whoever's machine is doing the onboarding (a CLI user,
 // or eventually a Worker fetching from GitHub some other way), not inside
 // the Ladder engine itself, so there's no Workers-compatibility reason to
 // avoid the real git binary here the way engine/gitNotes.ts does.
 
 export function cloneIfMissing(slug: string, repoDir: string): void {
  if (existsSync(repoDir)) return;
  execFileSync("git", ["clone", "--quiet", `https://github.com/${slug}.git`, repoDir], { stdio: "inherit" });
 }
 
 // --- GitHub API access, via the gh CLI --------------------------------
 // A CLI-local convenience (shells out, needs `gh` installed and
 // authenticated). The real target engine would call the GitHub REST API
 // with fetch() and a token instead, which works the same in a Worker; this
 // is the one tier of this file that's deliberately not Workers-portable,
 // and is called out as such rather than left implicit.
 
 function gh(args: string[]): string {
  return execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
 }
 
 function ghJsonLines<T>(apiPath: string, jq: string): T[] {
  const raw = gh(["api", apiPath, "--paginate", "--jq", jq]);
  return raw
  .trim()
  .split("\n")
  .filter(Boolean)
  .map((line) => JSON.parse(line) as T);
 }
 
 export interface IssueRow {
  number: number;
  title: string;
  state: string;
  user: string;
  created_at: string;
  closed_at: string | null;
  comments: number;
  body: string | null;
 }
 
 export function fetchIssues(slug: string): IssueRow[] {
  return ghJsonLines<IssueRow>(
  `repos/${slug}/issues?state=all&per_page=100`,
  ".[] | select(.pull_request == null) | {number, title, state, user: .user.login, created_at, closed_at, comments, body}",
  );
 }
 
 export interface PrRow {
  number: number;
  title: string;
  state: string;
  merged_at: string | null;
  user: string;
  created_at: string;
  closed_at: string | null;
  body: string | null;
 }
 
 export function fetchPullRequests(slug: string): PrRow[] {
  return ghJsonLines<PrRow>(`repos/${slug}/pulls?state=all&per_page=100`, ".[] | {number, title, state, merged_at, user: .user.login, created_at, closed_at, body}");
 }
 
 export interface CommentRow {
  author: string;
  body: string;
 }
 
 /** The comment thread for an issue or PR (GitHub serves both through the same issues/comments endpoint). Zero-cost: no model call, just data. */
 export function fetchThreadComments(slug: string, number: number): CommentRow[] {
  return ghJsonLines<CommentRow>(`repos/${slug}/issues/${number}/comments?per_page=100`, ".[] | {author: .user.login, body}");
 }
 
 /** A contributor's own commit messages, used to describe what they worked on. Zero-cost: no model call, just data. */
 export function fetchContributorCommitMessages(slug: string, login: string): string[] {
  // jq extracts just the first line of each message itself (raw-output,
  // so gh prints it unquoted): a multi-line commit message's embedded
  // newlines would otherwise corrupt the one-line-per-commit split below.
  const raw = gh(["api", `repos/${slug}/commits?author=${encodeURIComponent(login)}&per_page=100`, "--paginate", "--jq", '.[].commit.message | split("\\n")[0]']);
  return raw.trim().split("\n").filter(Boolean);
 }
 
 export interface ReleaseRow {
  tag_name: string;
  published_at: string;
  author: string;
  body: string | null;
 }
 
 export function fetchReleases(slug: string): ReleaseRow[] {
  return ghJsonLines<ReleaseRow>(`repos/${slug}/releases?per_page=100`, ".[] | {tag_name, published_at, author: .author.login, body}");
 }
 
 export interface ContributorRow {
  login: string;
  contributions: number;
 }
 
 export function fetchContributors(slug: string): ContributorRow[] {
  return ghJsonLines<ContributorRow>(`repos/${slug}/contributors?per_page=100`, ".[] | {login, contributions}");
 }
 
 /**
  * Not implemented: GitHub's dependents graph (network/dependents) has no
  * stable REST/GraphQL endpoint, only the interactive web page. The one
  * real count this project has (supertest: ~1.74M dependent repos) was
  * pulled by hand via a one-off page fetch, not this function, and isn't
  * something a plain script can reproduce unattended. Returns null rather
  * than fabricating a number for a repo this hasn't actually been checked
  * against.
  */
 export function fetchDependentsSummary(_slug: string): string | null {
  return null;
 }
 
 // --- real commit SHA for a release tag --------------------------------
 
 export function resolveTagCommit(repoDir: string, tag: string): string {
  return execFileSync("git", ["-C", repoDir, "rev-parse", `${tag}^{commit}`], { encoding: "utf8" }).trim();
 }
 
 // --- dependencies, from package.json -----------------------------------
 
 export interface DependencyRow {
  name: string;
  versionRange: string;
 }
 
 export function parseDependencies(repoDir: string): DependencyRow[] {
  const path = join(repoDir, "package.json");
  if (!existsSync(path)) return [];
  const pkg = JSON.parse(readFileSync(path, "utf8"));
  const deps: Record<string, string> = pkg.dependencies ?? {};
  return Object.entries(deps).map(([name, versionRange]) => ({ name, versionRange }));
 }
 
 export interface UsageSite {
  file: string;
  line: string;
 }
 
 /** Where a dependency is actually imported/required in the tracked source. Zero-cost: a grep, no model call. */
 export function findDependencyUsage(repoDir: string, name: string): UsageSite[] {
  let raw: string;
  try {
  raw = execFileSync(
  "grep",
  ["-rn", "-F", "--include=*.js", "--include=*.ts", "--include=*.mjs", "--include=*.cjs", "--exclude-dir=node_modules", "--exclude-dir=.git", name, repoDir],
  { encoding: "utf8" },
  );
  } catch {
  return []; // grep exits 1 when there are no matches
  }
  return raw
  .trim()
  .split("\n")
  .filter(Boolean)
  .map((entry) => {
  const [file, , ...rest] = entry.split(":");
  return { file: file.replace(`${repoDir}/`, ""), line: rest.join(":") };
  })
  .filter((site) => /require\(|from\s+['"]/.test(site.line));
 }
 
 // --- mechanical file tree: real git log per file, no narrative ---------
 // This is the part that didn't exist as code at all before this file --
 // every file-level rung before this was hand-written after reading the
 // source and its `git log`/`git blame` output directly in conversation,
 // not generated by anything re-runnable.
 
 const SKIP_DIR_PARTS = new Set(["node_modules", ".git", "test", "tests", "ci", ".github"]);
 const SKIP_EXTENSIONS = new Set([".lock", ".map", ".png", ".jpg", ".svg", ".ico"]);
 
 function shouldSkip(path: string): boolean {
  const parts = path.split("/");
  if (parts.some((p) => SKIP_DIR_PARTS.has(p))) return true;
  if ([...SKIP_EXTENSIONS].some((ext) => path.endsWith(ext))) return true;
  if (path === "package-lock.json" || path.endsWith("/package-lock.json")) return true;
  return false;
 }
 
 interface FileCommitSummary {
  count: number;
  first: string;
  last: string;
  lastMessage: string;
  lastAuthor: string;
 }
 
 async function fileCommitSummary(repoDir: string, filepath: string): Promise<FileCommitSummary> {
  const commits = await git.log({ fs, dir: repoDir, filepath, force: true });
  const dateOf = (c: (typeof commits)[number]) => new Date(c.commit.author.timestamp * 1000).toISOString().slice(0, 10);
  return {
  count: commits.length,
  first: commits.length ? dateOf(commits[commits.length - 1]) : "",
  last: commits.length ? dateOf(commits[0]) : "",
  lastMessage: commits[0]?.commit.message.split("\n")[0] ?? "",
  lastAuthor: commits[0]?.commit.author.name ?? "",
  };
 }
 
 async function readFileSafe(absPath: string): Promise<string> {
  try {
  return readFileSync(absPath, "utf8");
  } catch {
  return "";
  }
 }
 
 /**
  * Called once per file during walkFileTree, with that file's content and
  * the default mechanical (commit-history-only) summary text. Returning
  * the mechanical text unchanged is the zero-cost default; a caller that
  * wants real content analysis does its own work here (e.g. running a
  * Lane through ask/answer and a model call) and returns the result.
  */
 export type DescribeFile = (path: string, content: string, mechanical: string) => Promise<string>;
 
 const defaultDescribeFile: DescribeFile = async (_path, _content, mechanical) => mechanical;
 
 /**
  * Walks the tracked file tree at HEAD and builds one file/directory Rung
  * per path, each sourced from that file's commit history (count, date
  * range, most recent commit). Returns only the top-level entries (files
  * and directories directly under the repo root) -- each one's nested
  * children, if it's a directory, are already built in underneath it.
  * No function- or line-level rungs: that split requires actually reading
  * and understanding the code, which stays a by-hand pass.
  */
 export async function walkFileTree(repoDir: string, describeFile: DescribeFile = defaultDescribeFile): Promise<Rung[]> {
  const allPaths = await git.listFiles({ fs, dir: repoDir });
  const paths = allPaths.filter((p) => !shouldSkip(p));
 
  const dirPaths = new Set<string>();
  for (const p of paths) {
  const parts = p.split("/");
  for (let i = 1; i < parts.length; i += 1) dirPaths.add(parts.slice(0, i).join("/"));
  }
 
  const fileRungs = new Map<string, Rung>();
  for (const p of paths) {
  const summary = await fileCommitSummary(repoDir, p);
  const mechanical = `${summary.count} commits, ${summary.first} to ${summary.last}. Last: "${summary.lastMessage}" (${summary.lastAuthor}).`;
  const content = await readFileSafe(join(repoDir, p));
  const described = await describeFile(p, content, mechanical);
  fileRungs.set(p, {
  path: p,
  kind: "file",
  summary: described,
  sources: summary.count ? [{ kind: "commit", ref: "HEAD", title: summary.lastMessage, author: summary.lastAuthor, at: summary.last }] : [],
  children: [],
  });
  }
 
  const dirRungs = new Map<string, Rung>();
  for (const d of dirPaths) {
  dirRungs.set(d, { path: d, kind: "directory", summary: `Directory. Not individually described.`, sources: [], children: [] });
  }
 
  // Attach each file/dir to its immediate parent directory's children.
  const roots: Rung[] = [];
  const attach = (path: string, rung: Rung) => {
  const parts = path.split("/");
  if (parts.length === 1) {
  roots.push(rung);
  return;
  }
  const parentPath = parts.slice(0, -1).join("/");
  const parent = dirRungs.get(parentPath);
  if (parent) parent.children.push(rung);
  };
  for (const [p, r] of fileRungs) attach(p, r);
  for (const [p, r] of dirRungs) attach(p, r);
 
  // Directory summaries are a plain listing of what's in them -- the
  // children are already each individually described, so this just
  // names them rather than describing a directory itself.
  for (const rung of dirRungs.values()) {
  const names = rung.children.map((c) => c.path.split("/").pop()).join(", ");
  rung.summary = `Directory containing ${rung.children.length} entries: ${names}.`;
  }
 
  return roots;
 }
 
 // --- commit-by-commit history, for changeset replay ---------------------
 // A different cut through the same repo: not "what does HEAD look like"
 // (walkFileTree, above) but "what changed, commit by commit, from the
 // root commit forward" -- the raw material for one Lane/changeset per
 // commit. Shells out to real git (diff-tree, diff) for the same reason
 // cloneIfMissing does: this is a CLI-local convenience step, not part of
 // the Workers-bound engine.
 
 export interface CommitRow {
  sha: string;
  parent: string | null; // null for the repo's root commit
  author: string;
  at: string;
  message: string;
 }
 
 /** Every commit on the current branch, oldest first -- the order a changeset replay walks them in. Merge commits are walked against their first parent only; a merge's other parents' commits were already visited on their own branch. */
 export function listCommitsChronological(repoDir: string): CommitRow[] {
  const sep = "\x1f";
  const raw = execFileSync("git", ["-C", repoDir, "log", "--reverse", "--first-parent", `--format=%H${sep}%P${sep}%an${sep}%aI${sep}%s`], { encoding: "utf8" });
  return raw
  .trim()
  .split("\n")
  .filter(Boolean)
  .map((line) => {
  const [sha, parents, author, at, message] = line.split(sep);
  return { sha, parent: parents.split(" ")[0] || null, author, at, message };
  });
 }
 
 // The well-known empty-tree object every git repo has; diffing a root
 // commit (no parent) against it reports every one of its files as added.
 const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
 
 export interface FileChangeRow {
  path: string;
  status: "added" | "modified" | "deleted";
  /** Unified diff hunks for this file in this commit (empty for added/deleted, where the whole file is the change). */
  hunks: string[];
 }
 
 function parseHunks(unifiedDiff: string): string[] {
  const lines = unifiedDiff.split("\n");
  const hunks: string[] = [];
  let current: string[] | null = null;
  for (const line of lines) {
  if (line.startsWith("@@")) {
  if (current) hunks.push(current.join("\n"));
  current = [line];
  } else if (current) {
  current.push(line);
  }
  }
  if (current) hunks.push(current.join("\n"));
  return hunks;
 }
 
 /** What changed in one commit: which files, how, and (for modified files) the actual diff hunks. */
 export function commitFileChanges(repoDir: string, commit: CommitRow): FileChangeRow[] {
  const base = commit.parent ?? EMPTY_TREE_SHA;
  const nameStatus = execFileSync("git", ["-C", repoDir, "diff", "--name-status", base, commit.sha], { encoding: "utf8" });
  const rows: FileChangeRow[] = [];
  for (const line of nameStatus.trim().split("\n").filter(Boolean)) {
  const [statusCode, path] = line.split("\t");
  const status = statusCode.startsWith("A") ? "added" : statusCode.startsWith("D") ? "deleted" : "modified";
  let hunks: string[] = [];
  if (status === "modified") {
  const diff = execFileSync("git", ["-C", repoDir, "diff", "--unified=1", base, commit.sha, "--", path], { encoding: "utf8" });
  hunks = parseHunks(diff);
  }
  rows.push({ path, status, hunks });
  }
  return rows;
 }
 
 /** A file's full content as of a specific commit -- used to describe a newly-added file without needing a second checkout. */
 export function fileContentAtCommit(repoDir: string, sha: string, path: string): string {
  try {
  return execFileSync("git", ["-C", repoDir, "show", `${sha}:${path}`], { encoding: "utf8" });
  } catch {
  return "";
  }
 }