Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions packages/gittensory-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,15 @@ safe defaults and `warnings` explains any dropped or invalid fields.
`.gittensory-miner.yml` → `.github/gittensory-miner.yml` → the `.json` variants). It is IO-free — the caller injects
the existence check — so a caller reads the returned path and feeds its content to `parseMinerGoalSpecContent`. See
`.gittensory-miner.yml.example` for the documented fields.

## Repo map builder

`buildRepoMap(files)` gives a coding-agent driver (or the acceptance-criteria/prompt-packet builders upstream of
it) a compact, structural view of a target repository — function/class/method/interface/type signatures — without
paying the token cost of dumping full file contents into a prompt. It parses with `web-tree-sitter` (the WASM
binding, not a native addon, since this package also ships a Cloudflare Workers deployment target) using prebuilt
grammars from `tree-sitter-wasms`. Supported today: JavaScript/TypeScript/TSX. A file with an unsupported extension
or a grammar that fails to load/parse is reported via `skipped` on its `RepoMapFileEntry`, never thrown — this
module's contract is "extract what it safely can," not "block the whole driver invocation." `renderRepoMap(entries)`
renders a bounded plain-text outline, truncating (with a marker) once a configurable char budget is exceeded so it
can't blow out a prompt budget on a large repo. (#4280)
2 changes: 2 additions & 0 deletions packages/gittensory-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
"test": "npm run build && rm -rf dist-test && tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\""
},
"dependencies": {
"tree-sitter-wasms": "^0.1.13",
"web-tree-sitter": "^0.20.8",
"yaml": "^2.9.0"
},
"devDependencies": {
Expand Down
13 changes: 13 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,19 @@ export {
type RunCodingAgentAttemptOptions,
} from "./miner/driver-factory.js";
export * from "./miner/attempt-metering.js";
export {
buildRepoMap,
extractRepoMapSymbols,
renderRepoMap,
resolveRepoMapLanguage,
type BuildRepoMapOptions,
type LoadRepoMapLanguageFn,
type RepoMapFileEntry,
type RepoMapSkipReason,
type RepoMapSourceFile,
type RepoMapSymbol,
type RepoMapSymbolKind,
} from "./miner/repo-map.js";
export * from "./plan-export.js";
export { countPlanStepsByStatus } from "./plan-step-stats.js";
export { countPlanSteps } from "./plan-step-count.js";
Expand Down
219 changes: 219 additions & 0 deletions packages/gittensory-engine/src/miner/repo-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// Tree-sitter-based repo map builder (#4280): gives a coding-agent driver (or the acceptance-criteria/prompt-
// packet builders upstream of it) a compact, structural view of a target repository -- function/class/method
// signatures -- without paying the token cost of dumping full file contents into a prompt. Uses `web-tree-sitter`
// (the WASM binding) with prebuilt grammars from `tree-sitter-wasms`, not a native addon: this package also ships
// a Cloudflare Workers deployment target where native Node addons are not an option. This module only ever runs
// in the local miner/CLI process, but the WASM binding keeps that door open and needs no native build step.
//
// Supported today: JavaScript/TypeScript/TSX (this repo's own dominant languages). A file whose extension has no
// mapped grammar is skipped (not crashed on) with `skipped: "unsupported_language"`; a grammar that fails to load
// or a parse that throws is caught the same way, with `skipped: "grammar_unavailable"` -- this module's contract
// is "extract what it safely can," never "block the whole driver invocation."
//
// Known scope limit: only `function`/`class` declarations and expressions, `method_definition`, `interface`, and
// `type` alias nodes are extracted -- an arrow function or class expression bound via a `const foo = ...`
// declarator is not walked up to its binding identifier, so it is either missed (arrow functions aren't matched
// at all yet) or reported as "<anonymous>" (a bare class/function expression). Good enough for a compact outline
// today; resolving binding names is a reasonable follow-up, not attempted here.

import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import Parser from "web-tree-sitter";

const require = createRequire(import.meta.url);

export type RepoMapSymbolKind = "function" | "class" | "method" | "interface" | "type";

export type RepoMapSymbol = {
kind: RepoMapSymbolKind;
name: string;
signature: string;
line: number;
};

export type RepoMapSkipReason = "unsupported_language" | "grammar_unavailable";

export type RepoMapFileEntry = {
path: string;
language: string | null;
symbols: readonly RepoMapSymbol[];
skipped?: RepoMapSkipReason;
};

export type RepoMapSourceFile = {
path: string;
sourceText: string;
};

/** File extension (including the leading dot) -> tree-sitter grammar name in `tree-sitter-wasms`. */
const LANGUAGE_BY_EXTENSION: Readonly<Record<string, string>> = Object.freeze({
".js": "javascript",
".mjs": "javascript",
".cjs": "javascript",
".jsx": "javascript",
".ts": "typescript",
".mts": "typescript",
".cts": "typescript",
".tsx": "tsx",
});

const SYMBOL_NODE_KIND: Readonly<Record<string, RepoMapSymbolKind>> = Object.freeze({
function_declaration: "function",
// `function_expression`/`class` (bare, unnamed) cover `export default function() {}` / `export default class {}`
// and other expression positions -- these have no `name` field, so `nameOf` reports them as "<anonymous>"
// rather than skipping them outright.
function_expression: "function",
class_declaration: "class",
class: "class",
method_definition: "method",
interface_declaration: "interface",
type_alias_declaration: "type",
});

function extensionOf(path: string): string {
const dot = path.lastIndexOf(".");
return dot === -1 ? "" : path.slice(dot);
}

/** Pure: map a file path to the grammar name that would parse it, or null if unsupported. */
export function resolveRepoMapLanguage(path: string): string | null {
return LANGUAGE_BY_EXTENSION[extensionOf(path)] ?? null;
}

/** Test/injection seam for loading a compiled grammar -- real WASM-file IO lives only in the default
* implementation, so a test can inject a failing loader to exercise `grammar_unavailable` without needing an
* actually-broken WASM file. */
export type LoadRepoMapLanguageFn = (languageName: string) => Promise<Parser.Language>;

let parserInitialized: Promise<void> | null = null;

async function defaultLoadRepoMapLanguage(languageName: string): Promise<Parser.Language> {
parserInitialized ??= Parser.init();
await parserInitialized;
const wasmPath = require.resolve(`tree-sitter-wasms/out/tree-sitter-${languageName}.wasm`);
return Parser.Language.load(readFileSync(wasmPath));
}

/** First line of a symbol node's own text, trimmed and bounded to `maxChars` (with an ellipsis marker when cut),
* so one huge one-line minified function can't blow out the rendered output on its own. */
function signatureOf(node: Parser.SyntaxNode, maxChars: number): string {
const firstLine = node.text.split("\n", 1)[0]!.trim();
return firstLine.length > maxChars ? `${firstLine.slice(0, maxChars)}…` : firstLine;
}

function nameOf(node: Parser.SyntaxNode): string {
return node.childForFieldName("name")?.text ?? "<anonymous>";
}

/** Walk a parsed tree collecting one `RepoMapSymbol` per matched node kind (function/class/method/interface/
* type declarations). Pure given an already-parsed tree. */
export function extractRepoMapSymbols(tree: Parser.Tree, maxSignatureChars = 120): RepoMapSymbol[] {
const symbols: RepoMapSymbol[] = [];
function walk(node: Parser.SyntaxNode): void {
const kind = SYMBOL_NODE_KIND[node.type];
if (kind) {
symbols.push({
kind,
name: nameOf(node),
signature: signatureOf(node, maxSignatureChars),
line: node.startPosition.row + 1,
});
}
for (const child of node.namedChildren) walk(child);
}
walk(tree.rootNode);
return symbols;
}

export type BuildRepoMapOptions = {
loadLanguage?: LoadRepoMapLanguageFn | undefined;
maxSignatureChars?: number | undefined;
};

/** Build one `RepoMapFileEntry` per source file: unsupported extensions and grammar/parse failures are caught
* and reported via `skipped`, never thrown -- see module header. A language's grammar is only loaded once per
* call even across many files of the same language. */
export async function buildRepoMap(
files: readonly RepoMapSourceFile[],
options: BuildRepoMapOptions = {},
): Promise<RepoMapFileEntry[]> {
const loadLanguage = options.loadLanguage ?? defaultLoadRepoMapLanguage;
const maxSignatureChars = options.maxSignatureChars ?? 120;
const languageCache = new Map<string, Parser.Language | null>();

async function resolveLanguage(name: string): Promise<Parser.Language | null> {
const cached = languageCache.get(name);
if (cached !== undefined) return cached;
try {
const language = await loadLanguage(name);
languageCache.set(name, language);
return language;
} catch {
languageCache.set(name, null);
return null;
}
}

const entries: RepoMapFileEntry[] = [];
for (const file of files) {
const languageName = resolveRepoMapLanguage(file.path);
if (!languageName) {
entries.push({ path: file.path, language: null, symbols: [], skipped: "unsupported_language" });
continue;
}
const language = await resolveLanguage(languageName);
if (!language) {
entries.push({ path: file.path, language: languageName, symbols: [], skipped: "grammar_unavailable" });
continue;
}
try {
const parser = new Parser();
parser.setLanguage(language);
const tree = parser.parse(file.sourceText);
entries.push({
path: file.path,
language: languageName,
symbols: extractRepoMapSymbols(tree, maxSignatureChars),
});
} catch {
entries.push({ path: file.path, language: languageName, symbols: [], skipped: "grammar_unavailable" });
}
}
return entries;
}

/** Render entries into a bounded plain-text outline: one line per symbol (`kind name (line N): signature`),
* skipped/empty files noted with a one-line placeholder. Stops once `maxOutputChars` would be exceeded and
* appends a truncation marker, so a caller/prompt-builder can tell the map is partial rather than complete. */
export function renderRepoMap(entries: readonly RepoMapFileEntry[], maxOutputChars = 20_000): string {
const lines: string[] = [];
let length = 0;
let truncated = false;

function pushLine(line: string): boolean {
const addedLength = length === 0 ? line.length : line.length + 1; // +1 for the joining newline
if (length + addedLength > maxOutputChars) {
truncated = true;
return false;
}
lines.push(line);
length += addedLength;
return true;
}

outer: for (const entry of entries) {
if (entry.skipped) {
if (!pushLine(`${entry.path}: (skipped: ${entry.skipped})`)) break outer;
} else if (entry.symbols.length === 0) {
if (!pushLine(`${entry.path}: (no symbols)`)) break outer;
} else {
if (!pushLine(`${entry.path}:`)) break outer;
for (const symbol of entry.symbols) {
if (!pushLine(` ${symbol.kind} ${symbol.name} (line ${symbol.line}): ${symbol.signature}`)) break outer;
}
}
}

if (truncated) lines.push("… (repo map truncated to fit the output budget)");
return lines.join("\n");
}
Loading
Loading