app/engine/ladderNotes.ts
zzmq.dev@gmail.com/zz / app / engine / ladderNotes.ts
@ 959b61ce
Download
Ladder Summary
- file This module implements the mapping of **ladder** “rungs” to Git‑notes storage. It sanitises path components to build safe Git ref names (`sanitizeRefSegment` + `notesRef`), and recursively converts a `Rung` tree into a plain‑object representation that can be written as a note (`embed`). The only symbols exported for use elsewhere are the data‑shape interfaces `EmbeddedRef` and `EmbeddedRung` (the helper `embed` is internal, while the ref‑building helpers are private to the file).
// Ladder storage via git notes. A note is only worth writing for rungs
// anchored to a real git object (a file's blob, a directory's tree, the
// repo's root tree or root commit, or a release's own commit); a
// function/line/answer/nested-ask rung has no git object of its own, so
// it's embedded inline in its nearest anchor ancestor's note instead of
// getting one of its own. That keeps note count proportional to files plus
// top-level asks and releases, not to every function, line, and reply,
// while still letting you read "everything about this one file" or "this
// whole ask thread" in a single note.
import type { Rung, RungKind, RungSource } from "./ladder.js";
import { resolvePathSha, resolveRootCommit, writeNote, readNote, expandOid } from "./gitNotes.js";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
// repo/directory/file/release are anchors wherever they appear in the tree.
// ask/contributor/dependencies are repo-wide: no tree path, so they anchor
// to the repo's root commit with their own identity folded into the note
// ref instead. "ask" is further special: only a *top-level* ask (a new
// thread) is an anchor. Once you're inside a thread, nothing nested in it
// gets its own note, even an "ask"-kind reply that raises something new in
// that same thread -- it's still part of that one conversation, not a
// fresh top-level item. contributor/dependencies never thread, so they're
// always anchors wherever they appear.
const STRUCTURAL_ANCHOR_KINDS = new Set<RungKind>(["repo", "directory", "file", "release", "changeset"]);
const REPO_WIDE_ROOT_KINDS = new Set<RungKind>(["ask", "contributor", "dependencies"]);
// Git ref names forbid several characters a real-world path segment can
// easily contain (a bot login like "dependabot[bot]" has brackets). Applied
// consistently on both write and read (both go through this function with
// the same input path), so the mapping stays stable even though it's lossy.
function sanitizeRefSegment(segment: string): string {
return segment.replace(/[^A-Za-z0-9._/-]/g, "-");
}
function notesRef(kind: RungKind, path: string): string {
if (REPO_WIDE_ROOT_KINDS.has(kind)) return `refs/notes/zz/ladder/${sanitizeRefSegment(path)}`;
if (kind === "release") return "refs/notes/zz/ladder/release";
if (kind === "changeset") return "refs/notes/zz/ladder/changeset";
return "refs/notes/zz/ladder/code";
}
export interface EmbeddedRef {
ref: string;
}
export interface EmbeddedRung {
path: string;
kind: RungKind;
summary: string;
sources: RungSource[];
touches?: string[];
children: (EmbeddedRung | EmbeddedRef)[];
}
function isRef(x: EmbeddedRung | EmbeddedRef): x is EmbeddedRef {
return "ref" in x;
}
/** insideThread: true once `rung` itself is inside a thread (is, or descends from, an ask). Its children are then always embedded too, regardless of their own kind -- being a child of any ask means you're part of that one thread, not a fresh top-level item. */
function embed(rung: Rung, insideThread: boolean): EmbeddedRung {
const childrenInsideThread = insideThread || rung.kind === "ask";
return {
path: rung.path,
kind: rung.kind,
summary: rung.summary,
sources: rung.sources,
touches: rung.touches,
children: rung.children.map((c) => {
const isTopLevelRepoWide = !childrenInsideThread && REPO_WIDE_ROOT_KINDS.has(c.kind);
const isAnchorChild = !childrenInsideThread && STRUCTURAL_ANCHOR_KINDS.has(c.kind);
if (isTopLevelRepoWide || isAnchorChild) return { ref: c.path };
return embed(c, childrenInsideThread);
}),
};
}
// Small index, only for releases now: there's no tree path for a release
// and (unlike asks) it keeps anchoring to its own real commit rather than
// the repo-wide root, so something has to record which commit that was.
// Proportional to how many releases we track, not to codebase size -- the
// same reasoning data-format.md gives for keeping Lanes and Roles as plain
// files rather than notes.
function indexPath(stateDir: string): string {
return join(stateDir, ".zz", "ladder-index.json");
}
function readIndex(stateDir: string): Record<string, string> {
const path = indexPath(stateDir);
if (!existsSync(path)) return {};
return JSON.parse(readFileSync(path, "utf8"));
}
function writeIndexEntry(stateDir: string, rungPath: string, sha: string): void {
const path = indexPath(stateDir);
const index = readIndex(stateDir);
index[rungPath] = sha;
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(index, null, 2) + "\n");
}
function firstCommitRef(rung: Rung): string {
const commit = rung.sources.find((s) => s.kind === "commit");
if (!commit) throw new Error(`${rung.path}: no commit source to anchor a note to`);
return commit.ref;
}
// resolveRootCommit walks the repo's full history; cache it per repo rather
// than re-walking for every top-level ask.
const rootCommitCache = new Map<string, Promise<string>>();
function cachedRootCommit(repoDir: string): Promise<string> {
let cached = rootCommitCache.get(repoDir);
if (!cached) {
cached = resolveRootCommit(repoDir);
rootCommitCache.set(repoDir, cached);
}
return cached;
}
async function anchorSha(repoDir: string, stateDir: string, rung: Rung): Promise<string> {
// The repo's own root commit, not the current/HEAD tree: the tree sha
// changes on every commit, which would silently orphan _repo's note
// (and everything registered under it) on the very next commit in any
// repo under live development, not just a one-time onboarding pass.
if (rung.kind === "repo") return cachedRootCommit(repoDir);
if (rung.kind === "file" || rung.kind === "directory") {
const sha = await resolvePathSha(repoDir, rung.path);
if (!sha) throw new Error(`${rung.path}: not found in the repo at HEAD`);
return sha;
}
if (REPO_WIDE_ROOT_KINDS.has(rung.kind)) return cachedRootCommit(repoDir);
// release: anchor to its own real commit, already in its sources. Our
// sources store short SHAs for readability; notes need the full one.
const sha = await expandOid(repoDir, firstCommitRef(rung));
writeIndexEntry(stateDir, rung.path, sha);
return sha;
}
/** Writes one note per top-level anchor in the tree, recursively. Everything inside a thread, or nested under a file, is embedded, not written separately. */
export async function writeLadder(repoDir: string, stateDir: string, root: Rung): Promise<void> {
const visit = async (rung: Rung, insideThread: boolean): Promise<void> => {
const isAnchor = !insideThread && (REPO_WIDE_ROOT_KINDS.has(rung.kind) || STRUCTURAL_ANCHOR_KINDS.has(rung.kind));
if (!isAnchor) return; // shouldn't be called on a non-anchor, but guard anyway
const sha = await anchorSha(repoDir, stateDir, rung);
await writeNote(repoDir, notesRef(rung.kind, rung.path), sha, embed(rung, false));
const childrenInsideThread = insideThread || rung.kind === "ask";
for (const child of rung.children) {
if (!childrenInsideThread) await visit(child, false);
}
};
await visit(root, false);
}
/**
* Adds references to the repo root's own note without touching any
* existing child, written or not. writeLadder always recurses into every
* anchor child it's given, which is right when building a tree from
* scratch but would re-derive and overwrite already-correct content for
* anything passed in as a bare stub just to get it into the children
* list. This only ever rewrites the repo root's one note.
*/
export async function appendRepoChildren(repoDir: string, stateDir: string, newChildren: { path: string; kind: RungKind }[]): Promise<void> {
const current = await readAnchor(repoDir, stateDir, "_repo");
if (!current) throw new Error("repo root has no note yet; run the main build first");
const existingRefs = new Set(current.children.filter(isRef).map((c) => c.ref));
const toAdd = newChildren.filter((c) => !existingRefs.has(c.path));
current.children.push(...toAdd.map((c) => ({ ref: c.path })));
const sha = await cachedRootCommit(repoDir);
await writeNote(repoDir, notesRef("repo", "_repo"), sha, current);
}
/** Reads one anchor's note (its own fields plus everything embedded under it, inline). */
export async function readAnchor(repoDir: string, stateDir: string, path: string): Promise<EmbeddedRung | null> {
if (path === "_repo") {
const sha = await cachedRootCommit(repoDir);
return readNote<EmbeddedRung>(repoDir, notesRef("repo", path), sha);
}
if (path.startsWith("asks/")) {
const sha = await cachedRootCommit(repoDir);
return readNote<EmbeddedRung>(repoDir, notesRef("ask", path), sha);
}
if (path.startsWith("contributors/")) {
const sha = await cachedRootCommit(repoDir);
return readNote<EmbeddedRung>(repoDir, notesRef("contributor", path), sha);
}
if (path === "dependencies" || path.startsWith("dependencies/") || path === "dependents") {
const sha = await cachedRootCommit(repoDir);
return readNote<EmbeddedRung>(repoDir, notesRef("dependencies", path), sha);
}
if (path.startsWith("changesets/")) {
const sha = readIndex(stateDir)[path];
if (!sha) return null;
return readNote<EmbeddedRung>(repoDir, notesRef("changeset", path), sha);
}
const fileSha = await resolvePathSha(repoDir, path);
if (fileSha) return readNote<EmbeddedRung>(repoDir, notesRef("file", path), fileSha);
// Not a tree path: must be a release, looked up in the small index.
const releaseSha = readIndex(stateDir)[path];
if (!releaseSha) return null;
return readNote<EmbeddedRung>(repoDir, notesRef("release", path), releaseSha);
}
/** Finds a rung (anchor or embedded) by exact path, starting from an already-read anchor subtree. */
export function findEmbedded(root: EmbeddedRung, path: string): EmbeddedRung | null {
if (root.path === path) return root;
for (const child of root.children) {
if (isRef(child)) continue;
const found = findEmbedded(child, path);
if (found) return found;
}
return null;
}
export { isRef };