@flyingrobots/graft 0.3.1 → 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 +67 -0
- package/README.md +52 -1
- package/bin/graft.js +39 -8
- package/docs/GUIDE.md +14 -0
- package/package.json +15 -9
- package/src/cli/index-cmd.ts +22 -0
- package/src/cli/init.ts +112 -0
- package/src/mcp/context.ts +2 -0
- package/src/mcp/server.ts +19 -1
- package/src/mcp/tools/map.ts +82 -0
- package/src/mcp/tools/since.ts +44 -0
- package/src/warp/indexer.ts +398 -0
- package/src/warp/observers.ts +105 -0
- package/src/warp/open.ts +30 -0
- package/src/warp/plumbing.d.ts +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,73 @@ 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
|
+
|
|
30
|
+
## [0.3.5] - 2026-04-05
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **CI**: use `npx npm@latest` for OIDC trusted publishing — avoids
|
|
35
|
+
self-upgrade breakage on Node 22's bundled npm.
|
|
36
|
+
|
|
37
|
+
## [0.3.4] - 2026-04-05
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **CI**: upgrade npm CLI to >=11.5.1 for OIDC trusted publishing.
|
|
42
|
+
|
|
43
|
+
## [0.3.3] - 2026-04-05
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **CI**: use `npm publish` instead of `pnpm publish` for OIDC
|
|
48
|
+
provenance.
|
|
49
|
+
|
|
50
|
+
## [0.3.2] - 2026-04-05
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- **`graft init`**: zero-friction project onboarding. Scaffolds
|
|
55
|
+
`.graftignore`, adds `.graft/` to `.gitignore`, generates
|
|
56
|
+
`CLAUDE.md` snippet instructing agents to prefer graft tools,
|
|
57
|
+
and prints Claude Code hook config. Idempotent.
|
|
58
|
+
- **CI**: release workflow attaches npm tarball + SHA256SUMS to
|
|
59
|
+
GitHub releases as downloadable assets.
|
|
60
|
+
- **CI**: npm publish via OIDC provenance (no secret needed).
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- **CLI bootstrap**: `bin/graft.js` resolves tsx from the package's
|
|
65
|
+
own `node_modules`, so `graft init` works from any directory.
|
|
66
|
+
- **Docs**: regenerated README, GUIDE, BEARING, and VISION signposts.
|
|
67
|
+
- **package.json**: added `publishConfig`, `homepage`, `bugs`,
|
|
68
|
+
`packageManager`, upgraded keywords for MCP/agent discovery.
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
|
|
72
|
+
- **CI**: removed pnpm version override that conflicted with
|
|
73
|
+
`packageManager` field.
|
|
74
|
+
|
|
8
75
|
## [0.3.1] - 2026-04-05
|
|
9
76
|
|
|
10
77
|
### Changed
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ tools (outlines, diffs, symbol history) are useful to anyone.
|
|
|
10
10
|
|
|
11
11
|
## Why
|
|
12
12
|
|
|
13
|
-
Empirical analysis of 1,091 real coding sessions (Blacklight) found
|
|
13
|
+
Empirical analysis of 1,091 real coding sessions ([Blacklight](https://github.com/flyingrobots/blacklight)) found
|
|
14
14
|
that **Read accounts for 96.2 GB of context burden** — 6.6x all
|
|
15
15
|
other tools combined. 58% of reads are full-file. The fattest 2.4%
|
|
16
16
|
of reads produce 24% of raw bytes. Dynamic read caps + session
|
|
@@ -36,6 +36,16 @@ docker run -i --rm -v "$PWD:/workspace" flyingrobots/graft
|
|
|
36
36
|
|
|
37
37
|
## Quick start
|
|
38
38
|
|
|
39
|
+
```bash
|
|
40
|
+
npx @flyingrobots/graft init
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Scaffolds `.graftignore`, adds `.graft/` to `.gitignore`, generates
|
|
44
|
+
a `CLAUDE.md` snippet telling agents to prefer graft tools, and
|
|
45
|
+
prints Claude Code hook config.
|
|
46
|
+
|
|
47
|
+
Then add graft to your MCP config:
|
|
48
|
+
|
|
39
49
|
```json
|
|
40
50
|
{
|
|
41
51
|
"mcpServers": {
|
|
@@ -96,6 +106,47 @@ is structured JSON.
|
|
|
96
106
|
| `doctor` | Runtime health check |
|
|
97
107
|
| `stats` | Decision metrics summary |
|
|
98
108
|
|
|
109
|
+
## Claude Code hooks
|
|
110
|
+
|
|
111
|
+
Two hooks work alongside the MCP server to govern native `Read`
|
|
112
|
+
calls — a safety net for when agents bypass graft's tools:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"hooks": {
|
|
117
|
+
"PreToolUse": [
|
|
118
|
+
{
|
|
119
|
+
"matcher": "Read",
|
|
120
|
+
"hooks": [
|
|
121
|
+
{
|
|
122
|
+
"type": "command",
|
|
123
|
+
"command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/pretooluse-read.ts"
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
"PostToolUse": [
|
|
129
|
+
{
|
|
130
|
+
"matcher": "Read",
|
|
131
|
+
"hooks": [
|
|
132
|
+
{
|
|
133
|
+
"type": "command",
|
|
134
|
+
"command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/posttooluse-read.ts"
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Add to `.claude/settings.json` in your project root.
|
|
144
|
+
**PreToolUse** blocks banned files before the read.
|
|
145
|
+
**PostToolUse** shows the agent what `safe_read` would have saved.
|
|
146
|
+
|
|
147
|
+
See the **[Setup Guide](docs/GUIDE.md)** for full details on hooks,
|
|
148
|
+
per-editor MCP config, `.graftignore`, and troubleshooting.
|
|
149
|
+
|
|
99
150
|
## Reason codes
|
|
100
151
|
|
|
101
152
|
Every refusal or policy decision includes a machine-readable reason
|
package/bin/graft.js
CHANGED
|
@@ -1,11 +1,42 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// Graft
|
|
4
|
-
//
|
|
3
|
+
// Graft — context governor for coding agents
|
|
4
|
+
// Bootstrap: re-exec with tsx loader resolved from the package's own deps.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
8
10
|
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
|
|
14
|
+
// If already running under tsx, proceed directly
|
|
15
|
+
if (process.env.__GRAFT_TSX_LOADED === "1") {
|
|
16
|
+
const command = process.argv[2];
|
|
17
|
+
if (command === "init") {
|
|
18
|
+
const { runInit } = await import("../src/cli/init.js");
|
|
19
|
+
runInit();
|
|
20
|
+
} else if (command === "index") {
|
|
21
|
+
const { runIndex } = await import("../src/cli/index-cmd.js");
|
|
22
|
+
await runIndex();
|
|
23
|
+
} else {
|
|
24
|
+
const { createGraftServer } = await import("../src/mcp/server.js");
|
|
25
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
26
|
+
const graft = createGraftServer();
|
|
27
|
+
const transport = new StdioServerTransport();
|
|
28
|
+
await graft.getMcpServer().connect(transport);
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
// Re-exec with tsx loader from our own node_modules
|
|
32
|
+
const tsxPath = require.resolve("tsx/esm");
|
|
33
|
+
const script = join(__dirname, "graft.js");
|
|
34
|
+
try {
|
|
35
|
+
execFileSync(process.execPath, ["--import", tsxPath, script, ...process.argv.slice(2)], {
|
|
36
|
+
stdio: "inherit",
|
|
37
|
+
env: { ...process.env, __GRAFT_TSX_LOADED: "1" },
|
|
38
|
+
});
|
|
39
|
+
} catch (err) {
|
|
40
|
+
process.exit(err?.status ?? 1);
|
|
41
|
+
}
|
|
42
|
+
}
|
package/docs/GUIDE.md
CHANGED
|
@@ -12,6 +12,20 @@ Or run without installing:
|
|
|
12
12
|
npx @flyingrobots/graft
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
## Quick setup
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx @flyingrobots/graft init
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Scaffolds your project for graft in one command:
|
|
22
|
+
- Creates `.graftignore` (template with examples)
|
|
23
|
+
- Adds `.graft/` to `.gitignore`
|
|
24
|
+
- Generates a `CLAUDE.md` snippet instructing agents to prefer graft tools
|
|
25
|
+
- Prints Claude Code hook config for manual setup
|
|
26
|
+
|
|
27
|
+
Idempotent — safe to run again without duplicating entries.
|
|
28
|
+
|
|
15
29
|
## MCP Configuration
|
|
16
30
|
|
|
17
31
|
Graft runs as an MCP server over stdio. Add it to your editor or
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flyingrobots/graft",
|
|
3
|
-
"version": "0.
|
|
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
|
+
"packageManager": "pnpm@10.30.0",
|
|
6
7
|
"bin": {
|
|
7
8
|
"graft": "./bin/graft.js",
|
|
8
9
|
"git-graft": "./bin/graft.js"
|
|
@@ -16,6 +17,14 @@
|
|
|
16
17
|
"README.md",
|
|
17
18
|
"CHANGELOG.md"
|
|
18
19
|
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.build.json",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:watch": "vitest",
|
|
24
|
+
"lint": "eslint .",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"pack:check": "pnpm pack --dry-run"
|
|
27
|
+
},
|
|
19
28
|
"engines": {
|
|
20
29
|
"node": ">=20.11.0"
|
|
21
30
|
},
|
|
@@ -48,6 +57,8 @@
|
|
|
48
57
|
"url": "git+https://github.com/flyingrobots/graft.git"
|
|
49
58
|
},
|
|
50
59
|
"dependencies": {
|
|
60
|
+
"@git-stunts/git-warp": "^16.0.0",
|
|
61
|
+
"@git-stunts/plumbing": "^2.8.0",
|
|
51
62
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
52
63
|
"picomatch": "^4.0.4",
|
|
53
64
|
"tree-sitter-wasms": "0.1.13",
|
|
@@ -65,12 +76,7 @@
|
|
|
65
76
|
"typescript-eslint": "^8.58.0",
|
|
66
77
|
"vitest": "^4.1.2"
|
|
67
78
|
},
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"test": "vitest run",
|
|
71
|
-
"test:watch": "vitest",
|
|
72
|
-
"lint": "eslint .",
|
|
73
|
-
"typecheck": "tsc --noEmit",
|
|
74
|
-
"pack:check": "pnpm pack --dry-run"
|
|
79
|
+
"pnpm": {
|
|
80
|
+
"onlyBuiltDependencies": []
|
|
75
81
|
}
|
|
76
|
-
}
|
|
82
|
+
}
|
|
@@ -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
|
+
}
|
package/src/cli/init.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
const GRAFTIGNORE_TEMPLATE = `# Graft ignore patterns — files matching these are refused by safe_read.
|
|
5
|
+
# Syntax: same as .gitignore (glob matching via picomatch).
|
|
6
|
+
|
|
7
|
+
# Examples:
|
|
8
|
+
# *.generated.ts
|
|
9
|
+
# vendor/**
|
|
10
|
+
# data/**/*.json
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
const AGENT_SNIPPET = `## File reads
|
|
14
|
+
|
|
15
|
+
This project uses [graft](https://github.com/flyingrobots/graft) as
|
|
16
|
+
a context governor. Prefer graft's MCP tools over native file reads:
|
|
17
|
+
|
|
18
|
+
- Use \`safe_read\` instead of \`Read\` for file contents
|
|
19
|
+
- Use \`file_outline\` to see structure before reading
|
|
20
|
+
- Use \`read_range\` with jump table entries for targeted reads
|
|
21
|
+
- Use \`graft_diff\` instead of \`git diff\` for structural changes
|
|
22
|
+
- Use \`explain\` if you get an unfamiliar reason code
|
|
23
|
+
- Call \`set_budget\` at session start if context is tight
|
|
24
|
+
|
|
25
|
+
These tools enforce read policy, cache observations, and track
|
|
26
|
+
session metrics. Native reads bypass all of that.
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const GITIGNORE_ENTRY = "\n# Graft runtime data\n.graft/\n";
|
|
30
|
+
|
|
31
|
+
const HOOKS_CONFIG = `
|
|
32
|
+
Add to .claude/settings.json for Claude Code hook integration:
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
"hooks": {
|
|
36
|
+
"PreToolUse": [
|
|
37
|
+
{
|
|
38
|
+
"matcher": "Read",
|
|
39
|
+
"hooks": [
|
|
40
|
+
{
|
|
41
|
+
"type": "command",
|
|
42
|
+
"command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/pretooluse-read.ts"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"PostToolUse": [
|
|
48
|
+
{
|
|
49
|
+
"matcher": "Read",
|
|
50
|
+
"hooks": [
|
|
51
|
+
{
|
|
52
|
+
"type": "command",
|
|
53
|
+
"command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/posttooluse-read.ts"
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
function writeIfMissing(filePath: string, content: string, label: string): void {
|
|
63
|
+
if (fs.existsSync(filePath)) {
|
|
64
|
+
console.log(` exists ${label}`);
|
|
65
|
+
} else {
|
|
66
|
+
fs.writeFileSync(filePath, content);
|
|
67
|
+
console.log(` create ${label}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function appendIfMissing(filePath: string, marker: string, content: string, label: string): void {
|
|
72
|
+
if (fs.existsSync(filePath)) {
|
|
73
|
+
const existing = fs.readFileSync(filePath, "utf-8");
|
|
74
|
+
if (existing.includes(marker)) {
|
|
75
|
+
console.log(` exists ${label} (already has graft entry)`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
fs.appendFileSync(filePath, content);
|
|
79
|
+
console.log(` append ${label}`);
|
|
80
|
+
} else {
|
|
81
|
+
fs.writeFileSync(filePath, content.trimStart());
|
|
82
|
+
console.log(` create ${label}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function runInit(): void {
|
|
87
|
+
const cwd = process.cwd();
|
|
88
|
+
console.log(`\nInitializing graft in ${cwd}\n`);
|
|
89
|
+
|
|
90
|
+
// 1. .graftignore
|
|
91
|
+
writeIfMissing(path.join(cwd, ".graftignore"), GRAFTIGNORE_TEMPLATE, ".graftignore");
|
|
92
|
+
|
|
93
|
+
// 2. .gitignore — append .graft/
|
|
94
|
+
appendIfMissing(path.join(cwd, ".gitignore"), ".graft/", GITIGNORE_ENTRY, ".gitignore");
|
|
95
|
+
|
|
96
|
+
// 3. CLAUDE.md — append agent instructions snippet
|
|
97
|
+
appendIfMissing(path.join(cwd, "CLAUDE.md"), "safe_read", "\n" + AGENT_SNIPPET, "CLAUDE.md");
|
|
98
|
+
|
|
99
|
+
// 4. Print hooks config for manual setup
|
|
100
|
+
console.log(HOOKS_CONFIG);
|
|
101
|
+
|
|
102
|
+
console.log("Done. Add graft to your MCP config:\n");
|
|
103
|
+
console.log(` {
|
|
104
|
+
"mcpServers": {
|
|
105
|
+
"graft": {
|
|
106
|
+
"command": "npx",
|
|
107
|
+
"args": ["-y", "@flyingrobots/graft"]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
`);
|
|
112
|
+
}
|
package/src/mcp/context.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/warp/open.ts
ADDED
|
@@ -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
|
+
}
|