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/src/command.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/graft` — index this session's workspace, or report on it.
|
|
3
|
+
*
|
|
4
|
+
* WHY A COMMAND AND NOT JUST THE TOOLS
|
|
5
|
+
*
|
|
6
|
+
* Every graft tool needs a `graft/` index to exist, and until one does they all
|
|
7
|
+
* answer the same way: "No graft index for this workspace — run `graft build`
|
|
8
|
+
* in it once." That is a correct answer and a bad experience: it sends you to a
|
|
9
|
+
* terminal, in the right directory, to run a command whose name you have to
|
|
10
|
+
* remember, for a repo dsh already knows the path of.
|
|
11
|
+
*
|
|
12
|
+
* `/graft` closes that loop. It resolves the session's own workspace, builds
|
|
13
|
+
* there, and reports what it produced.
|
|
14
|
+
*
|
|
15
|
+
* WHAT `/graft` COSTS
|
|
16
|
+
*
|
|
17
|
+
* Nothing. The bare form runs the plain structural build — `$0`, offline, no
|
|
18
|
+
* API key — which is also what the auto-rebuild runs. `/graft deep` is the one
|
|
19
|
+
* form that calls an LLM, and it is never reached except by typing that word,
|
|
20
|
+
* which is the whole reason it is a separate subcommand rather than a flag on
|
|
21
|
+
* the default path.
|
|
22
|
+
*
|
|
23
|
+
* @module graft-status/command
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { join } from 'node:path'
|
|
27
|
+
|
|
28
|
+
import { runGraft } from './graft-tools.js'
|
|
29
|
+
|
|
30
|
+
/** How long a build may take before the command gives up, in ms. */
|
|
31
|
+
const BUILD_TIMEOUT_MS = 180_000
|
|
32
|
+
|
|
33
|
+
/** The workspace this invocation belongs to. */
|
|
34
|
+
export function workspaceOf(invocation, fallback) {
|
|
35
|
+
const cwd = invocation?.agent?.session?.header?.cwd
|
|
36
|
+
return typeof cwd === 'string' && cwd.length > 0 ? cwd : fallback
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What the user asked for, from the text after the command name.
|
|
41
|
+
*
|
|
42
|
+
* Unknown words are an error rather than a silent build: someone typing
|
|
43
|
+
* `/graft rebiuld` meant something, and quietly doing the default would hide
|
|
44
|
+
* the typo behind a plausible-looking success.
|
|
45
|
+
*/
|
|
46
|
+
export function parseArgs(rawInput) {
|
|
47
|
+
const word = String(rawInput ?? '').trim().toLowerCase()
|
|
48
|
+
if (word === '') return { mode: 'build' }
|
|
49
|
+
if (word === 'status' || word === 'check') return { mode: 'status' }
|
|
50
|
+
if (word === 'deep' || word === '--deep') return { mode: 'deep' }
|
|
51
|
+
return { mode: 'unknown', word }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The stats for a root, however they can be had.
|
|
56
|
+
*
|
|
57
|
+
* A plain `graft build` writes the graph but NOT `graft/.cache/stats.json` —
|
|
58
|
+
* that file is written by graft's `sync-run`, which builds and then patches it.
|
|
59
|
+
* So straight after an initialise there are no stats to read, and reporting
|
|
60
|
+
* "no stats written" for a build that just succeeded reads as a failure. The
|
|
61
|
+
* wiring graph is the same numbers one layer down; this is the precedence the
|
|
62
|
+
* status chip already uses, kept identical so the two never disagree.
|
|
63
|
+
*/
|
|
64
|
+
async function statsFor(root, deps) {
|
|
65
|
+
const stats = await deps.readJson(join(root, 'graft', '.cache', 'stats.json'))
|
|
66
|
+
if (stats !== undefined && typeof stats.nodeCount === 'number') return stats
|
|
67
|
+
const wiring = await deps.readJson(join(root, 'graft', '.graph', 'wiring.json'))
|
|
68
|
+
const counted = deps.countWiring?.(wiring)
|
|
69
|
+
if (counted === undefined) return undefined
|
|
70
|
+
// The graph keeps its summary under `meta`, not at the top level — the same
|
|
71
|
+
// place its own nodeCount/edgeCount live.
|
|
72
|
+
return { ...counted, languages: wiring?.meta?.languages ?? wiring?.languages }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A one-line summary of a built graph. */
|
|
76
|
+
function describe(stats) {
|
|
77
|
+
if (stats === undefined) return 'no graph written'
|
|
78
|
+
const nodes = typeof stats.nodeCount === 'number' ? stats.nodeCount.toLocaleString('en-US') : '?'
|
|
79
|
+
const edges = typeof stats.edgeCount === 'number' ? stats.edgeCount.toLocaleString('en-US') : '?'
|
|
80
|
+
const languages = Array.isArray(stats.languages) && stats.languages.length > 0
|
|
81
|
+
? ` · ${stats.languages.join(', ')}`
|
|
82
|
+
: ''
|
|
83
|
+
return `${nodes} nodes / ${edges} edges${languages}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createGraftCommand(deps) {
|
|
87
|
+
return {
|
|
88
|
+
name: 'graft',
|
|
89
|
+
description: 'Index this workspace for graft, or report on its graph',
|
|
90
|
+
input: { hint: '[status|deep]' },
|
|
91
|
+
handler: async (invocation) => {
|
|
92
|
+
const cwd = workspaceOf(invocation, deps.fallbackCwd)
|
|
93
|
+
const args = parseArgs(invocation?.rawInput)
|
|
94
|
+
|
|
95
|
+
if (args.mode === 'unknown') {
|
|
96
|
+
return {
|
|
97
|
+
kind: 'error',
|
|
98
|
+
text: `Unknown option "${args.word}". Use /graft, /graft status, or /graft deep.`,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const existing = await deps.findRoot(cwd)
|
|
103
|
+
|
|
104
|
+
if (args.mode === 'status') {
|
|
105
|
+
if (existing === undefined) {
|
|
106
|
+
return { kind: 'success', text: `No graft index for ${cwd}. Run /graft to build one.` }
|
|
107
|
+
}
|
|
108
|
+
const stats = await statsFor(existing, deps)
|
|
109
|
+
const drift = stats?.dirty === true ? ' · stale, run /graft' : ' · in sync'
|
|
110
|
+
return { kind: 'success', text: `graft: ${existing}\n${describe(stats)}${drift}` }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// An index found ABOVE the workspace is rebuilt where it lives; without
|
|
114
|
+
// one, the workspace itself becomes the new root. That is what makes the
|
|
115
|
+
// bare command mean "initialize here" in a fresh repo and "refresh" in a
|
|
116
|
+
// repo already indexed, with no flag to choose between them.
|
|
117
|
+
const target = existing ?? cwd
|
|
118
|
+
const initialising = existing === undefined
|
|
119
|
+
|
|
120
|
+
const result = await runGraft(
|
|
121
|
+
args.mode === 'deep' ? ['build', '--deep', target] : ['build', target],
|
|
122
|
+
target,
|
|
123
|
+
{ ...deps, timeoutMs: BUILD_TIMEOUT_MS, signal: invocation?.signal },
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const stats = await statsFor(target, deps)
|
|
127
|
+
const built = await deps.findRoot(target)
|
|
128
|
+
if (built === undefined) {
|
|
129
|
+
// The build produced no index. graft's own message is the useful part —
|
|
130
|
+
// an unsupported language, an empty tree, a missing key on --deep.
|
|
131
|
+
return {
|
|
132
|
+
kind: 'error',
|
|
133
|
+
text: `graft build did not produce an index in ${target}.\n\n${result.text.slice(0, 1200)}`,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const headline = initialising ? `graft initialised in ${target}` : `graft rebuilt for ${target}`
|
|
138
|
+
const deepNote = args.mode === 'deep'
|
|
139
|
+
? '\nDeep pass done — the meaning layer and manifest.json now exist.'
|
|
140
|
+
: ''
|
|
141
|
+
return {
|
|
142
|
+
kind: 'success',
|
|
143
|
+
text: `${headline}\n${describe(stats)}${deepNote}\n\nThe graft tools now work in this session, and the graph rebuilds itself at the end of any turn that changes files.`,
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-aware graft tools.
|
|
3
|
+
*
|
|
4
|
+
* WHY THESE EXIST INSTEAD OF THE MCP SERVER
|
|
5
|
+
*
|
|
6
|
+
* `graft mcp` is a stdio child spawned once per dsh boot, and it resolves the
|
|
7
|
+
* repo it serves — "nearest ancestor with a graft/ index" — from ITS working
|
|
8
|
+
* directory at startup. Nothing can re-point it afterwards: its tools take no
|
|
9
|
+
* directory argument, and the `in` parameter scopes to a sub-project WITHIN the
|
|
10
|
+
* root it already chose.
|
|
11
|
+
*
|
|
12
|
+
* That is incompatible with how this dsh is used: launched once from wherever,
|
|
13
|
+
* with the working directory chosen per session inside the UI. Measured on one
|
|
14
|
+
* repo, changing only the launch directory:
|
|
15
|
+
*
|
|
16
|
+
* launched in the workspace graft_repo_map ok, find_code ok, find_all ok
|
|
17
|
+
* launched from a home dir "no graph found — run `graft build` first"
|
|
18
|
+
*
|
|
19
|
+
* A tool registered here has what the MCP server cannot get: the call's own
|
|
20
|
+
* agent, and therefore `agent.session.header.cwd` — the workspace that session
|
|
21
|
+
* actually chose. So the repo is resolved per call, and one dsh can serve
|
|
22
|
+
* several workspaces at once.
|
|
23
|
+
*
|
|
24
|
+
* WHAT THEY ARE
|
|
25
|
+
*
|
|
26
|
+
* Thin wrappers over the `graft` CLI, which is the same graph the MCP server
|
|
27
|
+
* reads and needs no LLM key. Each maps to one subcommand and passes the
|
|
28
|
+
* resolved root as its `[dir]` argument.
|
|
29
|
+
*
|
|
30
|
+
* NO BOOLEAN PARAMETERS, DELIBERATELY
|
|
31
|
+
*
|
|
32
|
+
* On this deployment a tool call that SETS a boolean is emitted by the server
|
|
33
|
+
* as literal `<tool_call>` text instead of a structured call, and never runs
|
|
34
|
+
* (see the bool-param-compat plugin). These take string enums instead, which
|
|
35
|
+
* read better to a model anyway — `detail: "full"` says more than `full: true`
|
|
36
|
+
* — and cannot trip that bug even if that plugin is removed.
|
|
37
|
+
*
|
|
38
|
+
* @module graft-status/graft-tools
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
42
|
+
import { spawn } from 'node:child_process'
|
|
43
|
+
import { existsSync } from 'node:fs'
|
|
44
|
+
import { delimiter, join } from 'node:path'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Ceiling for one graft call.
|
|
48
|
+
*
|
|
49
|
+
* Every subcommand used here is the `$0, no LLM` kind — it reads a prebuilt
|
|
50
|
+
* graph — so a slow one means something is wrong rather than something is
|
|
51
|
+
* thinking. Declared on the tool so dsh's own timeout policy enforces it;
|
|
52
|
+
* without a declared `timeoutMs` that policy takes an early return and the
|
|
53
|
+
* call is unbounded.
|
|
54
|
+
*/
|
|
55
|
+
const TIMEOUT_MS = 60_000
|
|
56
|
+
|
|
57
|
+
/** Cap on returned text, so one broad query cannot swamp the context. */
|
|
58
|
+
const MAX_CHARS = 60_000
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How to invoke graft without a shell.
|
|
62
|
+
*
|
|
63
|
+
* `graft` on Windows is a `.cmd` shim, so a naive spawn cannot exec it and the
|
|
64
|
+
* obvious fix — `shell: true` — is a trap: Node joins argv into one command
|
|
65
|
+
* line WITHOUT quoting, so the first argument containing a space is silently
|
|
66
|
+
* split. That broke every call here at once, because both the queries
|
|
67
|
+
* (`"punch animation"`) and the repo path (`B:\...\Street Yeet`) contain
|
|
68
|
+
* spaces.
|
|
69
|
+
*
|
|
70
|
+
* The shim's own body is `node <dir>/node_modules/@nanonets/graft/dist/cli.js`,
|
|
71
|
+
* so finding that file on PATH and running it with the current Node needs no
|
|
72
|
+
* shell at all, and argv is passed through exactly as written.
|
|
73
|
+
*
|
|
74
|
+
* The bare-name fallback keeps a differently-installed graft working; it is
|
|
75
|
+
* only reached when the entry point cannot be found.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveGraftCommand(env = process.env, exists = existsSync, execPath = process.execPath) {
|
|
78
|
+
const path = String(env.PATH ?? env.Path ?? '')
|
|
79
|
+
for (const dir of path.split(delimiter)) {
|
|
80
|
+
if (dir === '') continue
|
|
81
|
+
const cli = join(dir, 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js')
|
|
82
|
+
if (exists(cli)) return { command: execPath, prefix: [cli], shell: false }
|
|
83
|
+
}
|
|
84
|
+
return { command: 'graft', prefix: [], shell: process.platform === 'win32' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let cachedCommand
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run one graft subcommand and return its stdout.
|
|
91
|
+
*
|
|
92
|
+
* stderr is folded into the result rather than dropped: graft reports "no
|
|
93
|
+
* graph", drift warnings and bad-argument errors there, and a tool that
|
|
94
|
+
* swallowed them would answer "(no output)" to a question that has a specific,
|
|
95
|
+
* actionable reason behind it.
|
|
96
|
+
*/
|
|
97
|
+
export function runGraft(args, cwd, deps = {}) {
|
|
98
|
+
const spawnFn = deps.spawn ?? spawn
|
|
99
|
+
const how = deps.command ?? (cachedCommand ??= resolveGraftCommand())
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
let child
|
|
102
|
+
try {
|
|
103
|
+
child = spawnFn(how.command, [...how.prefix, ...args], {
|
|
104
|
+
cwd,
|
|
105
|
+
shell: how.shell,
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
108
|
+
})
|
|
109
|
+
} catch (error) {
|
|
110
|
+
resolve({ text: `graft could not be run: ${String(error?.message ?? error)}` })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let out = ''
|
|
115
|
+
let err = ''
|
|
116
|
+
let settled = false
|
|
117
|
+
let timer
|
|
118
|
+
let abort
|
|
119
|
+
const finish = (text) => {
|
|
120
|
+
if (settled) return
|
|
121
|
+
settled = true
|
|
122
|
+
clearTimeout(timer)
|
|
123
|
+
if (abort !== undefined) deps.signal?.removeEventListener?.('abort', abort)
|
|
124
|
+
resolve({ text: text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}\n\n[truncated]` : text })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A build is the one call here that can genuinely run long, so both an
|
|
128
|
+
// upper bound and the caller's own cancellation have to reach the child —
|
|
129
|
+
// otherwise a wedged graft holds a slash command open with no way out.
|
|
130
|
+
const limit = Number.isInteger(deps.timeoutMs) && deps.timeoutMs > 0 ? deps.timeoutMs : TIMEOUT_MS
|
|
131
|
+
timer = setTimeout(() => {
|
|
132
|
+
child.kill()
|
|
133
|
+
finish(`graft timed out after ${Math.round(limit / 1000)}s.\n${out}${err}`)
|
|
134
|
+
}, limit)
|
|
135
|
+
if (deps.signal !== undefined) {
|
|
136
|
+
abort = () => {
|
|
137
|
+
child.kill()
|
|
138
|
+
finish('Cancelled.')
|
|
139
|
+
}
|
|
140
|
+
if (deps.signal.aborted) abort()
|
|
141
|
+
else deps.signal.addEventListener?.('abort', abort, { once: true })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
child.stdout?.on('data', (chunk) => { out += chunk.toString() })
|
|
145
|
+
child.stderr?.on('data', (chunk) => { err += chunk.toString() })
|
|
146
|
+
child.on('error', (error) => finish(`graft could not be run: ${String(error?.message ?? error)}`))
|
|
147
|
+
child.on('close', () => {
|
|
148
|
+
const combined = [out.trim(), err.trim()].filter(Boolean).join('\n')
|
|
149
|
+
finish(combined === '' ? '(graft produced no output)' : combined)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The repo root for the call's own session.
|
|
156
|
+
*
|
|
157
|
+
* `exec.agent.session.header.cwd` is the workspace chosen inside dsh for THIS
|
|
158
|
+
* session — the value its entry in the session list shows — which is the whole
|
|
159
|
+
* reason these tools exist. The process cwd is only the fallback, for a call
|
|
160
|
+
* with no agent (a direct dispatch) or a session created without one.
|
|
161
|
+
*/
|
|
162
|
+
export function repoFor(exec, fallback) {
|
|
163
|
+
const cwd = exec?.agent?.session?.header?.cwd
|
|
164
|
+
return typeof cwd === 'string' && cwd.length > 0 ? cwd : fallback
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `graft <sub> … <root>`, with the shared plumbing applied. */
|
|
168
|
+
function graftTool(spec, deps) {
|
|
169
|
+
return defineTool({
|
|
170
|
+
name: spec.name,
|
|
171
|
+
description: spec.description,
|
|
172
|
+
parameters: spec.parameters,
|
|
173
|
+
timeoutMs: TIMEOUT_MS,
|
|
174
|
+
output: {
|
|
175
|
+
schema: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: { text: { type: 'string' } },
|
|
178
|
+
additionalProperties: false,
|
|
179
|
+
},
|
|
180
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
181
|
+
},
|
|
182
|
+
execute: async (args, exec) => {
|
|
183
|
+
const root = repoFor(exec, deps.fallbackCwd)
|
|
184
|
+
const found = await deps.findRoot(root)
|
|
185
|
+
if (found === undefined) {
|
|
186
|
+
return {
|
|
187
|
+
text:
|
|
188
|
+
`No graft index for this workspace (${root}).\n` +
|
|
189
|
+
'Run `graft build` in it once — it needs no API key — and this tool will work from then on.',
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const result = await runGraft([...spec.argv(args), found], found, deps)
|
|
193
|
+
const saved = savedTokensIn(result.text)
|
|
194
|
+
if (saved > 0) deps.recordSavings?.(found, saved)
|
|
195
|
+
return result
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Only pass a flag when the model actually chose the non-default. */
|
|
201
|
+
const when = (condition, ...flag) => (condition ? flag : [])
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The token count graft claims it saved on one call.
|
|
205
|
+
*
|
|
206
|
+
* Every retrieval command opens its output with a line like
|
|
207
|
+
* `[graft] tokens saved ≈ 26,730 (99%) — this output ≈ 308 tok vs …`, which is
|
|
208
|
+
* graft's own estimate and the same number its Claude Code statusline totals.
|
|
209
|
+
* Reading it back is what lets the dsh chip show a running total too: graft
|
|
210
|
+
* keeps that tally in per-session files written by ITS hooks, and dsh has none,
|
|
211
|
+
* so without parsing it here the figure would sit at zero forever no matter how
|
|
212
|
+
* much the tools were used.
|
|
213
|
+
*
|
|
214
|
+
* Digit grouping is stripped rather than parsed by locale — graft renders these
|
|
215
|
+
* with the host's separators (`1,50,427` on this machine's Indian grouping), so
|
|
216
|
+
* anything that is not a digit between the number's ends is punctuation.
|
|
217
|
+
*/
|
|
218
|
+
export function savedTokensIn(text) {
|
|
219
|
+
const match = /tokens saved\s*≈\s*([\d., ]+?)\s*\(/.exec(String(text ?? ''))
|
|
220
|
+
if (match === null) return 0
|
|
221
|
+
const digits = match[1].replace(/\D/g, '')
|
|
222
|
+
if (digits === '') return 0
|
|
223
|
+
const value = Number.parseInt(digits, 10)
|
|
224
|
+
return Number.isSafeInteger(value) && value > 0 ? value : 0
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function graftTools(deps) {
|
|
228
|
+
return [
|
|
229
|
+
graftTool(
|
|
230
|
+
{
|
|
231
|
+
name: 'graft_ask',
|
|
232
|
+
description:
|
|
233
|
+
'Search this repo\'s prebuilt code graph in plain words and get ranked results with the ' +
|
|
234
|
+
'code inlined at exact file:line. The first thing to reach for on "how does X work" or ' +
|
|
235
|
+
'"where is Y" — one call usually replaces several file reads. Costs no API key.',
|
|
236
|
+
parameters: {
|
|
237
|
+
query: { type: 'string', required: true, description: 'What you are looking for, in plain words.' },
|
|
238
|
+
detail: {
|
|
239
|
+
type: 'string',
|
|
240
|
+
enum: ['crux', 'full'],
|
|
241
|
+
description: 'How much of each hit to inline: "crux" (default, <=8 lines) or "full" (the whole span).',
|
|
242
|
+
},
|
|
243
|
+
in: { type: 'string', description: 'Limit to one sub-project path, for a monorepo (e.g. "packages/core/").' },
|
|
244
|
+
},
|
|
245
|
+
argv: (args) => [
|
|
246
|
+
'ask', String(args.query), '--source',
|
|
247
|
+
...when(args.detail === 'full', '--full'),
|
|
248
|
+
...when(typeof args.in === 'string' && args.in !== '', '--in', String(args.in)),
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
deps,
|
|
252
|
+
),
|
|
253
|
+
|
|
254
|
+
graftTool(
|
|
255
|
+
{
|
|
256
|
+
name: 'graft_grep',
|
|
257
|
+
description:
|
|
258
|
+
'Exhaustive search over the indexed files, with hits grouped by their enclosing symbol ' +
|
|
259
|
+
'and ranked by how much depends on them. Use when you need EVERY occurrence — graft_ask ' +
|
|
260
|
+
'is ranked top-N and will miss some.',
|
|
261
|
+
parameters: {
|
|
262
|
+
pattern: { type: 'string', required: true, description: 'Regular expression, or a literal when match is "literal".' },
|
|
263
|
+
match: {
|
|
264
|
+
type: 'string',
|
|
265
|
+
enum: ['regex', 'literal'],
|
|
266
|
+
description: 'Treat pattern as a regex (default) or as literal text.',
|
|
267
|
+
},
|
|
268
|
+
case: {
|
|
269
|
+
type: 'string',
|
|
270
|
+
enum: ['sensitive', 'insensitive'],
|
|
271
|
+
description: 'Case sensitivity; defaults to sensitive.',
|
|
272
|
+
},
|
|
273
|
+
in: { type: 'string', description: 'Limit to one sub-project path, for a monorepo.' },
|
|
274
|
+
},
|
|
275
|
+
argv: (args) => [
|
|
276
|
+
'grep', String(args.pattern),
|
|
277
|
+
...when(args.match === 'literal', '--fixed'),
|
|
278
|
+
...when(args.case === 'insensitive', '--ignore-case'),
|
|
279
|
+
...when(typeof args.in === 'string' && args.in !== '', '--in', String(args.in)),
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
deps,
|
|
283
|
+
),
|
|
284
|
+
|
|
285
|
+
graftTool(
|
|
286
|
+
{
|
|
287
|
+
name: 'graft_callers',
|
|
288
|
+
description:
|
|
289
|
+
'The exact call/import/extends edges for one symbol: who calls it (default), what it ' +
|
|
290
|
+
'calls, or the full blast radius. Run this BEFORE renaming or changing a symbol — ' +
|
|
291
|
+
'editing the obvious file and missing its callers is the classic mistake.',
|
|
292
|
+
parameters: {
|
|
293
|
+
symbol: { type: 'string', required: true, description: 'The symbol name to trace.' },
|
|
294
|
+
direction: {
|
|
295
|
+
type: 'string',
|
|
296
|
+
enum: ['in', 'out'],
|
|
297
|
+
description: '"in" (default) = who calls it; "out" = what it calls.',
|
|
298
|
+
},
|
|
299
|
+
depth: {
|
|
300
|
+
type: 'string',
|
|
301
|
+
description: 'How far to walk: a number ("2"), or "all" for every connected source. Default 1.',
|
|
302
|
+
},
|
|
303
|
+
in: { type: 'string', description: 'Limit to one sub-project path, for a monorepo.' },
|
|
304
|
+
},
|
|
305
|
+
argv: (args) => [
|
|
306
|
+
'callers', String(args.symbol),
|
|
307
|
+
...when(args.direction === 'out', '--direction', 'out'),
|
|
308
|
+
...when(typeof args.depth === 'string' && args.depth !== '', '--depth', String(args.depth)),
|
|
309
|
+
...when(typeof args.in === 'string' && args.in !== '', '--in', String(args.in)),
|
|
310
|
+
],
|
|
311
|
+
},
|
|
312
|
+
deps,
|
|
313
|
+
),
|
|
314
|
+
|
|
315
|
+
graftTool(
|
|
316
|
+
{
|
|
317
|
+
name: 'graft_skeleton',
|
|
318
|
+
description:
|
|
319
|
+
'One file\'s whole API — every signature with its line span, no bodies. About a tenth ' +
|
|
320
|
+
'the tokens of reading the file, and usually enough to decide what to open.',
|
|
321
|
+
parameters: {
|
|
322
|
+
file: { type: 'string', required: true, description: 'Repo-relative path to the file.' },
|
|
323
|
+
},
|
|
324
|
+
argv: (args) => ['skeleton', String(args.file)],
|
|
325
|
+
},
|
|
326
|
+
deps,
|
|
327
|
+
),
|
|
328
|
+
|
|
329
|
+
graftTool(
|
|
330
|
+
{
|
|
331
|
+
name: 'graft_map',
|
|
332
|
+
description:
|
|
333
|
+
'Orientation in an unfamiliar repo: directory clusters, per-directory hubs, and the ' +
|
|
334
|
+
'global hotspots. One call is the answer — do not follow it by reading every file it names.',
|
|
335
|
+
parameters: {
|
|
336
|
+
max_dirs: { type: 'number', description: 'Cap on the directories listed.' },
|
|
337
|
+
},
|
|
338
|
+
argv: (args) => [
|
|
339
|
+
'map',
|
|
340
|
+
...when(Number.isFinite(args.max_dirs) && args.max_dirs > 0, '--max-dirs', String(args.max_dirs)),
|
|
341
|
+
],
|
|
342
|
+
},
|
|
343
|
+
deps,
|
|
344
|
+
),
|
|
345
|
+
]
|
|
346
|
+
}
|