dsh-graft-plugin 0.1.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/LICENSE +21 -0
- package/README.md +129 -0
- package/cordis.patch.yml +24 -0
- package/package.json +79 -0
- package/screenshots.json +5 -0
- package/scripts/postinstall.mjs +109 -0
- package/scripts/test-autosync.mjs +117 -0
- package/scripts/test-command.mjs +104 -0
- package/scripts/test-harness.mjs +240 -0
- package/scripts/test-tools.mjs +53 -0
- package/src/auto-sync.js +262 -0
- package/src/client.js +843 -0
- package/src/command.js +147 -0
- package/src/graft-tools.js +346 -0
- package/src/index.js +701 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adithyan Raj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# dsh-graft-plugin
|
|
2
|
+
|
|
3
|
+
      
|
|
4
|
+
|
|
5
|
+
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin that puts [graft](https://github.com/trailhq/Graft) — a prebuilt graph of every symbol, its `file:line` span, and who calls what — in front of both you and the model.
|
|
6
|
+
|
|
7
|
+
It does four things:
|
|
8
|
+
|
|
9
|
+
- **A chip in the composer** naming the graft index the current session is actually serving, whether it still matches the code, and how many tokens graft has saved in that repo.
|
|
10
|
+
- **Five tools for the model** — `graft_ask`, `graft_grep`, `graft_callers`, `graft_skeleton`, `graft_map` — resolved per session, so one dsh can serve several workspaces at once.
|
|
11
|
+
- **`/graft`** to build or rebuild the index, and **auto-rebuild** after the model edits files.
|
|
12
|
+
- **A `graft viz` button** that opens the graph as a tab in [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar).
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
dsh plugin --profile web add dsh-graft-plugin
|
|
18
|
+
dsh --profile web
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The install also puts the graft CLI (`@nanonets/graft`) on your PATH — every tool here shells out to it, so a plugin without it can do nothing. If your global npm prefix needs elevation the postinstall says so and prints the one command to run by hand:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install -g @nanonets/graft
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then index a repo once:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
cd <your repo>
|
|
31
|
+
graft build
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### The sidebar tab
|
|
35
|
+
|
|
36
|
+
The `graft viz` button needs `dsh-better-sidebar` **mounted**. It ships as a dependency of this plugin, so it is already downloaded — but dsh only mounts what the profile lists:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
dsh plugin --profile web add dsh-better-sidebar
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Skip it if you do not want the tab. The chip, the tools, `/graft` and the auto-rebuild all work without it; the button just reports that the sidebar is unavailable.
|
|
43
|
+
|
|
44
|
+
> This plugin does **not** insert a `dsh-better-sidebar` row itself, on purpose. That package's own bundle row carries a guard that disables it when another enabled row already mounts the same package. A second row with a different id makes that guard circular, and the losing outcome is *your existing sidebar silently disabling itself*. One explicit command is worth more than that risk.
|
|
45
|
+
|
|
46
|
+
## What you get
|
|
47
|
+
|
|
48
|
+
### The chip
|
|
49
|
+
|
|
50
|
+
Sits in the composer next to the model name:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
● graft · my-repo in sync
|
|
54
|
+
◐ graft · my-repo syncing
|
|
55
|
+
▲ graft · my-repo stale
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Hovering (or clicking) opens a card with the resolved root, node and edge counts, freshness, the running saved-token tally, and the `graft viz` button.
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
The root is resolved from **the session's own workspace**, not from wherever dsh was launched. That is the whole reason this exists rather than the `graft mcp` server: `graft mcp` resolves its repo once, from its own working directory at startup, and nothing can re-point it afterwards. Launched from a home directory it answers *"no graph found"* for every workspace you open.
|
|
63
|
+
|
|
64
|
+
### The visualiser
|
|
65
|
+
|
|
66
|
+
The `graft viz` button opens the graph as a tab in [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar). The context view lays out the whole graph:
|
|
67
|
+
|
|
68
|
+

|
|
69
|
+
|
|
70
|
+
The code view drills into a single file — what it contains, what it depends on, what depends on it:
|
|
71
|
+
|
|
72
|
+

|
|
73
|
+
|
|
74
|
+
### The tools
|
|
75
|
+
|
|
76
|
+
| tool | what it answers |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `graft_ask` | "how does X work" / "where is Y" — ranked hits with code inlined at exact `file:line` |
|
|
79
|
+
| `graft_grep` | every occurrence, grouped by enclosing symbol (`graft_ask` is top-N and misses some) |
|
|
80
|
+
| `graft_callers` | who calls a symbol, what it calls, or the full blast radius before a rename |
|
|
81
|
+
| `graft_skeleton` | a file's whole API in ~200 tokens |
|
|
82
|
+
| `graft_map` | orientation in an unfamiliar tree |
|
|
83
|
+
|
|
84
|
+
None of them take a boolean parameter. They take string enums instead — `detail: "full"` rather than `full: true` — which reads better to a model and sidesteps a real failure mode on some OpenAI-compatible servers, where a tool call that *sets* a boolean is emitted as literal `<tool_call>` text and never runs.
|
|
85
|
+
|
|
86
|
+
### `/graft`
|
|
87
|
+
|
|
88
|
+
- `/graft` — build the index, or rebuild it if one exists
|
|
89
|
+
- `/graft status` — what the chip knows, as text
|
|
90
|
+
- `/graft deep` — a full rebuild
|
|
91
|
+
|
|
92
|
+
### Auto-rebuild
|
|
93
|
+
|
|
94
|
+
After the model writes or edits files, the index is rebuilt in the background, debounced so a long turn does not rebuild on every keystroke. This reuses graft's own sync runner rather than reimplementing it, so it cannot drift from what `graft build` does.
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
98
|
+
All optional, all on the plugin's row in your profile's `cordis.patch.yml`:
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
- id: graft-status
|
|
102
|
+
name: 'dsh-graft-plugin'
|
|
103
|
+
config:
|
|
104
|
+
cwd: 'C:/path/to/repo' # pin to one repo instead of resolving per session
|
|
105
|
+
tools: false # drop the graft_* tools, keep the chip
|
|
106
|
+
command: false # drop /graft
|
|
107
|
+
promptSection: false # stop telling the model to prefer graft
|
|
108
|
+
autoBuild: false # stop rebuilding after edits
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Requirements
|
|
112
|
+
|
|
113
|
+
- dsh `0.1.2-rc.1` or newer, web profile
|
|
114
|
+
- Node 22+
|
|
115
|
+
- the `graft` CLI on PATH (installed for you; see above)
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
npm install
|
|
121
|
+
npm test # portable: builds a synthetic graft index in a temp dir
|
|
122
|
+
npm run test:live # drives the real graft CLI against a real repo
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`npm test` needs no graft index and no graft install anywhere on the machine. The one suite that does build a real index skips itself, loudly, when graft is absent — a missing CLI is a setup state, not a defect.
|
|
126
|
+
|
|
127
|
+
## Licence
|
|
128
|
+
|
|
129
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# dsh-graft-plugin bundle patch: mount the host half, which reads the graft index files
|
|
2
|
+
# and answers the browser's questions about them. The browser half — the chip,
|
|
3
|
+
# the viz button and the sidebar tab — is mounted by the `dsh.client` entry in
|
|
4
|
+
# package.json, not from here.
|
|
5
|
+
#
|
|
6
|
+
# `name` MUST be the published package name, because the loader imports this
|
|
7
|
+
# exact specifier. `id` is this row's handle and is deliberately NOT renamed
|
|
8
|
+
# alongside the package: it is the key the storage file (graft-status.json), the
|
|
9
|
+
# prompt section (graft-status:usage) and every effect label already use, and
|
|
10
|
+
# churning it would orphan a user's saved token tally for no gain.
|
|
11
|
+
#
|
|
12
|
+
# Config (all optional, all set on this row):
|
|
13
|
+
# cwd pin the lookup to one repo. Left unset it resolves
|
|
14
|
+
# from the session's own workspace, then dsh's working
|
|
15
|
+
# directory — the same rule `graft mcp` applies, so the
|
|
16
|
+
# chip and the tools cannot disagree about which graph
|
|
17
|
+
# is in play.
|
|
18
|
+
# tools: false drop the graft_* tools, keep the chip
|
|
19
|
+
# command: false drop the /graft command
|
|
20
|
+
# promptSection: false stop telling the model to prefer graft
|
|
21
|
+
# autoBuild: false stop rebuilding the index after edits
|
|
22
|
+
- insert:
|
|
23
|
+
- id: graft-status
|
|
24
|
+
name: 'dsh-graft-plugin'
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-graft-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shows which graft index a dsh session is serving and whether it still matches the code, and gives the model graft_ask, graft_grep, graft_callers, graft_skeleton and graft_map as native tools.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./client": "./src/client.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./package.json": "./package.json",
|
|
12
|
+
"./graft-tools": "./src/graft-tools.js",
|
|
13
|
+
"./auto-sync": "./src/auto-sync.js",
|
|
14
|
+
"./command": "./src/command.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/index.js",
|
|
18
|
+
"src/graft-tools.js",
|
|
19
|
+
"src/auto-sync.js",
|
|
20
|
+
"src/command.js",
|
|
21
|
+
"src/client.js",
|
|
22
|
+
"cordis.patch.yml",
|
|
23
|
+
"scripts/postinstall.mjs",
|
|
24
|
+
"scripts/test-harness.mjs",
|
|
25
|
+
"scripts/test-tools.mjs",
|
|
26
|
+
"scripts/test-autosync.mjs",
|
|
27
|
+
"scripts/test-command.mjs",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"screenshots.json"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node scripts/test-harness.mjs && node scripts/test-autosync.mjs && node scripts/test-command.mjs",
|
|
34
|
+
"test:live": "node scripts/test-tools.mjs",
|
|
35
|
+
"postinstall": "node scripts/postinstall.mjs"
|
|
36
|
+
},
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
},
|
|
41
|
+
"client": {
|
|
42
|
+
"platform": "web",
|
|
43
|
+
"inject": [
|
|
44
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
45
|
+
"@deepseek-ai/dsh-api-remotes"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"dsh-better-sidebar": "^0.18.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.6"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@deepseek-ai/dsh-tools": "0.1.2-rc.1",
|
|
57
|
+
"@deepseek-ai/dsh-typert-protocol": "0.1.2-rc.1"
|
|
58
|
+
},
|
|
59
|
+
"keywords": [
|
|
60
|
+
"dsh-plugin",
|
|
61
|
+
"dsh",
|
|
62
|
+
"graft",
|
|
63
|
+
"code-graph",
|
|
64
|
+
"context",
|
|
65
|
+
"tools"
|
|
66
|
+
],
|
|
67
|
+
"repository": {
|
|
68
|
+
"type": "git",
|
|
69
|
+
"url": "git+https://github.com/adithyanraj03/dsh-graft-plugin.git"
|
|
70
|
+
},
|
|
71
|
+
"homepage": "https://adithyanraj03.github.io/dsh-graft-plugin/",
|
|
72
|
+
"bugs": {
|
|
73
|
+
"url": "https://github.com/adithyanraj03/dsh-graft-plugin/issues"
|
|
74
|
+
},
|
|
75
|
+
"engines": {
|
|
76
|
+
"node": ">=22"
|
|
77
|
+
},
|
|
78
|
+
"license": "MIT"
|
|
79
|
+
}
|
package/screenshots.json
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put the graft CLI on the machine, and say what is still left to do.
|
|
3
|
+
*
|
|
4
|
+
* WHY A POSTINSTALL AT ALL
|
|
5
|
+
*
|
|
6
|
+
* This plugin is a front end. Every tool it registers shells out to `graft`,
|
|
7
|
+
* and the chip reads an index `graft build` writes — so a correct install of
|
|
8
|
+
* the plugin with no `graft` on PATH is a plugin that can do nothing, and says
|
|
9
|
+
* so only at the first tool call. Installing it here turns that into one
|
|
10
|
+
* install step instead of two.
|
|
11
|
+
*
|
|
12
|
+
* WHY IT NEVER FAILS THE INSTALL
|
|
13
|
+
*
|
|
14
|
+
* A global npm install is exactly the kind of thing that is refused: no write
|
|
15
|
+
* permission on the prefix, an offline machine, a locked-down CI image, or
|
|
16
|
+
* `--ignore-scripts` (which skips this file entirely and is a perfectly
|
|
17
|
+
* reasonable thing for a careful user to do). None of those mean the PLUGIN
|
|
18
|
+
* failed to install, so none of them exit non-zero. They print the one command
|
|
19
|
+
* to run by hand and get out of the way.
|
|
20
|
+
*
|
|
21
|
+
* Set DSH_GRAFT_SKIP_POSTINSTALL=1 to skip it outright.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from 'node:child_process'
|
|
25
|
+
import { delimiter, join } from 'node:path'
|
|
26
|
+
import { existsSync } from 'node:fs'
|
|
27
|
+
|
|
28
|
+
/** The package behind the `graft` command. The GitHub home is trailhq/Graft. */
|
|
29
|
+
const PACKAGE = '@nanonets/graft'
|
|
30
|
+
|
|
31
|
+
const BOLD = '[1m'
|
|
32
|
+
const DIM = '[2m'
|
|
33
|
+
const OFF = '[0m'
|
|
34
|
+
|
|
35
|
+
const say = (line = '') => process.stdout.write(`${line}\n`)
|
|
36
|
+
|
|
37
|
+
/** Is `graft` already reachable? Checked before installing, so a re-install is a no-op. */
|
|
38
|
+
function graftOnPath(env = process.env, exists = existsSync) {
|
|
39
|
+
const path = String(env.PATH ?? env.Path ?? '')
|
|
40
|
+
for (const dir of path.split(delimiter)) {
|
|
41
|
+
if (dir === '') continue
|
|
42
|
+
for (const name of ['graft.cmd', 'graft.exe', 'graft']) {
|
|
43
|
+
if (exists(join(dir, name))) return true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Run a command to completion. Resolves with the exit code; never rejects. */
|
|
50
|
+
function run(command, args) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
let child
|
|
53
|
+
try {
|
|
54
|
+
// `shell: true` on Windows, because npm is a `.cmd` shim that cannot be
|
|
55
|
+
// exec'd directly. No argument here contains a space, so the unquoted
|
|
56
|
+
// argv join that makes `shell: true` dangerous cannot bite.
|
|
57
|
+
child = spawn(command, args, { stdio: 'inherit', shell: process.platform === 'win32' })
|
|
58
|
+
} catch {
|
|
59
|
+
resolve(null)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
child.on('error', () => resolve(null))
|
|
63
|
+
child.on('close', (code) => resolve(code))
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function main() {
|
|
68
|
+
if (process.env.DSH_GRAFT_SKIP_POSTINSTALL === '1') return
|
|
69
|
+
|
|
70
|
+
say()
|
|
71
|
+
say(`${BOLD}dsh-graft-plugin${OFF}`)
|
|
72
|
+
|
|
73
|
+
if (graftOnPath()) {
|
|
74
|
+
say(` ${DIM}graft is already on PATH - nothing to install.${OFF}`)
|
|
75
|
+
} else {
|
|
76
|
+
say(` installing the graft CLI (${PACKAGE}) globally...`)
|
|
77
|
+
const code = await run('npm', ['install', '-g', PACKAGE])
|
|
78
|
+
if (code === 0) {
|
|
79
|
+
say(` ${BOLD}done.${OFF} graft is on PATH.`)
|
|
80
|
+
} else {
|
|
81
|
+
say()
|
|
82
|
+
say(` ${BOLD}graft was not installed${OFF} (npm exited ${code === null ? 'without running' : code}).`)
|
|
83
|
+
say(' This plugin needs it. Install it by hand:')
|
|
84
|
+
say()
|
|
85
|
+
say(` npm install -g ${PACKAGE}`)
|
|
86
|
+
say()
|
|
87
|
+
say(' On a machine where the global prefix needs elevation, run that in an')
|
|
88
|
+
say(' administrator shell, or point npm at a writable prefix first.')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
say()
|
|
93
|
+
say(` ${BOLD}Next:${OFF}`)
|
|
94
|
+
say(' 1. Index a repo: cd <your repo> && graft build')
|
|
95
|
+
say(' 2. The sidebar tab for `graft viz` needs dsh-better-sidebar mounted.')
|
|
96
|
+
say(' It ships as a dependency of this plugin, so it is already downloaded,')
|
|
97
|
+
say(' but dsh only mounts what the profile lists:')
|
|
98
|
+
say()
|
|
99
|
+
say(' dsh plugin --profile web add dsh-better-sidebar')
|
|
100
|
+
say()
|
|
101
|
+
say(' Skip it if you do not want the tab - the chip, the tools, the')
|
|
102
|
+
say(' /graft command and the auto-rebuild all work without it.')
|
|
103
|
+
say()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
main().catch(() => {
|
|
107
|
+
// A postinstall that throws fails the install of a plugin that is, itself,
|
|
108
|
+
// perfectly fine. Never do that.
|
|
109
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { savedTokensIn } from '../src/graft-tools.js'
|
|
2
|
+
import { createAutoSync } from '../src/auto-sync.js'
|
|
3
|
+
import { totalSaved } from '../src/index.js'
|
|
4
|
+
|
|
5
|
+
let failures = 0
|
|
6
|
+
const check = (l, ok, extra = '') => { console.log((ok ? 'PASS ' : 'FAIL ') + l + (extra ? ' -> ' + extra : '')); if (!ok) failures += 1 }
|
|
7
|
+
|
|
8
|
+
console.log('--- reading graft\'s own savings line ---')
|
|
9
|
+
check('plain grouping', savedTokensIn('[graft] tokens saved ≈ 26,730 (99%) — this output ≈ 308 tok') === 26730)
|
|
10
|
+
// This machine renders lakh grouping; a locale-naive parse would read 150.
|
|
11
|
+
check('Indian grouping (as this host prints it)', savedTokensIn('[graft] tokens saved ≈ 1,50,427 (100%) — x') === 150427)
|
|
12
|
+
check('no separators', savedTokensIn('tokens saved ≈ 42 (10%) — x') === 42)
|
|
13
|
+
check('absent line is zero, not NaN', savedTokensIn('no such line here') === 0)
|
|
14
|
+
check('undefined is zero', savedTokensIn(undefined) === 0)
|
|
15
|
+
|
|
16
|
+
console.log('\n--- the two tallies are summed, not merged ---')
|
|
17
|
+
check('graft + ours', totalSaved(663783, 26730) === 690513)
|
|
18
|
+
check('missing ours', totalSaved(663783, undefined) === 663783)
|
|
19
|
+
check('neither', totalSaved(undefined, undefined) === 0)
|
|
20
|
+
|
|
21
|
+
console.log('\n--- auto-rebuild gating ---')
|
|
22
|
+
let now = 1_000_000
|
|
23
|
+
let spawned = []
|
|
24
|
+
const state = {
|
|
25
|
+
stats: { dirty: false },
|
|
26
|
+
readStats: () => state.stats,
|
|
27
|
+
patchStats: (_d, patch) => Object.assign(state.stats, patch),
|
|
28
|
+
acquireLock: () => state.lock !== false,
|
|
29
|
+
}
|
|
30
|
+
const make = (over = {}) => createAutoSync({
|
|
31
|
+
graft: { state, syncRun: 'C:/fake/sync-run.js' },
|
|
32
|
+
now: () => now,
|
|
33
|
+
spawn: (...a) => { spawned.push(a); return { on() {}, unref() {} } },
|
|
34
|
+
...over,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
let sync = make()
|
|
38
|
+
// dsh's REAL wire names, from the packages that register them: dsh-tool-fs
|
|
39
|
+
// (write, edit), dsh-tool-str-replace-editor, dsh-tool-bash, dsh-tool-pwsh.
|
|
40
|
+
// This check previously asserted `write_file`, which dsh does not register — so
|
|
41
|
+
// it passed while the most common write path in a new repo triggered nothing.
|
|
42
|
+
check('write is watched', sync.touches('write'))
|
|
43
|
+
check('edit is watched', sync.touches('edit'))
|
|
44
|
+
check('so is the editor tool', sync.touches('str_replace_editor'))
|
|
45
|
+
check('shells too (scripts write files here)', sync.touches('pwsh') && sync.touches('bash'))
|
|
46
|
+
check('reads are not', sync.touches('read') === false)
|
|
47
|
+
// The regression, named so it cannot come back quietly.
|
|
48
|
+
check('the name dsh does NOT register is not relied on', sync.touches('write_file') === false)
|
|
49
|
+
check('a custom list replaces the defaults', make({ writeTools: ['only_this'] }).touches('only_this') && make({ writeTools: ['only_this'] }).touches('write') === false)
|
|
50
|
+
|
|
51
|
+
check('a clean repo is not rebuilt', (await sync.syncIfDirty('C:/x')) === 'clean')
|
|
52
|
+
await sync.markDirty('C:/x')
|
|
53
|
+
check('marking dirty writes graft\'s own flag', state.stats.dirty === true)
|
|
54
|
+
check('a dirty repo rebuilds', (await sync.syncIfDirty('C:/x')) === 'started')
|
|
55
|
+
check('and it spawned graft\'s sync-run', spawned.length === 1 && spawned[0][1][0] === 'C:/fake/sync-run.js')
|
|
56
|
+
// Windows: sync-run's own execFileSync passes no windowsHide, so it inherits
|
|
57
|
+
// whatever console this child has. `detached` would leave it with NONE, and the
|
|
58
|
+
// grandchild would then be given a new VISIBLE console — a terminal that
|
|
59
|
+
// flashes on every rebuild and takes foreground off a fullscreen game.
|
|
60
|
+
check('hidden, so the rebuild cannot flash a console', spawned[0][2].windowsHide === true)
|
|
61
|
+
check('and NOT detached, or the grandchild gets a visible one', spawned[0][2].detached !== true)
|
|
62
|
+
check('it set syncing so the chip can show it', state.stats.syncing === true)
|
|
63
|
+
|
|
64
|
+
check('a second rebuild inside the interval is refused', (await sync.syncIfDirty('C:/x')) === 'too-soon')
|
|
65
|
+
now += 6_000
|
|
66
|
+
state.stats.dirty = true
|
|
67
|
+
check('and allowed once the interval passes', (await sync.syncIfDirty('C:/x')) === 'started')
|
|
68
|
+
|
|
69
|
+
console.log('\n--- it never races another rebuild ---')
|
|
70
|
+
state.lock = false
|
|
71
|
+
state.stats.dirty = true
|
|
72
|
+
now += 6_000
|
|
73
|
+
check('a held lock defers instead of racing', (await sync.syncIfDirty('C:/x')) === 'locked')
|
|
74
|
+
|
|
75
|
+
console.log('\n--- graft missing is not an error ---')
|
|
76
|
+
const none = createAutoSync({ graft: null, now: () => now, spawn: () => { throw new Error('should not spawn') } })
|
|
77
|
+
check('no graft, no rebuild, no throw', (await none.syncIfDirty('C:/x')) === 'graft-unavailable')
|
|
78
|
+
|
|
79
|
+
console.log('\n--- mid-turn rebuild: a long turn must not sit on a stale graph ---')
|
|
80
|
+
// The turn boundary alone left a graph dirty for 36 minutes of one agentic
|
|
81
|
+
// turn while the model kept querying it. A write now also arms a debounce.
|
|
82
|
+
let fired = []
|
|
83
|
+
let timerId = 0
|
|
84
|
+
const pendingTimers = new Map()
|
|
85
|
+
const debounced = createAutoSync({
|
|
86
|
+
graft: { state, syncRun: 'C:/fake/sync-run.js' },
|
|
87
|
+
now: () => now,
|
|
88
|
+
spawn: (...a) => { fired.push(a); return { on() {}, unref() {} } },
|
|
89
|
+
debounceMs: 20000,
|
|
90
|
+
setTimeout: (fn) => { const id = ++timerId; pendingTimers.set(id, fn); return id },
|
|
91
|
+
clearTimeout: (id) => { pendingTimers.delete(id) },
|
|
92
|
+
})
|
|
93
|
+
state.stats = { dirty: false }
|
|
94
|
+
state.lock = true
|
|
95
|
+
now += 60_000
|
|
96
|
+
|
|
97
|
+
await debounced.markDirty('C:/x')
|
|
98
|
+
check('a write arms a timer', pendingTimers.size === 1, String(pendingTimers.size))
|
|
99
|
+
check('and does not rebuild immediately', fired.length === 0)
|
|
100
|
+
|
|
101
|
+
// A second write inside the window replaces the first timer, so a burst of
|
|
102
|
+
// edits collapses into ONE rebuild rather than one per file.
|
|
103
|
+
await debounced.markDirty('C:/x')
|
|
104
|
+
check('a second write replaces the timer, not adds one', pendingTimers.size === 1, String(pendingTimers.size))
|
|
105
|
+
|
|
106
|
+
const fire = [...pendingTimers.values()][0]
|
|
107
|
+
pendingTimers.clear()
|
|
108
|
+
await fire()
|
|
109
|
+
check('the quiet period rebuilds without waiting for the turn', fired.length === 1, String(fired.length))
|
|
110
|
+
|
|
111
|
+
const off = createAutoSync({ graft: { state, syncRun: 'x' }, now: () => now, spawn: () => ({ on() {}, unref() {} }), debounceMs: 0, setTimeout: () => { throw new Error('should not arm') } })
|
|
112
|
+
let armed = true
|
|
113
|
+
try { await off.markDirty('C:/y') } catch { armed = false }
|
|
114
|
+
check('debounceMs 0 restores turn-end-only', armed === true)
|
|
115
|
+
|
|
116
|
+
console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`)
|
|
117
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/graft`, end to end against a real index.
|
|
3
|
+
*
|
|
4
|
+
* This suite BUILDS a graft index with the real CLI, because the thing it is
|
|
5
|
+
* checking is the difference between initialising and rebuilding one, and a
|
|
6
|
+
* stub cannot be wrong about that. That makes graft a hard requirement, so it
|
|
7
|
+
* skips rather than fails when graft is absent: a missing CLI is a setup state,
|
|
8
|
+
* not a defect in this plugin, and a red suite would say the wrong thing.
|
|
9
|
+
*
|
|
10
|
+
* Run: npm test
|
|
11
|
+
*/
|
|
12
|
+
import { createGraftCommand, parseArgs, workspaceOf } from '../src/command.js'
|
|
13
|
+
import { findGraftRoot } from '../src/index.js'
|
|
14
|
+
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { delimiter, join } from 'node:path'
|
|
17
|
+
import { existsSync } from 'node:fs'
|
|
18
|
+
|
|
19
|
+
/** Is the graft CLI reachable? Mirrors what graft-tools.js resolves against. */
|
|
20
|
+
const graftInstalled = String(process.env.PATH ?? process.env.Path ?? '')
|
|
21
|
+
.split(delimiter)
|
|
22
|
+
.some((dir) => dir !== '' && ['graft.cmd', 'graft.exe', 'graft'].some((n) => existsSync(join(dir, n))))
|
|
23
|
+
|
|
24
|
+
if (!graftInstalled) {
|
|
25
|
+
console.log('SKIP /graft end-to-end: the graft CLI is not on PATH.')
|
|
26
|
+
console.log(' Install it with: npm install -g @nanonets/graft')
|
|
27
|
+
console.log('\nSKIPPED (not a failure)')
|
|
28
|
+
process.exit(0)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let failures = 0
|
|
32
|
+
const check = (l, ok, extra = '') => { console.log((ok ? 'PASS ' : 'FAIL ') + l + (extra ? ' -> ' + extra : '')); if (!ok) failures += 1 }
|
|
33
|
+
const exists = async (p) => { try { await access(p); return true } catch { return false } }
|
|
34
|
+
const readJson = async (p) => { try { return JSON.parse(await readFile(p, 'utf8')) } catch { return undefined } }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A repo built from scratch on every run.
|
|
38
|
+
*
|
|
39
|
+
* The suite asserts the difference between INITIALISING and REBUILDING, so it
|
|
40
|
+
* cannot reuse an index a previous run left behind — that is exactly the state
|
|
41
|
+
* the first two checks are supposed to prove is absent.
|
|
42
|
+
*/
|
|
43
|
+
const FRESH = process.argv[2] ?? join(tmpdir(), 'graft-status-fixture')
|
|
44
|
+
await rm(FRESH, { recursive: true, force: true })
|
|
45
|
+
await mkdir(join(FRESH, 'src'), { recursive: true })
|
|
46
|
+
await writeFile(
|
|
47
|
+
join(FRESH, 'src', 'shapes.py'),
|
|
48
|
+
[
|
|
49
|
+
'def area(w, h):',
|
|
50
|
+
' return w * h',
|
|
51
|
+
'',
|
|
52
|
+
'class Rect:',
|
|
53
|
+
' def __init__(self, w, h):',
|
|
54
|
+
' self.w, self.h = w, h',
|
|
55
|
+
' def area(self):',
|
|
56
|
+
' return area(self.w, self.h)',
|
|
57
|
+
'',
|
|
58
|
+
].join('\n'),
|
|
59
|
+
)
|
|
60
|
+
await writeFile(
|
|
61
|
+
join(FRESH, 'src', 'main.py'),
|
|
62
|
+
['from shapes import Rect', '', 'def run():', ' return Rect(3, 4).area()', ''].join('\n'),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const { countWiring } = await import('../src/index.js')
|
|
66
|
+
const cmd = createGraftCommand({ fallbackCwd: 'C:/nowhere', findRoot: (s) => findGraftRoot(s, exists), readJson, countWiring })
|
|
67
|
+
const invoke = (cwd, rawInput = '') => cmd.handler({ agent: { session: { header: { cwd } } }, rawInput, signal: undefined })
|
|
68
|
+
|
|
69
|
+
console.log('--- argument parsing ---')
|
|
70
|
+
check('bare builds', parseArgs('').mode === 'build')
|
|
71
|
+
check('status', parseArgs(' status ').mode === 'status')
|
|
72
|
+
check('deep', parseArgs('deep').mode === 'deep')
|
|
73
|
+
check('a typo is refused, not silently built', parseArgs('rebiuld').mode === 'unknown')
|
|
74
|
+
|
|
75
|
+
console.log('\n--- workspace resolution ---')
|
|
76
|
+
check('takes the session cwd', workspaceOf({ agent: { session: { header: { cwd: 'B:/x' } } } }, 'C:/f') === 'B:/x')
|
|
77
|
+
check('falls back', workspaceOf({}, 'C:/f') === 'C:/f')
|
|
78
|
+
|
|
79
|
+
console.log('\n--- status BEFORE any index exists ---')
|
|
80
|
+
const before = await invoke(FRESH, 'status')
|
|
81
|
+
check('says there is none, and how to fix it', /No graft index/.test(before.text) && /\/graft/.test(before.text), before.text)
|
|
82
|
+
|
|
83
|
+
console.log('\n--- /graft initialises a fresh repo ---')
|
|
84
|
+
const built = await invoke(FRESH)
|
|
85
|
+
check('reports success', built.kind === 'success', built.kind)
|
|
86
|
+
check('says it INITIALISED (not rebuilt)', /initialised/.test(built.text), built.text.split('\n')[0])
|
|
87
|
+
check('reports real counts', /\d+ nodes \/ \d+ edges/.test(built.text), built.text.split('\n')[1])
|
|
88
|
+
check('names the language it found', /python/i.test(built.text), built.text.split('\n')[1])
|
|
89
|
+
check('the index is really on disk', (await findGraftRoot(FRESH, exists)) !== undefined)
|
|
90
|
+
|
|
91
|
+
console.log('\n--- a second /graft REBUILDS rather than re-initialising ---')
|
|
92
|
+
const again = await invoke(FRESH)
|
|
93
|
+
check('wording changes to rebuilt', /rebuilt/.test(again.text), again.text.split('\n')[0])
|
|
94
|
+
|
|
95
|
+
console.log('\n--- status AFTER ---')
|
|
96
|
+
const after = await invoke(FRESH, 'status')
|
|
97
|
+
check('reports the graph and its freshness', /nodes \/ /.test(after.text) && /in sync|stale/.test(after.text), after.text.replace(/\n/g, ' | '))
|
|
98
|
+
|
|
99
|
+
console.log('\n--- an unknown option ---')
|
|
100
|
+
const bad = await invoke(FRESH, 'rebiuld')
|
|
101
|
+
check('is an error naming the valid forms', bad.kind === 'error' && /\/graft status/.test(bad.text), bad.text)
|
|
102
|
+
|
|
103
|
+
console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`)
|
|
104
|
+
process.exit(failures === 0 ? 0 : 1)
|