@flyingrobots/graft 0.3.1

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +4 -0
  4. package/README.md +119 -0
  5. package/bin/graft.js +11 -0
  6. package/docs/GUIDE.md +374 -0
  7. package/package.json +76 -0
  8. package/src/adapters/canonical-json.ts +56 -0
  9. package/src/adapters/node-fs.ts +39 -0
  10. package/src/git/diff.ts +96 -0
  11. package/src/guards/stream-boundary.ts +110 -0
  12. package/src/hooks/posttooluse-read.ts +107 -0
  13. package/src/hooks/pretooluse-read.ts +88 -0
  14. package/src/hooks/shared.ts +168 -0
  15. package/src/mcp/cache.ts +94 -0
  16. package/src/mcp/cached-file.ts +38 -0
  17. package/src/mcp/context.ts +52 -0
  18. package/src/mcp/metrics.ts +53 -0
  19. package/src/mcp/receipt.ts +83 -0
  20. package/src/mcp/server.ts +166 -0
  21. package/src/mcp/stdio.ts +6 -0
  22. package/src/mcp/tools/budget.ts +20 -0
  23. package/src/mcp/tools/changed-since.ts +68 -0
  24. package/src/mcp/tools/doctor.ts +20 -0
  25. package/src/mcp/tools/explain.ts +80 -0
  26. package/src/mcp/tools/file-outline.ts +57 -0
  27. package/src/mcp/tools/graft-diff.ts +24 -0
  28. package/src/mcp/tools/read-range.ts +21 -0
  29. package/src/mcp/tools/run-capture.ts +67 -0
  30. package/src/mcp/tools/safe-read.ts +135 -0
  31. package/src/mcp/tools/state.ts +30 -0
  32. package/src/mcp/tools/stats.ts +20 -0
  33. package/src/metrics/logger.ts +69 -0
  34. package/src/metrics/types.ts +12 -0
  35. package/src/operations/file-outline.ts +38 -0
  36. package/src/operations/graft-diff.ts +117 -0
  37. package/src/operations/read-range.ts +65 -0
  38. package/src/operations/safe-read.ts +96 -0
  39. package/src/operations/state.ts +33 -0
  40. package/src/parser/diff.ts +142 -0
  41. package/src/parser/lang.ts +12 -0
  42. package/src/parser/outline.ts +327 -0
  43. package/src/parser/types.ts +67 -0
  44. package/src/policy/evaluate.ts +178 -0
  45. package/src/policy/graftignore.ts +6 -0
  46. package/src/policy/types.ts +86 -0
  47. package/src/ports/codec.ts +13 -0
  48. package/src/ports/filesystem.ts +17 -0
  49. package/src/session/tracker.ts +114 -0
  50. package/src/session/types.ts +20 -0
@@ -0,0 +1,142 @@
1
+ import { OutlineEntry } from "./types.js";
2
+
3
+ export class DiffEntry {
4
+ /** @internal */
5
+ private readonly _brand = "DiffEntry" as const;
6
+ readonly name: string;
7
+ readonly kind: OutlineEntry["kind"];
8
+ readonly signature?: string;
9
+ readonly oldSignature?: string;
10
+ readonly childDiff?: OutlineDiff;
11
+
12
+ constructor(opts: {
13
+ name: string;
14
+ kind: OutlineEntry["kind"];
15
+ signature?: string;
16
+ oldSignature?: string;
17
+ childDiff?: OutlineDiff;
18
+ }) {
19
+ if (opts.name.trim().length === 0) {
20
+ throw new Error("DiffEntry: name must be non-empty");
21
+ }
22
+ this.name = opts.name.trim();
23
+ this.kind = opts.kind;
24
+ if (opts.signature !== undefined) this.signature = opts.signature;
25
+ if (opts.oldSignature !== undefined) this.oldSignature = opts.oldSignature;
26
+ if (opts.childDiff !== undefined) this.childDiff = opts.childDiff;
27
+ Object.freeze(this);
28
+ }
29
+ }
30
+
31
+ export class OutlineDiff {
32
+ /** @internal */
33
+ private readonly _brand = "OutlineDiff" as const;
34
+ readonly added: readonly DiffEntry[];
35
+ readonly removed: readonly DiffEntry[];
36
+ readonly changed: readonly DiffEntry[];
37
+ readonly unchangedCount: number;
38
+
39
+ constructor(opts: {
40
+ added: DiffEntry[];
41
+ removed: DiffEntry[];
42
+ changed: DiffEntry[];
43
+ unchangedCount: number;
44
+ }) {
45
+ if (!Number.isInteger(opts.unchangedCount) || opts.unchangedCount < 0) {
46
+ throw new Error("OutlineDiff: unchangedCount must be a non-negative integer");
47
+ }
48
+ this.added = Object.freeze([...opts.added]);
49
+ this.removed = Object.freeze([...opts.removed]);
50
+ this.changed = Object.freeze([...opts.changed]);
51
+ this.unchangedCount = opts.unchangedCount;
52
+ Object.freeze(this);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Returns the effective signature for comparison and reporting.
58
+ * Falls back to the entry's name when no explicit signature exists
59
+ * (e.g., for classes, interfaces, and plain exports).
60
+ */
61
+ function entrySignature(entry: OutlineEntry): string {
62
+ return entry.signature ?? entry.name;
63
+ }
64
+
65
+ export function diffOutlines(
66
+ oldEntries: readonly OutlineEntry[],
67
+ newEntries: readonly OutlineEntry[],
68
+ ): OutlineDiff {
69
+ // Note: duplicate names are clobbered by Map.set — only the last
70
+ // entry with a given name survives. This is acceptable because
71
+ // tree-sitter outlines rarely produce duplicate top-level names,
72
+ // and symbol identity tracking (WARP Level 2+) will handle that case.
73
+ const oldByName = new Map<string, OutlineEntry>();
74
+ for (const entry of oldEntries) {
75
+ oldByName.set(entry.name, entry);
76
+ }
77
+
78
+ const newByName = new Map<string, OutlineEntry>();
79
+ for (const entry of newEntries) {
80
+ newByName.set(entry.name, entry);
81
+ }
82
+
83
+ const added: DiffEntry[] = [];
84
+ const removed: DiffEntry[] = [];
85
+ const changed: DiffEntry[] = [];
86
+ let unchangedCount = 0;
87
+
88
+ // Check new entries against old
89
+ for (const [name, newEntry] of newByName) {
90
+ const oldEntry = oldByName.get(name);
91
+ if (oldEntry === undefined) {
92
+ added.push(new DiffEntry({
93
+ name,
94
+ kind: newEntry.kind,
95
+ ...(newEntry.signature !== undefined ? { signature: newEntry.signature } : {}),
96
+ }));
97
+ } else {
98
+ const oldSig = entrySignature(oldEntry);
99
+ const newSig = entrySignature(newEntry);
100
+ if (oldSig !== newSig) {
101
+ changed.push(new DiffEntry({
102
+ name,
103
+ kind: newEntry.kind,
104
+ oldSignature: entrySignature(oldEntry),
105
+ ...(newEntry.signature !== undefined ? { signature: newEntry.signature } : {}),
106
+ }));
107
+ } else {
108
+ // Same name and signature — check children recursively
109
+ const oldChildren = oldEntry.children ?? [];
110
+ const newChildren = newEntry.children ?? [];
111
+ if (oldChildren.length > 0 || newChildren.length > 0) {
112
+ const childDiff = diffOutlines(oldChildren, newChildren);
113
+ if (childDiff.added.length > 0 || childDiff.removed.length > 0 || childDiff.changed.length > 0) {
114
+ changed.push(new DiffEntry({
115
+ name,
116
+ kind: newEntry.kind,
117
+ childDiff,
118
+ ...(newEntry.signature !== undefined ? { signature: newEntry.signature } : {}),
119
+ }));
120
+ } else {
121
+ unchangedCount++;
122
+ }
123
+ } else {
124
+ unchangedCount++;
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ // Check for removed (in old but not in new)
131
+ for (const [name, oldEntry] of oldByName) {
132
+ if (!newByName.has(name)) {
133
+ removed.push(new DiffEntry({
134
+ name,
135
+ kind: oldEntry.kind,
136
+ ...(oldEntry.signature !== undefined ? { signature: oldEntry.signature } : {}),
137
+ }));
138
+ }
139
+ }
140
+
141
+ return new OutlineDiff({ added, removed, changed, unchangedCount });
142
+ }
@@ -0,0 +1,12 @@
1
+ import * as path from "node:path";
2
+
3
+ /**
4
+ * Detect the tree-sitter language from a file extension.
5
+ * Returns null for unsupported file types.
6
+ */
7
+ export function detectLang(filePath: string): "ts" | "js" | null {
8
+ const ext = path.extname(filePath).toLowerCase();
9
+ if (ext === ".ts" || ext === ".tsx") return "ts";
10
+ if (ext === ".js" || ext === ".jsx") return "js";
11
+ return null;
12
+ }
@@ -0,0 +1,327 @@
1
+ import Parser from "web-tree-sitter";
2
+ import { createRequire } from "node:module";
3
+ import { OutlineEntry, JumpEntry } from "./types.js";
4
+ import type { OutlineResult } from "./types.js";
5
+
6
+ const MAX_SIGNATURE_LENGTH = 199;
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // WASM initialisation (top-level await — ESM only)
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const esmRequire = createRequire(import.meta.url);
13
+
14
+ await Parser.init();
15
+
16
+ const tsLang = await Parser.Language.load(
17
+ esmRequire.resolve("tree-sitter-wasms/out/tree-sitter-typescript.wasm"),
18
+ );
19
+ const jsLang = await Parser.Language.load(
20
+ esmRequire.resolve("tree-sitter-wasms/out/tree-sitter-javascript.wasm"),
21
+ );
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Signature helpers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** Compact object-literal default values: `= { ... }` becomes `= {…}`. */
28
+ function compactObjectDefaults(sig: string): string {
29
+ return sig.replace(/=\s*\{[^}]*\}/g, "= {…}");
30
+ }
31
+
32
+ /** Truncate a signature to fit within MAX_SIGNATURE_LENGTH. */
33
+ function boundSignature(raw: string): string {
34
+ let sig = compactObjectDefaults(raw);
35
+ if (sig.length >= MAX_SIGNATURE_LENGTH) {
36
+ sig = sig.slice(0, MAX_SIGNATURE_LENGTH - 1) + "…";
37
+ }
38
+ return sig;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Tree-sitter node interface (avoids importing the Node class directly)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ interface TSNode {
46
+ readonly type: string;
47
+ readonly text: string;
48
+ readonly startPosition: { readonly row: number; readonly column: number };
49
+ readonly endPosition: { readonly row: number; readonly column: number };
50
+ readonly children: TSNode[];
51
+ readonly namedChildren: TSNode[];
52
+ readonly hasError: boolean;
53
+ childForFieldName(name: string): TSNode | null;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Node kind mapping
58
+ // ---------------------------------------------------------------------------
59
+
60
+ type EntryKind = OutlineEntry["kind"];
61
+
62
+ function nodeKind(nodeType: string): EntryKind | undefined {
63
+ switch (nodeType) {
64
+ case "function_declaration":
65
+ case "generator_function_declaration":
66
+ return "function";
67
+ case "class_declaration":
68
+ return "class";
69
+ case "method_definition":
70
+ return "method";
71
+ case "interface_declaration":
72
+ return "interface";
73
+ case "type_alias_declaration":
74
+ return "type";
75
+ case "enum_declaration":
76
+ return "enum";
77
+ default:
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Extraction helpers
84
+ // ---------------------------------------------------------------------------
85
+
86
+ function extractFunctionSignature(node: TSNode): string | undefined {
87
+ const nameNode = node.childForFieldName("name");
88
+ if (!nameNode) return undefined;
89
+
90
+ const params = node.childForFieldName("parameters");
91
+ const returnType = node.childForFieldName("return_type");
92
+
93
+ let sig = nameNode.text;
94
+ if (params) {
95
+ sig += params.text;
96
+ }
97
+ if (returnType) {
98
+ // return_type node text includes the leading `: ` in some grammars
99
+ const rt = returnType.text.replace(/^:\s*/, "");
100
+ sig += ": " + rt;
101
+ }
102
+
103
+ return boundSignature(sig);
104
+ }
105
+
106
+ function extractName(node: TSNode): string | undefined {
107
+ const nameNode = node.childForFieldName("name");
108
+ return nameNode?.text;
109
+ }
110
+
111
+ function extractClassChildren(node: TSNode): OutlineEntry[] {
112
+ const body = node.childForFieldName("body");
113
+ if (!body) return [];
114
+
115
+ const children: OutlineEntry[] = [];
116
+ for (const child of body.namedChildren) {
117
+ if (child.type === "method_definition") {
118
+ const name = extractName(child);
119
+ if (!name) continue;
120
+
121
+ const sig = extractFunctionSignature(child);
122
+ children.push(new OutlineEntry({
123
+ kind: "method",
124
+ name,
125
+ exported: false,
126
+ ...(sig !== undefined ? { signature: sig } : {}),
127
+ }));
128
+ }
129
+ }
130
+ return children;
131
+ }
132
+
133
+ function processDeclaration(
134
+ node: TSNode,
135
+ exported: boolean,
136
+ ): OutlineEntry | undefined {
137
+ const kind = nodeKind(node.type);
138
+ if (!kind) return undefined;
139
+
140
+ const name = extractName(node);
141
+ if (!name) return undefined;
142
+
143
+ const sig = kind === "function" ? extractFunctionSignature(node) : undefined;
144
+ const children = kind === "class" ? extractClassChildren(node) : undefined;
145
+
146
+ return new OutlineEntry({
147
+ kind,
148
+ name,
149
+ exported,
150
+ ...(sig !== undefined ? { signature: sig } : {}),
151
+ ...(children !== undefined && children.length > 0 ? { children } : {}),
152
+ });
153
+ }
154
+
155
+ function extractArrowSignature(name: string, arrowNode: TSNode): string | undefined {
156
+ const params = arrowNode.childForFieldName("parameters");
157
+ const returnType = arrowNode.childForFieldName("return_type");
158
+
159
+ let sig = name;
160
+ if (params) {
161
+ sig += params.text;
162
+ }
163
+ if (returnType) {
164
+ const rt = returnType.text.replace(/^:\s*/, "");
165
+ sig += ": " + rt;
166
+ }
167
+
168
+ return boundSignature(sig);
169
+ }
170
+
171
+ function processLexicalExport(node: TSNode): OutlineEntry[] {
172
+ const entries: OutlineEntry[] = [];
173
+ for (const child of node.namedChildren) {
174
+ if (child.type === "variable_declarator") {
175
+ const name = extractName(child);
176
+ if (!name) continue;
177
+
178
+ // Check if the value is an arrow function or function expression
179
+ const value = child.childForFieldName("value");
180
+ if (value && (value.type === "arrow_function" || value.type === "function")) {
181
+ const sig = extractArrowSignature(name, value);
182
+ entries.push(new OutlineEntry({
183
+ kind: "function",
184
+ name,
185
+ exported: true,
186
+ ...(sig !== undefined ? { signature: sig } : {}),
187
+ }));
188
+ } else {
189
+ entries.push(new OutlineEntry({ kind: "export", name, exported: true }));
190
+ }
191
+ }
192
+ }
193
+ return entries;
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Jump table builder
198
+ // ---------------------------------------------------------------------------
199
+
200
+ function buildJumpEntry(
201
+ name: string,
202
+ kind: string,
203
+ node: TSNode,
204
+ ): JumpEntry {
205
+ return new JumpEntry({
206
+ symbol: name,
207
+ kind,
208
+ start: node.startPosition.row + 1,
209
+ end: node.endPosition.row + 1,
210
+ });
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Main extraction
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /**
218
+ * Extract a structural outline from source code.
219
+ *
220
+ * @param source - The source code text.
221
+ * @param lang - Language identifier: `"ts"` or `"js"`. Defaults to `"ts"`.
222
+ * @returns An {@link OutlineResult} with entries, jump table, and partial flag.
223
+ */
224
+ export function extractOutline(
225
+ source: string,
226
+ lang: "ts" | "js" = "ts",
227
+ ): OutlineResult {
228
+ const parser = new Parser();
229
+ parser.setLanguage(lang === "ts" ? tsLang : jsLang);
230
+
231
+ const tree = parser.parse(source);
232
+ const root = tree.rootNode as unknown as TSNode;
233
+
234
+ const entries: OutlineEntry[] = [];
235
+ const jumpTable: JumpEntry[] = [];
236
+ const hasError = root.hasError;
237
+
238
+ for (const child of root.children) {
239
+ if (child.type === "export_statement") {
240
+ // export_statement wraps a declaration
241
+ const inner = child.namedChildren.find(
242
+ (c) =>
243
+ c.type === "function_declaration" ||
244
+ c.type === "generator_function_declaration" ||
245
+ c.type === "class_declaration" ||
246
+ c.type === "interface_declaration" ||
247
+ c.type === "type_alias_declaration" ||
248
+ c.type === "enum_declaration",
249
+ );
250
+
251
+ if (inner) {
252
+ const entry = processDeclaration(inner, true);
253
+ if (entry) {
254
+ entries.push(entry);
255
+ jumpTable.push(buildJumpEntry(entry.name, entry.kind, child));
256
+ }
257
+ continue;
258
+ }
259
+
260
+ // Check for lexical declaration (const/let exports)
261
+ const lexical = child.namedChildren.find(
262
+ (c) => c.type === "lexical_declaration",
263
+ );
264
+ if (lexical) {
265
+ const lexEntries = processLexicalExport(lexical);
266
+ for (const entry of lexEntries) {
267
+ entries.push(entry);
268
+ jumpTable.push(buildJumpEntry(entry.name, entry.kind, child));
269
+ }
270
+ continue;
271
+ }
272
+
273
+ // Re-exports: export { A, B } from './x'
274
+ const exportClause = child.namedChildren.find(
275
+ (c) => c.type === "export_clause",
276
+ );
277
+ if (exportClause) {
278
+ for (const spec of exportClause.namedChildren) {
279
+ if (spec.type === "export_specifier") {
280
+ const nameNode = spec.childForFieldName("alias") ?? spec.childForFieldName("name");
281
+ if (nameNode) {
282
+ entries.push(new OutlineEntry({ kind: "export", name: nameNode.text, exported: true }));
283
+ jumpTable.push(buildJumpEntry(nameNode.text, "export", child));
284
+ }
285
+ }
286
+ }
287
+ continue;
288
+ }
289
+
290
+ // Wildcard re-export: export * from './x'
291
+ const moduleSpecifier = child.namedChildren.find((c) => c.type === "string");
292
+ const hasWildcard = child.children.some((c) => c.type === "*" || c.type === "namespace_export");
293
+ if (hasWildcard && moduleSpecifier) {
294
+ const name = `* from ${moduleSpecifier.text}`;
295
+ entries.push(new OutlineEntry({ kind: "export", name, exported: true }));
296
+ jumpTable.push(buildJumpEntry(name, "export", child));
297
+ continue;
298
+ }
299
+
300
+ // Other export forms — skip
301
+ } else {
302
+ // Non-exported top-level declaration
303
+ const kind = nodeKind(child.type);
304
+ if (kind) {
305
+ const entry = processDeclaration(child, false);
306
+ if (entry) {
307
+ entries.push(entry);
308
+ jumpTable.push(buildJumpEntry(entry.name, entry.kind, child));
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ parser.delete();
315
+ tree.delete();
316
+
317
+ const result: OutlineResult = {
318
+ entries,
319
+ jumpTable,
320
+ };
321
+
322
+ if (hasError) {
323
+ result.partial = true;
324
+ }
325
+
326
+ return result;
327
+ }
@@ -0,0 +1,67 @@
1
+ /** The kind of a top-level or member declaration. */
2
+ export type EntryKind = "function" | "class" | "method" | "interface" | "type" | "enum" | "export";
3
+
4
+ /** A single entry in a file outline. */
5
+ export class OutlineEntry {
6
+ /** @internal Brand prevents structural forgery from plain objects. */
7
+ private readonly _brand = "OutlineEntry" as const;
8
+ readonly kind: EntryKind;
9
+ readonly name: string;
10
+ readonly signature?: string;
11
+ readonly exported: boolean;
12
+ readonly children?: readonly OutlineEntry[];
13
+
14
+ constructor(opts: {
15
+ kind: EntryKind;
16
+ name: string;
17
+ exported: boolean;
18
+ signature?: string;
19
+ children?: readonly OutlineEntry[];
20
+ }) {
21
+ if (opts.name.trim().length === 0) {
22
+ throw new Error("OutlineEntry: name must be non-empty");
23
+ }
24
+ this.kind = opts.kind;
25
+ this.name = opts.name.trim();
26
+ this.exported = opts.exported;
27
+ if (opts.signature !== undefined) this.signature = opts.signature;
28
+ if (opts.children !== undefined) this.children = Object.freeze([...opts.children]);
29
+ Object.freeze(this);
30
+ }
31
+ }
32
+
33
+ /** A jump-table entry mapping a symbol to its 1-based line range. */
34
+ export class JumpEntry {
35
+ /** @internal Brand prevents structural forgery from plain objects. */
36
+ private readonly _brand = "JumpEntry" as const;
37
+ readonly symbol: string;
38
+ readonly kind: string;
39
+ readonly start: number; // 1-based line
40
+ readonly end: number; // 1-based line
41
+
42
+ constructor(opts: {
43
+ symbol: string;
44
+ kind: string;
45
+ start: number;
46
+ end: number;
47
+ }) {
48
+ if (!Number.isInteger(opts.start) || opts.start < 1) {
49
+ throw new Error("JumpEntry: start must be an integer >= 1");
50
+ }
51
+ if (!Number.isInteger(opts.end) || opts.end < opts.start) {
52
+ throw new Error("JumpEntry: end must be an integer >= start");
53
+ }
54
+ this.symbol = opts.symbol;
55
+ this.kind = opts.kind;
56
+ this.start = opts.start;
57
+ this.end = opts.end;
58
+ Object.freeze(this);
59
+ }
60
+ }
61
+
62
+ /** Result of outline extraction. */
63
+ export interface OutlineResult {
64
+ entries: OutlineEntry[];
65
+ jumpTable?: JumpEntry[];
66
+ partial?: boolean;
67
+ }