@flyingrobots/graft 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.4.0] - 2026-04-05
9
+
10
+ ### Added
11
+
12
+ - **WARP Level 1 — structural memory substrate**: git-warp-backed
13
+ graph stores structural facts per commit. Directory tree, file,
14
+ symbol, and commit nodes with containment edges and provenance
15
+ links (touches, adds, changes, removes).
16
+ - **`graft_since`**: structural changes since a git ref — symbols
17
+ added, removed, and changed per file with summary lines. Instant.
18
+ - **`graft_map`**: structural map of a directory — all files and
19
+ their symbols in one call via tree-sitter.
20
+ - **`graft index` CLI**: manual WARP indexing trigger.
21
+ - **WARP indexer**: walks git history, parses files with tree-sitter,
22
+ emits WARP patches. Handles nested symbols, file deletion,
23
+ signature changes, unsupported language degradation.
24
+ - **Observer factory**: 8 canonical lens patterns for focused graph
25
+ projections (file symbols, all symbols, directory files, etc.).
26
+ - **11 WARP invariants**: observer-only-access, materialization-
27
+ deterministic, delta-only-storage, address-not-identity, and more.
28
+ - **`@git-stunts/git-warp` v16** + `@git-stunts/plumbing` deps.
29
+
8
30
  ## [0.3.5] - 2026-04-05
9
31
 
10
32
  ### Fixed
package/bin/graft.js CHANGED
@@ -17,6 +17,9 @@ if (process.env.__GRAFT_TSX_LOADED === "1") {
17
17
  if (command === "init") {
18
18
  const { runInit } = await import("../src/cli/init.js");
19
19
  runInit();
20
+ } else if (command === "index") {
21
+ const { runIndex } = await import("../src/cli/index-cmd.js");
22
+ await runIndex();
20
23
  } else {
21
24
  const { createGraftServer } = await import("../src/mcp/server.js");
22
25
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flyingrobots/graft",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Context governor for coding agents — MCP server with policy-enforced reads, structural outlines, and session tracking",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.0",
@@ -57,6 +57,8 @@
57
57
  "url": "git+https://github.com/flyingrobots/graft.git"
58
58
  },
59
59
  "dependencies": {
60
+ "@git-stunts/git-warp": "^16.0.0",
61
+ "@git-stunts/plumbing": "^2.8.0",
60
62
  "@modelcontextprotocol/sdk": "^1.29.0",
61
63
  "picomatch": "^4.0.4",
62
64
  "tree-sitter-wasms": "0.1.13",
@@ -0,0 +1,22 @@
1
+ import { openWarp } from "../warp/open.js";
2
+ import { indexCommits } from "../warp/indexer.js";
3
+
4
+ export async function runIndex(): Promise<void> {
5
+ const cwd = process.cwd();
6
+ const from: string | undefined = process.argv[3];
7
+
8
+ console.log(`\nIndexing structural history in ${cwd}\n`);
9
+
10
+ try {
11
+ const warp = await openWarp({ cwd });
12
+ const result = await indexCommits(warp, { cwd, ...(from !== undefined ? { from } : {}) });
13
+
14
+ console.log(` commits indexed: ${String(result.commitsIndexed)}`);
15
+ console.log(` patches written: ${String(result.patchesWritten)}`);
16
+ console.log("\nDone.\n");
17
+ } catch (err: unknown) {
18
+ const msg = err instanceof Error ? err.message : String(err);
19
+ console.error(`Error: ${msg}`);
20
+ process.exitCode = 1;
21
+ }
22
+ }
@@ -9,6 +9,7 @@ import type { SessionTracker } from "../session/tracker.js";
9
9
  import type { McpToolResult } from "./receipt.js";
10
10
  import type { FileSystem } from "../ports/filesystem.js";
11
11
  import type { JsonCodec } from "../ports/codec.js";
12
+ import type WarpApp from "@git-stunts/git-warp";
12
13
 
13
14
  import type { z } from "zod";
14
15
 
@@ -32,6 +33,7 @@ export interface ToolContext {
32
33
  readonly codec: JsonCodec;
33
34
  respond(tool: string, data: Record<string, unknown>): McpToolResult;
34
35
  resolvePath(relative: string): string;
36
+ getWarp(): Promise<WarpApp>;
35
37
  }
36
38
 
37
39
  /**
package/src/mcp/server.ts CHANGED
@@ -13,6 +13,8 @@ import { nodeFs } from "../adapters/node-fs.js";
13
13
  import { CanonicalJsonCodec } from "../adapters/canonical-json.js";
14
14
  import { evaluatePolicy } from "../policy/evaluate.js";
15
15
  import { RefusedResult } from "../policy/types.js";
16
+ import type WarpApp from "@git-stunts/git-warp";
17
+ import { openWarp } from "../warp/open.js";
16
18
 
17
19
  // Tool definitions — each file exports a ToolDefinition object
18
20
  import { safeReadTool } from "./tools/safe-read.js";
@@ -26,6 +28,8 @@ import { doctorTool } from "./tools/doctor.js";
26
28
  import { statsTool } from "./tools/stats.js";
27
29
  import { explainTool } from "./tools/explain.js";
28
30
  import { setBudgetTool } from "./tools/budget.js";
31
+ import { sinceTool } from "./tools/since.js";
32
+ import { mapTool } from "./tools/map.js";
29
33
 
30
34
  export type { McpToolResult, ToolHandler, ToolContext };
31
35
 
@@ -43,6 +47,8 @@ const TOOL_REGISTRY: readonly ToolDefinition[] = [
43
47
  statsTool,
44
48
  explainTool,
45
49
  setBudgetTool,
50
+ sinceTool,
51
+ mapTool,
46
52
  ];
47
53
 
48
54
  export interface GraftServer {
@@ -78,7 +84,19 @@ export function createGraftServer(): GraftServer {
78
84
  return result;
79
85
  }
80
86
 
81
- const ctx: ToolContext = { projectRoot, graftDir, session, cache, metrics, respond, resolvePath: createPathResolver(projectRoot), fs: nodeFs, codec };
87
+ // Lazy WARP initialization only loaded when a WARP-backed tool needs it.
88
+ // Single pending promise prevents duplicate instances from concurrent calls.
89
+ // On rejection, clear cache so subsequent calls can retry.
90
+ let warpPromise: Promise<WarpApp> | null = null;
91
+ function getWarp(): Promise<WarpApp> {
92
+ warpPromise ??= openWarp({ cwd: projectRoot }).catch((err: unknown) => {
93
+ warpPromise = null;
94
+ throw err;
95
+ });
96
+ return warpPromise;
97
+ }
98
+
99
+ const ctx: ToolContext = { projectRoot, graftDir, session, cache, metrics, respond, resolvePath: createPathResolver(projectRoot), fs: nodeFs, codec, getWarp };
82
100
 
83
101
  function wrapWithPolicyCheck(toolName: string, inner: ToolHandler): ToolHandler {
84
102
  return (args: Record<string, unknown>) => {
@@ -0,0 +1,82 @@
1
+ import * as path from "node:path";
2
+ import { z } from "zod";
3
+ import { extractOutline } from "../../parser/outline.js";
4
+ import { detectLang } from "../../parser/lang.js";
5
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
6
+ import { execFileSync } from "node:child_process";
7
+
8
+ interface FileEntry {
9
+ path: string;
10
+ lang: string;
11
+ symbols: { name: string; kind: string; signature?: string | undefined; exported: boolean; startLine?: number | undefined; endLine?: number | undefined }[];
12
+ }
13
+
14
+ /**
15
+ * List files in a directory (git ls-files for tracked files).
16
+ */
17
+ function listFiles(dirPath: string, cwd: string): string[] {
18
+ try {
19
+ const args = dirPath.length > 0
20
+ ? ["ls-files", "--", dirPath]
21
+ : ["ls-files"];
22
+ return execFileSync("git", args, { cwd, encoding: "utf-8" })
23
+ .trim().split("\n").filter((l) => l.length > 0);
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ export const mapTool: ToolDefinition = {
30
+ name: "graft_map",
31
+ description:
32
+ "Structural map of a directory — all files and their symbols " +
33
+ "(function signatures, class shapes, exports) in one call. " +
34
+ "Uses tree-sitter to parse the working tree directly.",
35
+ schema: {
36
+ path: z.string().optional(),
37
+ },
38
+ createHandler(ctx: ToolContext): ToolHandler {
39
+ return (args) => {
40
+ const dirPath = (args["path"] as string | undefined) ?? "";
41
+
42
+ const filePaths = listFiles(dirPath, ctx.projectRoot);
43
+ const files: FileEntry[] = [];
44
+
45
+ for (const filePath of filePaths) {
46
+ const lang = detectLang(filePath);
47
+ if (lang === null) continue;
48
+
49
+ let content: string;
50
+ try {
51
+ content = ctx.fs.readFileSync(path.join(ctx.projectRoot, filePath), "utf-8");
52
+ } catch {
53
+ continue;
54
+ }
55
+
56
+ const result = extractOutline(content, lang);
57
+ const symbols: FileEntry["symbols"] = result.entries.map((entry) => {
58
+ const jump = result.jumpTable?.find((j) => j.symbol === entry.name);
59
+ return {
60
+ name: entry.name,
61
+ kind: entry.kind,
62
+ signature: entry.signature,
63
+ exported: entry.exported,
64
+ startLine: jump?.start,
65
+ endLine: jump?.end,
66
+ };
67
+ });
68
+
69
+ files.push({ path: filePath, lang, symbols });
70
+ }
71
+
72
+ files.sort((a, b) => a.path.localeCompare(b.path));
73
+ const totalSymbols = files.reduce((n, f) => n + f.symbols.length, 0);
74
+
75
+ return ctx.respond("graft_map", {
76
+ directory: dirPath.length > 0 ? dirPath : ".",
77
+ files,
78
+ summary: `${String(files.length)} files, ${String(totalSymbols)} symbols`,
79
+ });
80
+ };
81
+ },
82
+ };
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import { graftDiff } from "../../operations/graft-diff.js";
3
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
4
+
5
+ export const sinceTool: ToolDefinition = {
6
+ name: "graft_since",
7
+ description:
8
+ "Structural changes since a git ref. Shows symbols added, removed, " +
9
+ "and changed per file — not line hunks. Includes per-file summary " +
10
+ "lines for quick triage. Defaults to HEAD as the comparison target.",
11
+ schema: {
12
+ base: z.string(),
13
+ head: z.string().optional(),
14
+ },
15
+ createHandler(ctx: ToolContext): ToolHandler {
16
+ return (args) => {
17
+ const base = args["base"] as string;
18
+ const head = (args["head"] as string | undefined) ?? "HEAD";
19
+
20
+ const result = graftDiff({
21
+ cwd: ctx.projectRoot,
22
+ fs: ctx.fs,
23
+ base,
24
+ head,
25
+ });
26
+
27
+ // Aggregate symbol-level changes across all files
28
+ let totalAdded = 0;
29
+ let totalRemoved = 0;
30
+ let totalChanged = 0;
31
+
32
+ for (const file of result.files) {
33
+ totalAdded += file.diff.added.length;
34
+ totalRemoved += file.diff.removed.length;
35
+ totalChanged += file.diff.changed.length;
36
+ }
37
+
38
+ return ctx.respond("graft_since", {
39
+ ...result,
40
+ summary: `+${String(totalAdded)} added, -${String(totalRemoved)} removed, ~${String(totalChanged)} changed across ${String(result.files.length)} files`,
41
+ });
42
+ };
43
+ },
44
+ };
@@ -0,0 +1,398 @@
1
+ /**
2
+ * WARP Indexer — walks git history and writes structural delta
3
+ * patches into the WARP graph.
4
+ *
5
+ * Observer Law: this module WRITES facts. It never reads them back.
6
+ * Reading is done exclusively through observers (see observers.ts).
7
+ */
8
+
9
+ import type WarpApp from "@git-stunts/git-warp";
10
+ import { execFileSync } from "node:child_process";
11
+ import { extractOutline } from "../parser/outline.js";
12
+ import { diffOutlines } from "../parser/diff.js";
13
+ import { detectLang } from "../parser/lang.js";
14
+ import { getFileAtRef } from "../git/diff.js";
15
+ import type { OutlineEntry, JumpEntry } from "../parser/types.js";
16
+ import type { DiffEntry } from "../parser/diff.js";
17
+
18
+ export interface IndexOptions {
19
+ readonly cwd: string;
20
+ readonly from?: string;
21
+ readonly to?: string;
22
+ }
23
+
24
+ export interface IndexResult {
25
+ readonly commitsIndexed: number;
26
+ readonly patchesWritten: number;
27
+ readonly commitTicks: ReadonlyMap<string, number>;
28
+ }
29
+
30
+ // Patch builder shape — matches PatchBuilderV2's fluent API.
31
+ interface PatchOps {
32
+ addNode(id: string): PatchOps;
33
+ removeNode(id: string): PatchOps;
34
+ setProperty(id: string, key: string, value: unknown): PatchOps;
35
+ addEdge(from: string, to: string, label: string): PatchOps;
36
+ removeEdge(from: string, to: string, label: string): PatchOps;
37
+ }
38
+
39
+ function listCommits(cwd: string, from?: string, to?: string): string[] {
40
+ const range = from !== undefined ? `${from}..${to ?? "HEAD"}` : to ?? "HEAD";
41
+ const args = ["log", "--reverse", "--format=%H", range];
42
+ try {
43
+ return execFileSync("git", args, { cwd, encoding: "utf-8" })
44
+ .trim().split("\n").filter((l) => l.length > 0);
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ function getCommitChanges(sha: string, cwd: string): { status: string; path: string }[] {
51
+ // --root handles the initial commit (no parent to diff against)
52
+ const args = ["diff-tree", "--root", "--no-commit-id", "-r", "--name-status", sha];
53
+ try {
54
+ return execFileSync("git", args, { cwd, encoding: "utf-8" })
55
+ .trim().split("\n").filter((l) => l.length > 0).map((line) => {
56
+ const parts = line.split("\t");
57
+ return { status: parts[0] ?? "", path: parts[1] ?? "" };
58
+ });
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ function getCommitMeta(sha: string, cwd: string): { message: string; author: string; email: string; timestamp: string } {
65
+ try {
66
+ const output = execFileSync("git", ["log", "-1", "--format=%s%n%aN%n%aE%n%aI", sha], { cwd, encoding: "utf-8" });
67
+ const lines = output.trim().split("\n");
68
+ return { message: lines[0] ?? "", author: lines[1] ?? "", email: lines[2] ?? "", timestamp: lines[3] ?? "" };
69
+ } catch {
70
+ return { message: "", author: "", email: "", timestamp: "" };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Check if a commit has a parent (is not the root commit).
76
+ */
77
+ function hasParent(sha: string, cwd: string): boolean {
78
+ try {
79
+ execFileSync("git", ["rev-parse", "--verify", `${sha}~1`], { cwd, encoding: "utf-8" });
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function dirNodeId(dirPath: string): string {
87
+ return `dir:${dirPath}`;
88
+ }
89
+
90
+ function fileNodeId(filePath: string): string {
91
+ return `file:${filePath}`;
92
+ }
93
+
94
+ function symNodeId(filePath: string, name: string): string {
95
+ return `sym:${filePath}:${name}`;
96
+ }
97
+
98
+ /**
99
+ * Build a lookup from symbol name → line range from the jump table.
100
+ */
101
+ function buildJumpLookup(jumpTable: readonly JumpEntry[]): Map<string, { start: number; end: number }> {
102
+ const lookup = new Map<string, { start: number; end: number }>();
103
+ for (const entry of jumpTable) {
104
+ lookup.set(entry.symbol, { start: entry.start, end: entry.end });
105
+ }
106
+ return lookup;
107
+ }
108
+
109
+ /**
110
+ * Emit directory nodes + edges for all path components of a file.
111
+ */
112
+ function emitDirectoryChain(patch: PatchOps, filePath: string): void {
113
+ const parts = filePath.split("/");
114
+ if (parts.length <= 1) return;
115
+
116
+ let current = "";
117
+ for (let i = 0; i < parts.length - 1; i++) {
118
+ const parent = current;
119
+ const part = parts[i] ?? "";
120
+ current = current.length > 0 ? `${current}/${part}` : part;
121
+ const dirId = dirNodeId(current);
122
+ patch.addNode(dirId);
123
+ patch.setProperty(dirId, "path", current);
124
+
125
+ if (parent.length > 0) {
126
+ patch.addEdge(dirNodeId(parent), dirId, "contains");
127
+ }
128
+ }
129
+
130
+ patch.addEdge(dirNodeId(current), fileNodeId(filePath), "contains");
131
+ }
132
+
133
+ /**
134
+ * Emit symbol nodes + edges for all entries in a file outline.
135
+ */
136
+ function emitSymbols(
137
+ patch: PatchOps,
138
+ filePath: string,
139
+ entries: readonly OutlineEntry[],
140
+ jumpLookup: Map<string, { start: number; end: number }>,
141
+ parentSymId?: string,
142
+ ): void {
143
+ for (const entry of entries) {
144
+ const symId = symNodeId(filePath, entry.name);
145
+ patch.addNode(symId);
146
+ patch.setProperty(symId, "name", entry.name);
147
+ patch.setProperty(symId, "kind", entry.kind);
148
+ patch.setProperty(symId, "exported", entry.exported);
149
+ if (entry.signature !== undefined) {
150
+ patch.setProperty(symId, "signature", entry.signature);
151
+ }
152
+ const jump = jumpLookup.get(entry.name);
153
+ if (jump !== undefined) {
154
+ patch.setProperty(symId, "startLine", jump.start);
155
+ patch.setProperty(symId, "endLine", jump.end);
156
+ }
157
+ patch.addEdge(fileNodeId(filePath), symId, "contains");
158
+ if (parentSymId !== undefined) {
159
+ patch.addEdge(parentSymId, symId, "child_of");
160
+ }
161
+ if (entry.children !== undefined && entry.children.length > 0) {
162
+ emitSymbols(patch, filePath, entry.children, jumpLookup, symId);
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Tombstone (remove) symbol nodes recursively including children.
169
+ */
170
+ function removeSymbols(
171
+ patch: PatchOps,
172
+ filePath: string,
173
+ entries: readonly OutlineEntry[],
174
+ ): void {
175
+ for (const entry of entries) {
176
+ // Recurse into children FIRST (bottom-up removal)
177
+ if (entry.children !== undefined && entry.children.length > 0) {
178
+ removeSymbols(patch, filePath, entry.children);
179
+ }
180
+ const symId = symNodeId(filePath, entry.name);
181
+ patch.removeEdge(fileNodeId(filePath), symId, "contains");
182
+ patch.removeNode(symId);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Remove symbols from DiffEntry (removed symbols in a diff).
188
+ * Also recursively handles childDiff if present.
189
+ */
190
+ function removeDiffSymbols(
191
+ patch: PatchOps,
192
+ filePath: string,
193
+ fileId: string,
194
+ entries: readonly DiffEntry[],
195
+ ): void {
196
+ for (const entry of entries) {
197
+ // Recurse into childDiff if present (remove grandchildren first)
198
+ if (entry.childDiff !== undefined) {
199
+ removeDiffSymbols(patch, filePath, fileId, [...entry.childDiff.removed]);
200
+ removeDiffSymbols(patch, filePath, fileId, [...entry.childDiff.added]);
201
+ removeDiffSymbols(patch, filePath, fileId, [...entry.childDiff.changed]);
202
+ }
203
+ const symId = symNodeId(filePath, entry.name);
204
+ patch.removeEdge(fileId, symId, "contains");
205
+ patch.removeNode(symId);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Apply child diffs for changed symbols (methods added/removed/changed
211
+ * inside a class that kept its name).
212
+ */
213
+ function applyChildDiffs(
214
+ patch: PatchOps,
215
+ filePath: string,
216
+ fileId: string,
217
+ commitId: string,
218
+ changed: readonly DiffEntry[],
219
+ jumpLookup: Map<string, { start: number; end: number }>,
220
+ ): void {
221
+ for (const entry of changed) {
222
+ if (entry.childDiff === undefined) continue;
223
+ const parentSymId = symNodeId(filePath, entry.name);
224
+
225
+ for (const added of entry.childDiff.added) {
226
+ const symId = symNodeId(filePath, added.name);
227
+ patch.addNode(symId);
228
+ patch.setProperty(symId, "name", added.name);
229
+ patch.setProperty(symId, "kind", added.kind);
230
+ patch.setProperty(symId, "exported", false);
231
+ if (added.signature !== undefined) {
232
+ patch.setProperty(symId, "signature", added.signature);
233
+ }
234
+ const jump = jumpLookup.get(added.name);
235
+ if (jump !== undefined) {
236
+ patch.setProperty(symId, "startLine", jump.start);
237
+ patch.setProperty(symId, "endLine", jump.end);
238
+ }
239
+ patch.addEdge(fileId, symId, "contains");
240
+ patch.addEdge(parentSymId, symId, "child_of");
241
+ patch.addEdge(commitId, symId, "adds");
242
+ }
243
+
244
+ for (const removed of entry.childDiff.removed) {
245
+ const symId = symNodeId(filePath, removed.name);
246
+ patch.addEdge(commitId, symId, "removes");
247
+ patch.removeEdge(fileId, symId, "contains");
248
+ patch.removeNode(symId);
249
+ }
250
+
251
+ // Recurse into changed children that have their own childDiffs
252
+ applyChildDiffs(patch, filePath, fileId, commitId, [...entry.childDiff.changed], jumpLookup);
253
+
254
+ for (const child of entry.childDiff.changed) {
255
+ const symId = symNodeId(filePath, child.name);
256
+ patch.setProperty(symId, "kind", child.kind);
257
+ if (child.signature !== undefined) {
258
+ patch.setProperty(symId, "signature", child.signature);
259
+ }
260
+ patch.addEdge(commitId, symId, "changes");
261
+ }
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Index a range of commits into the WARP graph.
267
+ */
268
+ export async function indexCommits(
269
+ warp: WarpApp,
270
+ options: IndexOptions,
271
+ ): Promise<IndexResult> {
272
+ const { cwd } = options;
273
+ const commits = listCommits(cwd, options.from, options.to);
274
+
275
+ let patchesWritten = 0;
276
+ const commitTicks = new Map<string, number>();
277
+
278
+ for (const sha of commits) {
279
+ const changes = getCommitChanges(sha, cwd);
280
+
281
+ // Only materialize when removals are possible (D or M status).
282
+ // Materialization is expensive — O(n) replay of all prior patches.
283
+ // Add-only commits (A status) and no-change commits don't need it.
284
+ const hasRemovals = changes.some((c) => c.status === "D" || c.status === "M");
285
+ if (hasRemovals) {
286
+ await warp.core().materialize();
287
+ }
288
+
289
+ const meta = getCommitMeta(sha, cwd);
290
+ const parentExists = hasParent(sha, cwd);
291
+ const parentRef = `${sha}~1`;
292
+
293
+ await warp.patch((p) => {
294
+ const patch = p as unknown as PatchOps;
295
+
296
+ const commitId = `commit:${sha}`;
297
+ patch.addNode(commitId);
298
+ patch.setProperty(commitId, "sha", sha);
299
+ patch.setProperty(commitId, "message", meta.message);
300
+ patch.setProperty(commitId, "author", meta.author);
301
+ patch.setProperty(commitId, "email", meta.email);
302
+ patch.setProperty(commitId, "timestamp", meta.timestamp);
303
+
304
+ for (const change of changes) {
305
+ const filePath = change.path;
306
+ const fileId = fileNodeId(filePath);
307
+ const lang = detectLang(filePath);
308
+
309
+ if (change.status === "D") {
310
+ if (lang !== null && parentExists) {
311
+ const oldContent = getFileAtRef(parentRef, filePath, cwd);
312
+ if (oldContent !== null) {
313
+ const oldOutline = extractOutline(oldContent, lang).entries;
314
+ removeSymbols(patch, filePath, oldOutline);
315
+ }
316
+ }
317
+ patch.removeNode(fileId);
318
+ continue;
319
+ }
320
+
321
+ // Added or modified — ensure file + directory nodes exist
322
+ patch.addNode(fileId);
323
+ patch.setProperty(fileId, "path", filePath);
324
+ patch.setProperty(fileId, "lang", lang ?? "unknown");
325
+ patch.addEdge(commitId, fileId, "touches");
326
+ emitDirectoryChain(patch, filePath);
327
+
328
+ if (lang === null) continue;
329
+
330
+ const newContent = getFileAtRef(sha, filePath, cwd);
331
+ if (newContent === null) continue;
332
+ const newResult = extractOutline(newContent, lang);
333
+ const newOutline = newResult.entries;
334
+ const jumpLookup = buildJumpLookup(newResult.jumpTable ?? []);
335
+
336
+ if (change.status === "A" || !parentExists) {
337
+ // New file or root commit — emit all symbols
338
+ emitSymbols(patch, filePath, newOutline, jumpLookup);
339
+ } else {
340
+ // Modified file — structural diff
341
+ const oldContent = getFileAtRef(parentRef, filePath, cwd);
342
+ if (oldContent === null) {
343
+ emitSymbols(patch, filePath, newOutline, jumpLookup);
344
+ continue;
345
+ }
346
+
347
+ const oldOutline = extractOutline(oldContent, lang).entries;
348
+ const diff = diffOutlines(oldOutline, newOutline);
349
+
350
+ // Remove deleted symbols
351
+ for (const removed of diff.removed) {
352
+ const symId = symNodeId(filePath, removed.name);
353
+ patch.addEdge(commitId, symId, "removes");
354
+ removeDiffSymbols(patch, filePath, fileId, [removed]);
355
+ }
356
+
357
+ // Add new symbols (preserve actual exported status)
358
+ for (const added of diff.added) {
359
+ const symId = symNodeId(filePath, added.name);
360
+ patch.addNode(symId);
361
+ patch.setProperty(symId, "name", added.name);
362
+ patch.setProperty(symId, "kind", added.kind);
363
+ // DiffEntry doesn't carry exported — default false for safety.
364
+ // Full exported status comes from emitSymbols on initial add.
365
+ patch.setProperty(symId, "exported", false);
366
+ if (added.signature !== undefined) {
367
+ patch.setProperty(symId, "signature", added.signature);
368
+ }
369
+ const jump = jumpLookup.get(added.name);
370
+ if (jump !== undefined) {
371
+ patch.setProperty(symId, "startLine", jump.start);
372
+ patch.setProperty(symId, "endLine", jump.end);
373
+ }
374
+ patch.addEdge(fileId, symId, "contains");
375
+ patch.addEdge(commitId, symId, "adds");
376
+ }
377
+
378
+ // Update changed symbols
379
+ for (const changed of diff.changed) {
380
+ const symId = symNodeId(filePath, changed.name);
381
+ patch.setProperty(symId, "kind", changed.kind);
382
+ if (changed.signature !== undefined) {
383
+ patch.setProperty(symId, "signature", changed.signature);
384
+ }
385
+ patch.addEdge(commitId, symId, "changes");
386
+ }
387
+
388
+ // Apply nested child diffs (methods in classes)
389
+ applyChildDiffs(patch, filePath, fileId, commitId, [...diff.changed], jumpLookup);
390
+ }
391
+ }
392
+ });
393
+ patchesWritten++;
394
+ commitTicks.set(sha, patchesWritten);
395
+ }
396
+
397
+ return { commitsIndexed: commits.length, patchesWritten, commitTicks };
398
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * WARP Observer Factory — canonical observer lenses for graft queries.
3
+ *
4
+ * Observer Law: this module READS through observers. It never
5
+ * walks the graph directly, maintains shadow state, or implements
6
+ * traversal algorithms.
7
+ *
8
+ * Each function returns an observer with a focused lens. The lens
9
+ * determines the aperture — what the observer can see.
10
+ */
11
+
12
+ import type WarpApp from "@git-stunts/git-warp";
13
+ import type { Observer } from "@git-stunts/git-warp";
14
+
15
+ /** Lens config for creating focused observers. */
16
+ export interface Lens {
17
+ match: string;
18
+ expose?: string[];
19
+ redact?: string[];
20
+ }
21
+
22
+ /**
23
+ * Observe all symbols in a specific file.
24
+ * Aperture: sym:<path>:*
25
+ */
26
+ export function fileSymbolsLens(filePath: string): Lens {
27
+ return {
28
+ match: `sym:${filePath}:*`,
29
+ expose: ["name", "kind", "signature", "exported", "startLine", "endLine"],
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Observe all symbols in the project.
35
+ * Aperture: sym:*
36
+ */
37
+ export function allSymbolsLens(): Lens {
38
+ return {
39
+ match: "sym:*",
40
+ expose: ["name", "kind", "signature", "exported"],
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Observe all files in the project.
46
+ * Aperture: file:*
47
+ */
48
+ export function allFilesLens(): Lens {
49
+ return {
50
+ match: "file:*",
51
+ expose: ["path", "lang"],
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Observe a single symbol by name across all files.
57
+ * Aperture: sym:*:<name>
58
+ */
59
+ export function symbolByNameLens(symbolName: string): Lens {
60
+ return {
61
+ match: `sym:*:${symbolName}`,
62
+ expose: ["name", "kind", "signature", "exported", "startLine", "endLine"],
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Observe a directory subtree.
68
+ * Aperture: dir:<path>*
69
+ */
70
+ export function directoryLens(dirPath: string): Lens {
71
+ return {
72
+ match: `dir:${dirPath}*`,
73
+ expose: ["path"],
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Observe all files under a directory.
79
+ * Aperture: file:<path>/*
80
+ */
81
+ export function directoryFilesLens(dirPath: string): Lens {
82
+ return {
83
+ match: `file:${dirPath}/*`,
84
+ expose: ["path", "lang"],
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Observe commit metadata.
90
+ * Aperture: commit:*
91
+ */
92
+ export function commitsLens(): Lens {
93
+ return {
94
+ match: "commit:*",
95
+ expose: ["sha", "message", "timestamp", "author", "email"],
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Create an observer on the current frontier with a given lens.
101
+ * Observers are static snapshots — create a new one after writes.
102
+ */
103
+ export function observe(warp: WarpApp, lens: Lens): Promise<Observer> {
104
+ return warp.observer(lens);
105
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * WARP graph initialization — opens the graft-ast graph backed by
3
+ * the repo's own .git directory.
4
+ *
5
+ * Single entry point. Returns a WarpApp instance ready for patch
6
+ * writes and observer reads.
7
+ */
8
+
9
+ import WarpApp, { GitGraphAdapter } from "@git-stunts/git-warp";
10
+ import GitPlumbing from "@git-stunts/plumbing";
11
+
12
+ const GRAPH_NAME = "graft-ast";
13
+ const WRITER_ID = "graft";
14
+
15
+ export interface OpenWarpOptions {
16
+ readonly cwd: string;
17
+ }
18
+
19
+ export async function openWarp(options: OpenWarpOptions): Promise<WarpApp> {
20
+ // createDefault() wires the ShellRunnerFactory (required port)
21
+ const plumbing = GitPlumbing.createDefault({ cwd: options.cwd });
22
+ const persistence = new GitGraphAdapter({ plumbing });
23
+
24
+ return WarpApp.open({
25
+ persistence,
26
+ graphName: GRAPH_NAME,
27
+ writerId: WRITER_ID,
28
+ onDeleteWithData: "cascade",
29
+ });
30
+ }
@@ -0,0 +1,11 @@
1
+ declare module "@git-stunts/plumbing" {
2
+ import type { GitPlumbing as GitPlumbingInterface } from "@git-stunts/git-warp";
3
+
4
+ export default class GitPlumbing implements GitPlumbingInterface {
5
+ readonly emptyTree: string;
6
+ constructor(options: { runner: unknown; cwd?: string });
7
+ static createDefault(options?: { cwd?: string; env?: string }): GitPlumbing;
8
+ execute(options: { args: string[]; input?: string | Uint8Array }): Promise<string>;
9
+ executeStream(options: { args: string[] }): Promise<AsyncIterable<Uint8Array> & { collect(opts?: { asString?: boolean }): Promise<Uint8Array | string> }>;
10
+ }
11
+ }