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 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
+ ![language](https://img.shields.io/badge/language-javascript-f7dc6f) ![style](https://img.shields.io/badge/style-vanilla-gray) ![license](https://img.shields.io/badge/license-MIT-blue) ![node](https://img.shields.io/badge/node-%E2%89%A5%2022-brightgreen) ![dsh](https://img.shields.io/badge/dsh-web%20profile-orange) ![runtime](https://img.shields.io/badge/runtime-offline_%C2%B7_100%25_local-brightgreen) ![tests](https://img.shields.io/badge/tests-91_passing-brightgreen)
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
+ ![The chip in the composer, with its hover card](assets/chip.png)
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
+ ![The graph in the sidebar — context view](assets/viz-context.png)
69
+
70
+ The code view drills into a single file — what it contains, what it depends on, what depends on it:
71
+
72
+ ![The graph in the sidebar — code view](assets/viz-code.png)
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
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ [
2
+ "assets/chip.png",
3
+ "assets/viz-context.png",
4
+ "assets/viz-code.png"
5
+ ]
@@ -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 = ''
32
+ const DIM = ''
33
+ const OFF = ''
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)