@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.
- package/CHANGELOG.md +218 -0
- package/LICENSE +190 -0
- package/NOTICE +4 -0
- package/README.md +119 -0
- package/bin/graft.js +11 -0
- package/docs/GUIDE.md +374 -0
- package/package.json +76 -0
- package/src/adapters/canonical-json.ts +56 -0
- package/src/adapters/node-fs.ts +39 -0
- package/src/git/diff.ts +96 -0
- package/src/guards/stream-boundary.ts +110 -0
- package/src/hooks/posttooluse-read.ts +107 -0
- package/src/hooks/pretooluse-read.ts +88 -0
- package/src/hooks/shared.ts +168 -0
- package/src/mcp/cache.ts +94 -0
- package/src/mcp/cached-file.ts +38 -0
- package/src/mcp/context.ts +52 -0
- package/src/mcp/metrics.ts +53 -0
- package/src/mcp/receipt.ts +83 -0
- package/src/mcp/server.ts +166 -0
- package/src/mcp/stdio.ts +6 -0
- package/src/mcp/tools/budget.ts +20 -0
- package/src/mcp/tools/changed-since.ts +68 -0
- package/src/mcp/tools/doctor.ts +20 -0
- package/src/mcp/tools/explain.ts +80 -0
- package/src/mcp/tools/file-outline.ts +57 -0
- package/src/mcp/tools/graft-diff.ts +24 -0
- package/src/mcp/tools/read-range.ts +21 -0
- package/src/mcp/tools/run-capture.ts +67 -0
- package/src/mcp/tools/safe-read.ts +135 -0
- package/src/mcp/tools/state.ts +30 -0
- package/src/mcp/tools/stats.ts +20 -0
- package/src/metrics/logger.ts +69 -0
- package/src/metrics/types.ts +12 -0
- package/src/operations/file-outline.ts +38 -0
- package/src/operations/graft-diff.ts +117 -0
- package/src/operations/read-range.ts +65 -0
- package/src/operations/safe-read.ts +96 -0
- package/src/operations/state.ts +33 -0
- package/src/parser/diff.ts +142 -0
- package/src/parser/lang.ts +12 -0
- package/src/parser/outline.ts +327 -0
- package/src/parser/types.ts +67 -0
- package/src/policy/evaluate.ts +178 -0
- package/src/policy/graftignore.ts +6 -0
- package/src/policy/types.ts +86 -0
- package/src/ports/codec.ts +13 -0
- package/src/ports/filesystem.ts +17 -0
- package/src/session/tracker.ts +114 -0
- 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
|
+
}
|