Skip to content
Open
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
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Changelog

## Unreleased

### Added

- **JS/TS namespace-member functions are first-class nodes.** `NS.foo = (…) => …`,
`NS.foo = function () {}`, `exports.foo = …` and `Foo.prototype.bar = function`
now mint a method node owned by the receiver path (`NS`, `Foo`), and an untyped
`NS.foo()` call resolves owner-qualified against it — never by bare name (#35
still holds). Pre-ESM codebases and single-namespace apps define most of their
API this way; before, those functions had no node at all: `callers` answered
"no symbol", `skeleton` omitted them, and the calls in their bodies attributed
to the file. Measured on a 200-file app written in that style: 581 → 2,518
named symbols, 0 → 5,603 resolved calls into the namespace.
- **`graft build --exclude-dir <path>`** — the complement of `--only-dir`, for a
committed generated copy of real source that `.gitignore` cannot hide (a tracked
file is always listed). Repeatable, normalized like `--only-dir`, and recorded in
the graph fingerprint so the hooks/refresh path, `graft check` and a later
`--deep` skip the same set.
- **A committed `.graftignore`.** One repo-relative path per line (`#` comments),
same effect as `--exclude-dir` but it travels with the repo: a fresh checkout,
a teammate's first `graft build`, and every hook/refresh honour it with no flag
and no fingerprint. Read live on each enumeration, so an edit takes effect on
the next build or refresh.
- **A namespace member defined in several files links every caller to every
definition.** `MN.foo = …` in a base file and again in an overlay is one symbol
assigned twice, not two candidates to guess between, so `MN.foo()` now resolves
to both (confidence `inferred`) instead of dropping as ambiguous — which read as
"nothing calls this" for exactly the functions a second file overrides. Class
methods with several same-named owners still drop, as before.

### Fixed

- **The end-of-turn rebuild forgot `--only-dir`.** The Claude Code `Stop` hook's
background sync ran a plain `graft build`, so the first turn after a
whitelisted (or now excluded) build silently widened the graph back to the
whole tree; only the query-path refresh re-applied the fingerprint's lists. The
sync now passes them too, read straight off the fingerprint sidecar.

## 0.17.0

### Added
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,9 @@ Where a CLI agent supports user-level `hooks.json`, `init` also installs Graft's
graft build [dir] # build graft/ from the code at [dir]: wiring graph + per-file cards (no LLM, no key)
graft build --deep # add the LLM layer: concept nodes + per-symbol summary/crux (cached)
graft build --extensions .ts .py # only include these code extensions
graft build --only-dir src --only-dir lib # index only these repo-relative paths (repeatable; recorded in the graph, not the repo)
graft build --exclude-dir cloud/src # leave out a path — e.g. a committed generated copy of real source (repeatable; the complement of --only-dir)
# or commit a .graftignore (one repo-relative path per line): same effect in every checkout, no flag to remember
graft build --no-reuse # re-parse every file instead of replaying unchanged ones from cache
graft build --follow-submodules # include initialized submodules; persist the choice for builds + MCP refresh
graft build --no-follow-submodules # exclude submodules again and persist that choice (the default)
Expand Down
37 changes: 37 additions & 0 deletions src/claude/sync-run.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { execFileSync } from 'node:child_process';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { readWiring, computeStats } from './stats.js';
import { patchStats, releaseLock, resolveContextDir } from './state.js';
Expand All @@ -13,9 +15,44 @@ function realBuild(dir: string): void {
// Mirrors `withContextDirArg` in hooks.ts: a no-op unless GRAFT_DIR is set, so an
// unconfigured repo's rebuild sees byte-identical argv to before this existed.
if (process.env.GRAFT_DIR) args.push('--dir', resolveContextDir(dir));
// Keep the walk the last build chose. A `--only-dir` / `--exclude-dir` build
// records its lists in the fingerprint and the query-path refresh re-applies
// them (refresh.ts); this rebuild must too, or the first end-of-turn sync after
// such a build silently widened the graph back to the whole tree.
args.push(...lastWalkFlags(resolveContextDir(dir)));
execFileSync(process.execPath, args, { cwd: dir, stdio: 'ignore', timeout: 120000 });
}

/** The last build's `--only-dir` / `--exclude-dir` lists as CLI flags, read
* straight off the newest fingerprint sidecar with plain fs — no extractor-stamp
* check (even a fingerprint from an older build records the walk the person who
* last built chose), and no import of the graph modules into a hook process. */
export function lastWalkFlags(outDir: string): string[] {
const cache = join(outDir, '.cache');
let newest: { path: string; mtime: number } | null = null;
try {
for (const name of readdirSync(cache)) {
if (!name.startsWith('fingerprint.') || !name.endsWith('.json')) continue;
const path = join(cache, name);
const mtime = statSync(path).mtimeMs;
if (!newest || mtime > newest.mtime) newest = { path, mtime };
}
} catch {
return [];
}
if (!newest) return [];
try {
const fp = JSON.parse(readFileSync(newest.path, 'utf8')) as { onlyDirs?: unknown; excludeDirs?: unknown };
const list = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []);
const flags: string[] = [];
for (const d of list(fp.onlyDirs)) flags.push('--only-dir', d);
for (const d of list(fp.excludeDirs)) flags.push('--exclude-dir', d);
return flags;
} catch {
return [];
}
}

export function runSync(dir: string, build: (d: string) => void = realBuild): void {
try {
build(dir);
Expand Down
22 changes: 22 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,14 @@ program
(val: string, prev: string[]) => [...prev, val],
[] as string[],
)
.option(
"--exclude-dir <path>",
"leave out files under this repo-relative path — repeatable; the complement of --only-dir, for a " +
"committed generated copy of real source that .gitignore cannot hide (e.g. --exclude-dir cloud/src). " +
"Recorded in the graph fingerprint so a later build (and the hooks/refresh path) skips the same set",
(val: string, prev: string[]) => [...prev, val],
[] as string[],
)
.option("--no-gitignore", "skip writing graft/ into .gitignore (same as GRAFT_NO_GITIGNORE=1)")
.option("--no-ignore", "skip writing .ignore for ripgrep re-admit (same as GRAFT_NO_IGNORE=1)")
.action(async (
Expand All @@ -356,6 +364,7 @@ program
allowPartial?: boolean;
includeDir?: string[];
onlyDir?: string[];
excludeDir?: string[];
followSubmodules?: boolean;
followNestedRepos?: boolean;
gitignore?: boolean;
Expand Down Expand Up @@ -411,6 +420,17 @@ program
}
onlyDirs = normalized;
}
// --exclude-dir: same normalization and the same home (the fingerprint, not
// the source repo's config) as --only-dir.
let excludeDirs: string[] | undefined;
if (opts.excludeDir && opts.excludeDir.length > 0) {
const normalized = opts.excludeDir.map((p) => normalizePathPrefix(p)).filter((p) => p !== "");
if (normalized.length === 0) {
console.error("✗ --exclude-dir: expected a non-empty repo-relative path");
process.exit(1);
}
excludeDirs = normalized;
}
const followSubmodulesWasExplicit = command.getOptionValueSource("followSubmodules") === "cli";
if (followSubmodulesWasExplicit && typeof opts.followSubmodules === "boolean") {
buildConfigPatch.followSubmodules = opts.followSubmodules;
Expand Down Expand Up @@ -470,6 +490,7 @@ program
const c = await engine.init(dir, {
extensions: opts.extensions,
onlyDirs,
excludeDirs,
onProgress: ({ phase, index, total, file }) =>
process.stderr.write(
`\r${phase === "summarize" ? "reading" : "writing"} concepts ${index + 1}/${total}: ${file.slice(0, 40).padEnd(40)}`,
Expand All @@ -495,6 +516,7 @@ program
reuse: opts.reuse,
lsp: opts.lsp,
onlyDirs,
excludeDirs,
onProgress: ({ phase, index, total, file }) =>
process.stderr.write(
`\r${phase === "enrich" ? "summarizing" : "parsing"} ${index + 1}/${total}: ${file.slice(0, 50).padEnd(50)}`,
Expand Down
21 changes: 18 additions & 3 deletions src/context/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { walkDir } from "../ingest/fs.js";
import { filterByOnlyDirs } from "../graph/source-files.js";
import { filterByOnlyDirs, effectiveExcludeDirs } from "../graph/source-files.js";
import { readFingerprint } from "../graph/fingerprint.js";
import { contentHash } from "../util/id.js";
import { relPosix } from "../util/paths.js";
Expand Down Expand Up @@ -66,6 +66,9 @@ export interface BuildOptions {
* Same prefix semantics as the wiring walk. When omitted, falls back to the
* whitelist recorded in the graph fingerprint (mirrors `checkGraph`). */
onlyDirs?: string[];
/** Repo-relative directory prefixes to leave out of the concept pass
* (`--exclude-dir`); same fallback to the fingerprint as `onlyDirs`. */
excludeDirs?: string[];
/** Human label for the model, recorded in the manifest (e.g. "openrouter:openai/gpt-4o-mini"). */
model: string;
summarizer: Summarizer;
Expand Down Expand Up @@ -100,6 +103,12 @@ function resolveOnlyDirs(outDir: string, explicit?: readonly string[]): Set<stri
return list.length > 0 ? new Set(list) : undefined;
}

/** `--exclude-dir`, with the same CLI-then-fingerprint precedence as `resolveOnlyDirs`. */
function resolveExcludeDirs(outDir: string, explicit?: readonly string[]): Set<string> | undefined {
const list = explicit && explicit.length > 0 ? explicit : (readFingerprint(outDir)?.excludeDirs ?? []);
return list.length > 0 ? new Set(list) : undefined;
}

/**
* Files the concept pass summarizes (and `checkContext` re-hashes): the same
* walk as before (`--include-dir` / submodule flags from state, minus the
Expand All @@ -111,14 +120,20 @@ export function listContextFiles(
outDir: string,
exts: readonly string[],
explicitOnlyDirs?: readonly string[],
explicitExcludeDirs?: readonly string[],
): string[] {
const walked = walkDir(root, readIncludeDirs(root), {
followSubmodules: readFollowSubmodules(root),
followNestedRepos: readFollowNestedRepos(root),
})
.filter((f) => exts.some((e) => f.toLowerCase().endsWith(e)))
.filter((f) => !f.startsWith(outDir));
return filterByOnlyDirs(walked, root, resolveOnlyDirs(outDir, explicitOnlyDirs));
return filterByOnlyDirs(
walked,
root,
resolveOnlyDirs(outDir, explicitOnlyDirs),
effectiveExcludeDirs(root, resolveExcludeDirs(outDir, explicitExcludeDirs)),
);
}

/** The gitignored LLM-call cache: per-file summaries + per-batch synthesis. */
Expand Down Expand Up @@ -151,7 +166,7 @@ export async function buildContext(dir: string, opts: BuildOptions): Promise<Bui
// Tier-2 concept pipeline sees exactly the same directories and submodules.
// `--only-dir` is applied with the same prefix match as wiring (CLI/API, else
// the fingerprint) so out-of-scope files are never summarized or synthesized.
const files = listContextFiles(root, outDir, exts, opts.onlyDirs);
const files = listContextFiles(root, outDir, exts, opts.onlyDirs, opts.excludeDirs);

const cache = loadCache(outDir);
// Flush the summary cache to disk during phase 1 so a build interrupted
Expand Down
6 changes: 6 additions & 0 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface InitOptions {
extensions?: string[];
/** Repo-relative directory prefixes to limit the concept pass (`--only-dir`). */
onlyDirs?: string[];
/** Repo-relative directory prefixes to leave out of the concept pass (`--exclude-dir`). */
excludeDirs?: string[];
/** Progress callback for long builds. */
onProgress?: (info: BuildProgress) => void;
}
Expand All @@ -49,6 +51,8 @@ export interface GraphRunOptions {
lsp?: boolean;
/** Repo-relative directory prefixes to limit the build to (`--only-dir`). */
onlyDirs?: string[];
/** Repo-relative directory prefixes to leave out (`--exclude-dir`). */
excludeDirs?: string[];
onProgress?: GraphBuildOptions["onProgress"];
}

Expand All @@ -65,6 +69,7 @@ export class Graft {
contextDir: this.cfg.contextDir,
extensions: opts.extensions,
onlyDirs: opts.onlyDirs,
excludeDirs: opts.excludeDirs,
model: this.modelLabel(),
summarizer: this.summarizer(),
synthesizer: this.synthesizer(),
Expand Down Expand Up @@ -96,6 +101,7 @@ export class Graft {
reuse: opts.reuse,
lsp: opts.lsp,
onlyDirs: opts.onlyDirs,
excludeDirs: opts.excludeDirs,
onProgress: opts.onProgress,
});
}
Expand Down
43 changes: 43 additions & 0 deletions src/graph/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,52 @@ export function defName(node: Parser.SyntaxNode, lang: Language): string | null
const value = node.childForFieldName("value");
if (value && FN_VALUE_TYPES.has(value.type)) return node.childForFieldName("name")?.text ?? null;
}
// `NS.foo = (…) => …` pushes the same segment extract.ts mints for it
// (`NS.foo`), so a binding set inside the body is keyed under the same path.
if ((lang === "typescript" || lang === "tsx") && node.type === "assignment_expression") {
const value = node.childForFieldName("right");
if (value && FN_VALUE_TYPES.has(value.type)) {
const target = tsMemberAssignmentTarget(node.childForFieldName("left"));
if (target) return `${target.owner}.${target.name}`;
}
}
return null;
}

/**
* The `{ owner, name }` a JS/TS member assignment names, when its left side is a
* plain dotted identifier path: `MN.foo` → owner `MN`, name `foo`; `MN.sub.fn` →
* owner `MN.sub`; `Foo.prototype.bar` → owner `Foo` (the ES5 class idiom — the
* method belongs to `Foo`, and `this.x()` inside it resolves against `Foo`).
* Null for anything else: computed keys (`NS[k] = …`), `this.x = …` (its owner
* is the enclosing class, a different mechanism), and any call or optional
* chain in the path. Shared by extract.ts's `describe` and this file's `defName`
* so the two scope stacks agree on the segment such a definition pushes.
*/
export function tsMemberAssignmentTarget(
left: Parser.SyntaxNode | null,
): { owner: string; name: string } | null {
if (!left || left.type !== "member_expression") return null;
const prop = left.childForFieldName("property");
if (prop?.type !== "property_identifier") return null;
const path: string[] = [];
let cur: Parser.SyntaxNode | null = left.childForFieldName("object");
for (;;) {
if (!cur) return null;
if (cur.type === "identifier") {
path.unshift(cur.text);
break;
}
if (cur.type !== "member_expression") return null;
const p = cur.childForFieldName("property");
if (p?.type !== "property_identifier") return null;
path.unshift(p.text);
cur = cur.childForFieldName("object");
}
if (path.length >= 2 && path[path.length - 1] === "prototype") path.pop();
return { owner: path.join("."), name: prop.text };
}

/** The scope segment a Swift definition pushes, mirroring extract.ts's
* `describeSwift` (duplicated, not imported, per this file's
* no-value-import-of-extract rule): types and extensions push the type's name
Expand Down
12 changes: 9 additions & 3 deletions src/graph/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
} from "./extract-cache.js";
import { writeFingerprint } from "./fingerprint.js";
import { seedGraph, type SeedResult } from "./seed.js";
import { filterByOnlyDirs, listSourceStats } from "./source-files.js";
import { filterByOnlyDirs, listSourceStats, effectiveExcludeDirs } from "./source-files.js";
import { resolveEdges, type GoModule } from "./resolve.js";
import { enrichGraph, type EnrichStats } from "./enrich.js";
import { readGraph, writeGraph, wiringPath } from "./write.js";
Expand Down Expand Up @@ -93,6 +93,9 @@ export interface GraphBuildOptions {
* set, only files under these prefixes are indexed; the list is recorded in the
* fingerprint so the freshness probe enumerates the same set. */
onlyDirs?: string[];
/** Repo-relative directory prefixes to leave out (`--exclude-dir`), applied
* after `onlyDirs`; recorded in the fingerprint the same way. */
excludeDirs?: string[];
onProgress?: (info: {
phase: "parse" | "enrich";
index: number;
Expand Down Expand Up @@ -162,7 +165,10 @@ export async function buildGraph(
followNestedRepos: readFollowNestedRepos(root),
});
const onlyDirs = opts.onlyDirs && opts.onlyDirs.length > 0 ? new Set(opts.onlyDirs) : undefined;
const repoFiles = filterByOnlyDirs(walked, root, onlyDirs);
// `--exclude-dir` merged with the repo's committed `.graftignore`; only the
// flags go into the fingerprint, the file is re-read live on every enumeration.
const excludeDirs = effectiveExcludeDirs(root, opts.excludeDirs);
const repoFiles = filterByOnlyDirs(walked, root, onlyDirs, excludeDirs);
const files = listSourceStats(root, outDir, repoFiles);
const discoveredScopes = discoverScopes(root, repoFiles);

Expand Down Expand Up @@ -358,7 +364,7 @@ export async function buildGraph(
// these source bytes." Nothing about the projections below — which is why it is
// safe to write here, and why `graphOnly` builds (the query path, which stops
// right after this line) are still recorded as fresh.
writeFingerprint(outDir, entries, opts.onlyDirs);
writeFingerprint(outDir, entries, opts.onlyDirs, opts.excludeDirs);

// Tier-2 passive surface: project the nodes into per-file markdown cards, and
// refresh the INDEX roster. Pure projection — no LLM, no network.
Expand Down
7 changes: 4 additions & 3 deletions src/graph/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,10 @@ export async function checkGraph(
// A `--only-dir` build records its whitelist in the fingerprint; read it back
// so `check` diffs the same limited set instead of flagging every excluded
// file as "added".
const fpOnlyDirs = readFingerprint(outDir)?.onlyDirs;
const onlyDirs = fpOnlyDirs && fpOnlyDirs.length > 0 ? new Set(fpOnlyDirs) : undefined;
const sourceFiles = listSourceFiles(root, outDir, undefined, onlyDirs);
const fp = readFingerprint(outDir);
const onlyDirs = fp?.onlyDirs && fp.onlyDirs.length > 0 ? new Set(fp.onlyDirs) : undefined;
const excludeDirs = fp?.excludeDirs && fp.excludeDirs.length > 0 ? new Set(fp.excludeDirs) : undefined;
const sourceFiles = listSourceFiles(root, outDir, undefined, onlyDirs, excludeDirs);
await warmGenericGrammars(
new Set(sourceFiles.map((f) => genericLangOf(f)?.name).filter((n): n is string => !!n)),
);
Expand Down
Loading
Loading