@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
package/docs/GUIDE.md
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# Graft Setup Guide
|
|
2
|
+
|
|
3
|
+
## Install
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @flyingrobots/graft
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Or run without installing:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @flyingrobots/graft
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## MCP Configuration
|
|
16
|
+
|
|
17
|
+
Graft runs as an MCP server over stdio. Add it to your editor or
|
|
18
|
+
agent's MCP configuration.
|
|
19
|
+
|
|
20
|
+
### Claude Code
|
|
21
|
+
|
|
22
|
+
Add to `.mcp.json` in your project root (per-project) or
|
|
23
|
+
`~/.claude.json` (global):
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"graft": {
|
|
29
|
+
"command": "npx",
|
|
30
|
+
"args": ["-y", "@flyingrobots/graft"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Cursor
|
|
37
|
+
|
|
38
|
+
Add to Cursor's MCP settings (Settings → MCP Servers → Add):
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"mcpServers": {
|
|
43
|
+
"graft": {
|
|
44
|
+
"command": "npx",
|
|
45
|
+
"args": ["-y", "@flyingrobots/graft"]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Windsurf
|
|
52
|
+
|
|
53
|
+
Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"graft": {
|
|
59
|
+
"command": "npx",
|
|
60
|
+
"args": ["-y", "@flyingrobots/graft"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### VS Code + Continue
|
|
67
|
+
|
|
68
|
+
Add to `.continue/config.json`:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": [
|
|
73
|
+
{
|
|
74
|
+
"name": "graft",
|
|
75
|
+
"command": "npx",
|
|
76
|
+
"args": ["-y", "@flyingrobots/graft"]
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Cline
|
|
83
|
+
|
|
84
|
+
Add via Cline's MCP settings UI, or in
|
|
85
|
+
`.vscode/cline_mcp_settings.json`:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"mcpServers": {
|
|
90
|
+
"graft": {
|
|
91
|
+
"command": "npx",
|
|
92
|
+
"args": ["-y", "@flyingrobots/graft"]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Any MCP-compatible client
|
|
99
|
+
|
|
100
|
+
The pattern is the same everywhere:
|
|
101
|
+
|
|
102
|
+
- **Command**: `npx`
|
|
103
|
+
- **Args**: `["-y", "@flyingrobots/graft"]`
|
|
104
|
+
- **Transport**: stdio (the default for most clients)
|
|
105
|
+
|
|
106
|
+
If your client doesn't support `npx`, install globally and use:
|
|
107
|
+
|
|
108
|
+
- **Command**: `graft`
|
|
109
|
+
- **Args**: (none)
|
|
110
|
+
|
|
111
|
+
## Claude Code Hooks
|
|
112
|
+
|
|
113
|
+
Two hooks work together to govern agent reads:
|
|
114
|
+
|
|
115
|
+
- **PreToolUse** — blocks banned files before the read happens
|
|
116
|
+
- **PostToolUse** — educates the agent on context cost after large
|
|
117
|
+
file reads complete
|
|
118
|
+
|
|
119
|
+
### Setup
|
|
120
|
+
|
|
121
|
+
Add to `.claude/settings.json` in your project root:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"hooks": {
|
|
126
|
+
"PreToolUse": [
|
|
127
|
+
{
|
|
128
|
+
"matcher": "Read",
|
|
129
|
+
"hooks": [
|
|
130
|
+
{
|
|
131
|
+
"type": "command",
|
|
132
|
+
"command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/pretooluse-read.ts"
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
"PostToolUse": [
|
|
138
|
+
{
|
|
139
|
+
"matcher": "Read",
|
|
140
|
+
"hooks": [
|
|
141
|
+
{
|
|
142
|
+
"type": "command",
|
|
143
|
+
"command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/posttooluse-read.ts"
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
If developing graft itself, replace the `node_modules/...` paths
|
|
153
|
+
with local paths:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
src/hooks/pretooluse-read.ts (PreToolUse)
|
|
157
|
+
src/hooks/posttooluse-read.ts (PostToolUse)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### PreToolUse — ban enforcement
|
|
161
|
+
|
|
162
|
+
When the agent calls `Read(file_path)`, the PreToolUse hook:
|
|
163
|
+
|
|
164
|
+
1. Evaluates the file against graft's policy
|
|
165
|
+
2. **Banned files** (binaries, lockfiles, secrets, `.graftignore`
|
|
166
|
+
matches): exits 2 (block) with refusal reason and next steps
|
|
167
|
+
3. **Everything else**: exits 0 — lets native Read proceed
|
|
168
|
+
|
|
169
|
+
The PreToolUse hook does NOT block large files. It only enforces
|
|
170
|
+
hard bans. Large file governance is handled by the PostToolUse hook.
|
|
171
|
+
|
|
172
|
+
### PostToolUse — context cost education
|
|
173
|
+
|
|
174
|
+
After a Read completes, the PostToolUse hook evaluates what
|
|
175
|
+
`safe_read` would have done and tells the agent the cost:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
[graft] You just read 450 lines (18KB) into context.
|
|
179
|
+
safe_read would have returned a structural outline (2048 bytes),
|
|
180
|
+
saving 16.0KB of context. Threshold: 150 lines / 12KB.
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
This feedback appears for large JS/TS files where an outline was
|
|
184
|
+
available. Small files, non-JS/TS files, and nonexistent files
|
|
185
|
+
produce no feedback. The hook always exits 0 — it never blocks.
|
|
186
|
+
|
|
187
|
+
### Limitations
|
|
188
|
+
|
|
189
|
+
Both hooks run as standalone processes — they do not share state
|
|
190
|
+
with the MCP server. This means:
|
|
191
|
+
|
|
192
|
+
- No session-depth dynamic caps (early/mid/late)
|
|
193
|
+
- No re-read suppression or cache hits
|
|
194
|
+
- No structural diffs on changed files
|
|
195
|
+
- No metrics tracking or receipts
|
|
196
|
+
|
|
197
|
+
For the full experience, agents should use graft's MCP tools
|
|
198
|
+
(`safe_read`, `file_outline`, `read_range`) directly. The hooks
|
|
199
|
+
are a safety net; the MCP server is the full governor.
|
|
200
|
+
|
|
201
|
+
### Disabling
|
|
202
|
+
|
|
203
|
+
To disable hooks locally without removing the project config,
|
|
204
|
+
add to `.claude/settings.local.json`:
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"hooks": {}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Tool Reference
|
|
213
|
+
|
|
214
|
+
| Tool | Description |
|
|
215
|
+
|------|-------------|
|
|
216
|
+
| `safe_read` | Policy-enforced file read. Returns full content for small files, structural outline with jump table for large files, or refusal with reason code for banned files. Detects re-reads and returns cached outlines or structural diffs. |
|
|
217
|
+
| `file_outline` | Structural skeleton of a file — function signatures, class shapes, exports. Includes a jump table mapping each symbol to its line range for targeted `read_range` follow-ups. |
|
|
218
|
+
| `read_range` | Read a bounded range of lines from a file. Maximum 250 lines. Use jump table entries from `file_outline` or `safe_read` to target specific symbols. |
|
|
219
|
+
| `changed_since` | Check if a file changed since it was last read. Returns structural diff (added/removed/changed symbols) or "unchanged". Peek mode by default; pass `consume: true` to update the observation cache. |
|
|
220
|
+
| `graft_diff` | Structural diff between two git refs. Shows added, removed, and changed symbols per file — not line hunks. Defaults to working tree vs HEAD. |
|
|
221
|
+
| `run_capture` | Execute a shell command and return the last N lines of output (default 60). Full output saved to `.graft/logs/capture.log` for follow-up `read_range` calls. |
|
|
222
|
+
| `state_save` | Save session working state (max 8 KB). Use for session bookmarks: current task, files modified, next planned actions. |
|
|
223
|
+
| `state_load` | Load previously saved session state. Returns null if no state has been saved. |
|
|
224
|
+
| `doctor` | Runtime health check. Shows project root, parser status, active thresholds, session depth, and message count. |
|
|
225
|
+
| `set_budget` | Declare a session byte budget. Graft tightens read thresholds as the budget drains — no single read may consume more than 5% of remaining budget. Call once at session start. |
|
|
226
|
+
| `explain` | Explain a graft reason code. Returns human-readable meaning and recommended next action for any code (e.g., `BINARY`, `BUDGET_CAP`). Case-insensitive. |
|
|
227
|
+
| `stats` | Decision metrics for the current session. Total reads, outlines, refusals, cache hits, and bytes avoided. |
|
|
228
|
+
|
|
229
|
+
## What the agent sees
|
|
230
|
+
|
|
231
|
+
Once configured, the agent gains 12 new tools. Here's what
|
|
232
|
+
happens when it uses them:
|
|
233
|
+
|
|
234
|
+
### Reading files
|
|
235
|
+
|
|
236
|
+
The agent calls `safe_read` instead of reading files directly.
|
|
237
|
+
Graft decides what to return:
|
|
238
|
+
|
|
239
|
+
- **Small files** (< 150 lines, < 12 KB): full content, as normal.
|
|
240
|
+
- **Large files**: a structural outline showing function signatures,
|
|
241
|
+
class shapes, and exports — with a jump table mapping each symbol
|
|
242
|
+
to its line range. The agent can then use `read_range` to read
|
|
243
|
+
specific sections.
|
|
244
|
+
- **Banned files** (binaries, lockfiles, `.env`, minified bundles,
|
|
245
|
+
build output): refused with a reason code and suggested
|
|
246
|
+
alternatives.
|
|
247
|
+
- **Re-reads**: if the agent reads the same unchanged file twice,
|
|
248
|
+
graft returns the cached outline instead of the full content.
|
|
249
|
+
If the file changed, it returns a structural diff (added/removed/
|
|
250
|
+
changed symbols).
|
|
251
|
+
|
|
252
|
+
### Structural navigation
|
|
253
|
+
|
|
254
|
+
`file_outline` returns the structural skeleton of any file —
|
|
255
|
+
function signatures, class members, exports — without the bodies.
|
|
256
|
+
Each symbol has a line range so the agent can follow up with
|
|
257
|
+
`read_range` for the specific code it needs.
|
|
258
|
+
|
|
259
|
+
### Git diffs
|
|
260
|
+
|
|
261
|
+
`graft_diff` shows what changed between git refs at the symbol
|
|
262
|
+
level: "function `foo` gained a parameter" instead of line hunks.
|
|
263
|
+
|
|
264
|
+
### Budget governor
|
|
265
|
+
|
|
266
|
+
If the agent calls `set_budget(bytes)` at session start, graft
|
|
267
|
+
tracks cumulative bytes consumed and tightens thresholds as the
|
|
268
|
+
budget drains. No single read may consume more than 5% of remaining
|
|
269
|
+
budget. When the budget is exhausted, all reads return outlines.
|
|
270
|
+
|
|
271
|
+
Budget status appears in every receipt:
|
|
272
|
+
```json
|
|
273
|
+
"budget": { "total": 500000, "consumed": 14345, "remaining": 485655, "fraction": 0.029 }
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Session awareness
|
|
277
|
+
|
|
278
|
+
Graft tracks the session and tightens policy as context pressure
|
|
279
|
+
grows:
|
|
280
|
+
|
|
281
|
+
| Session stage | Max read size |
|
|
282
|
+
|---------------|---------------|
|
|
283
|
+
| Early (< 100 messages) | 20 KB |
|
|
284
|
+
| Mid (100–500 messages) | 10 KB |
|
|
285
|
+
| Late (> 500 messages) | 4 KB |
|
|
286
|
+
|
|
287
|
+
Tripwires warn when sessions are going off the rails (> 500
|
|
288
|
+
messages, edit-bash loops, runaway tool calls).
|
|
289
|
+
|
|
290
|
+
### Receipts
|
|
291
|
+
|
|
292
|
+
Every response includes a `_receipt` block with session ID,
|
|
293
|
+
sequence number, projection type, bytes returned, and cumulative
|
|
294
|
+
counters. This is for usage analysis — you can ignore it.
|
|
295
|
+
|
|
296
|
+
## Configuration
|
|
297
|
+
|
|
298
|
+
### .graftignore
|
|
299
|
+
|
|
300
|
+
Create a `.graftignore` file in your project root to ban
|
|
301
|
+
additional file patterns:
|
|
302
|
+
|
|
303
|
+
```text
|
|
304
|
+
# Generated files
|
|
305
|
+
*.generated.ts
|
|
306
|
+
*.snap
|
|
307
|
+
|
|
308
|
+
# Vendor code
|
|
309
|
+
vendor/**
|
|
310
|
+
|
|
311
|
+
# Large data files
|
|
312
|
+
data/**/*.json
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Patterns follow the same syntax as `.gitignore` (glob matching
|
|
316
|
+
via picomatch).
|
|
317
|
+
|
|
318
|
+
### Policy defaults
|
|
319
|
+
|
|
320
|
+
| Setting | Default | Description |
|
|
321
|
+
|---------|---------|-------------|
|
|
322
|
+
| Line threshold | 150 | Files over this → outline |
|
|
323
|
+
| Byte threshold | 12 KB | Files over this → outline |
|
|
324
|
+
| Max range | 250 lines | read_range cap |
|
|
325
|
+
| State cap | 8 KB | state_save max size |
|
|
326
|
+
| Capture tail | 60 lines | run_capture default |
|
|
327
|
+
|
|
328
|
+
These are not yet configurable at runtime (planned for a future
|
|
329
|
+
release).
|
|
330
|
+
|
|
331
|
+
## Troubleshooting
|
|
332
|
+
|
|
333
|
+
### "Tool not found" or no graft tools visible
|
|
334
|
+
|
|
335
|
+
- Verify graft is installed: `npx @flyingrobots/graft --help`
|
|
336
|
+
(should start the server; Ctrl+C to stop)
|
|
337
|
+
- Check your MCP config syntax — JSON must be valid
|
|
338
|
+
- Restart your editor/agent after adding MCP config
|
|
339
|
+
- Some clients cache tool lists — try reopening the project
|
|
340
|
+
|
|
341
|
+
### Agent keeps getting outlines instead of content
|
|
342
|
+
|
|
343
|
+
Your files are over 150 lines or 12 KB. This is intentional.
|
|
344
|
+
The agent should use the jump table from the outline to
|
|
345
|
+
`read_range` the specific section it needs.
|
|
346
|
+
|
|
347
|
+
### Agent can't read a file (refused)
|
|
348
|
+
|
|
349
|
+
Check the reason code in the response:
|
|
350
|
+
- `BINARY` — binary file, use `ls -lh` or `file` for metadata
|
|
351
|
+
- `LOCKFILE` — read `package.json` instead
|
|
352
|
+
- `SECRET` — `.env` files are banned for safety
|
|
353
|
+
- `BUILD_OUTPUT` — read the source file, not `dist/`
|
|
354
|
+
- `GRAFTIGNORE` — file matches a `.graftignore` pattern
|
|
355
|
+
|
|
356
|
+
### graft is slow on first call
|
|
357
|
+
|
|
358
|
+
Tree-sitter WASM grammars load on first parse (~200ms). Subsequent
|
|
359
|
+
calls are fast.
|
|
360
|
+
|
|
361
|
+
## Verify it works
|
|
362
|
+
|
|
363
|
+
After setup, ask your agent to read a large file in your project.
|
|
364
|
+
Instead of dumping the entire file, it should return an outline
|
|
365
|
+
with a jump table. That's graft working.
|
|
366
|
+
|
|
367
|
+
You can also ask the agent to call `doctor` to verify:
|
|
368
|
+
|
|
369
|
+
```
|
|
370
|
+
Use the doctor tool to check graft's health.
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
This returns the project root, parser status, active thresholds,
|
|
374
|
+
and session depth.
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flyingrobots/graft",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Context governor for coding agents — MCP server with policy-enforced reads, structural outlines, and session tracking",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"graft": "./bin/graft.js",
|
|
8
|
+
"git-graft": "./bin/graft.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"docs/GUIDE.md",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"NOTICE",
|
|
16
|
+
"README.md",
|
|
17
|
+
"CHANGELOG.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20.11.0"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org/"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/flyingrobots/graft#readme",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/flyingrobots/graft/issues"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"mcp",
|
|
32
|
+
"mcp-server",
|
|
33
|
+
"model-context-protocol",
|
|
34
|
+
"claude-code",
|
|
35
|
+
"cursor",
|
|
36
|
+
"coding-agent",
|
|
37
|
+
"ai-agent",
|
|
38
|
+
"context-window",
|
|
39
|
+
"code-navigation",
|
|
40
|
+
"structural-diff",
|
|
41
|
+
"tree-sitter",
|
|
42
|
+
"developer-tools"
|
|
43
|
+
],
|
|
44
|
+
"author": "James Ross <james@flyingrobots.dev>",
|
|
45
|
+
"license": "Apache-2.0",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/flyingrobots/graft.git"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
52
|
+
"picomatch": "^4.0.4",
|
|
53
|
+
"tree-sitter-wasms": "0.1.13",
|
|
54
|
+
"tsx": "^4.21.0",
|
|
55
|
+
"web-tree-sitter": "^0.20.8",
|
|
56
|
+
"zod": "4.3.6"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@eslint/js": "^10.0.1",
|
|
60
|
+
"@types/node": "^25.5.0",
|
|
61
|
+
"@types/picomatch": "^4.0.2",
|
|
62
|
+
"eslint": "^10.1.0",
|
|
63
|
+
"globals": "^17.4.0",
|
|
64
|
+
"typescript": "^6.0.2",
|
|
65
|
+
"typescript-eslint": "^8.58.0",
|
|
66
|
+
"vitest": "^4.1.2"
|
|
67
|
+
},
|
|
68
|
+
"scripts": {
|
|
69
|
+
"build": "tsc -p tsconfig.build.json",
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"test:watch": "vitest",
|
|
72
|
+
"lint": "eslint .",
|
|
73
|
+
"typecheck": "tsc --noEmit",
|
|
74
|
+
"pack:check": "pnpm pack --dry-run"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// CanonicalJsonCodec — deterministic JSON with sorted keys
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// Subset of RFC 8785 (JSON Canonicalization Scheme):
|
|
6
|
+
// - Object keys sorted lexicographically at every nesting level
|
|
7
|
+
// - Compact output (no whitespace)
|
|
8
|
+
// - Deterministic: same data always produces the same string
|
|
9
|
+
//
|
|
10
|
+
// Enables stable hashes, diffable logs, reproducible receipts.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
import type { JsonCodec } from "../ports/codec.js";
|
|
14
|
+
|
|
15
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
const proto = Object.getPrototypeOf(value) as unknown;
|
|
20
|
+
return proto === Object.prototype || proto === null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sortDeep(value: unknown, seen = new WeakSet()): unknown {
|
|
24
|
+
if (value === null || typeof value !== "object") {
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
if (!isPlainObject(value) && !Array.isArray(value)) {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
if (seen.has(value)) {
|
|
31
|
+
throw new TypeError("Converting circular structure to JSON");
|
|
32
|
+
}
|
|
33
|
+
seen.add(value);
|
|
34
|
+
try {
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
return value.map((v) => sortDeep(v, seen));
|
|
37
|
+
}
|
|
38
|
+
const sorted: Record<string, unknown> = {};
|
|
39
|
+
for (const key of Object.keys(value).sort()) {
|
|
40
|
+
sorted[key] = sortDeep(value[key], seen);
|
|
41
|
+
}
|
|
42
|
+
return sorted;
|
|
43
|
+
} finally {
|
|
44
|
+
seen.delete(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class CanonicalJsonCodec implements JsonCodec {
|
|
49
|
+
encode(value: unknown): string {
|
|
50
|
+
return JSON.stringify(sortDeep(value));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
decode(data: string): unknown {
|
|
54
|
+
return JSON.parse(data) as unknown;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Node.js filesystem adapter — implements the FileSystem port
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as fsp from "node:fs/promises";
|
|
7
|
+
import type { FileSystem } from "../ports/filesystem.js";
|
|
8
|
+
|
|
9
|
+
class NodeFileSystem implements FileSystem {
|
|
10
|
+
readFile(path: string, encoding: "utf-8"): Promise<string>;
|
|
11
|
+
readFile(path: string): Promise<Buffer>;
|
|
12
|
+
readFile(path: string, encoding?: "utf-8"): Promise<string | Buffer> {
|
|
13
|
+
if (encoding !== undefined) return fsp.readFile(path, encoding);
|
|
14
|
+
return fsp.readFile(path);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
writeFile(path: string, data: string, encoding: "utf-8"): Promise<void> {
|
|
18
|
+
return fsp.writeFile(path, data, encoding);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
appendFile(path: string, data: string, encoding: "utf-8"): Promise<void> {
|
|
22
|
+
return fsp.appendFile(path, data, encoding);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async mkdir(path: string, options: { recursive: true }): Promise<void> {
|
|
26
|
+
await fsp.mkdir(path, options);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async stat(path: string): Promise<{ size: number }> {
|
|
30
|
+
const s = await fsp.stat(path);
|
|
31
|
+
return { size: s.size };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
readFileSync(path: string, encoding: "utf-8"): string {
|
|
35
|
+
return fs.readFileSync(path, encoding);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const nodeFs: FileSystem = new NodeFileSystem();
|
package/src/git/diff.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface ChangedFilesOptions {
|
|
4
|
+
cwd: string;
|
|
5
|
+
base?: string | undefined;
|
|
6
|
+
head?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class GitError extends Error {
|
|
10
|
+
constructor(message: string) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "GitError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function git(args: string[], cwd: string): string {
|
|
17
|
+
return execFileSync("git", args, {
|
|
18
|
+
cwd,
|
|
19
|
+
encoding: "utf-8",
|
|
20
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function refExists(ref: string, cwd: string): boolean {
|
|
25
|
+
try {
|
|
26
|
+
git(["rev-parse", "--verify", ref], cwd);
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function objectExists(ref: string, filePath: string, cwd: string): boolean {
|
|
34
|
+
try {
|
|
35
|
+
git(["cat-file", "-e", `${ref}:${filePath}`], cwd);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* List files changed between two refs, or between a ref and the working tree.
|
|
44
|
+
* If head is omitted, diffs against the working tree.
|
|
45
|
+
* If both base and head are omitted, diffs HEAD against the working tree.
|
|
46
|
+
*
|
|
47
|
+
* Throws GitError for invalid refs or non-git directories.
|
|
48
|
+
* Returns empty array only when there are genuinely no changes.
|
|
49
|
+
*/
|
|
50
|
+
export function getChangedFiles(opts: ChangedFilesOptions): string[] {
|
|
51
|
+
const base = opts.base ?? "HEAD";
|
|
52
|
+
const args = opts.head !== undefined
|
|
53
|
+
? ["diff", "--name-only", base, opts.head]
|
|
54
|
+
: ["diff", "--name-only", base];
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const output = git(args, opts.cwd).trim();
|
|
58
|
+
if (output === "") return [];
|
|
59
|
+
return output.split("\n");
|
|
60
|
+
} catch (err: unknown) {
|
|
61
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
62
|
+
throw new GitError(`git diff failed: ${msg}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get the content of a file at a specific git ref.
|
|
68
|
+
* Returns null if the file doesn't exist at that ref (clean absence).
|
|
69
|
+
* Throws GitError for invalid refs or git failures.
|
|
70
|
+
*
|
|
71
|
+
* Uses `git rev-parse --verify` and `git cat-file -e` for stable
|
|
72
|
+
* detection — no error message parsing.
|
|
73
|
+
*/
|
|
74
|
+
export function getFileAtRef(
|
|
75
|
+
ref: string,
|
|
76
|
+
filePath: string,
|
|
77
|
+
cwd: string,
|
|
78
|
+
): string | null {
|
|
79
|
+
// Validate the ref exists (stable probe, no message parsing)
|
|
80
|
+
if (!refExists(ref, cwd)) {
|
|
81
|
+
throw new GitError(`ref does not exist: ${ref}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check if the object exists at this ref (stable probe)
|
|
85
|
+
if (!objectExists(ref, filePath, cwd)) {
|
|
86
|
+
return null; // Clean absence — file not in this ref
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Object exists — read it
|
|
90
|
+
try {
|
|
91
|
+
return git(["show", `${ref}:${filePath}`], cwd);
|
|
92
|
+
} catch (err: unknown) {
|
|
93
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
94
|
+
throw new GitError(`git show ${ref}:${filePath} failed: ${msg}`);
|
|
95
|
+
}
|
|
96
|
+
}
|