aihand 0.0.1 → 0.1.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/README.md +152 -2
- package/dist/chunk-2NTK7H4W.js +10 -0
- package/dist/chunk-3X4FTHLC.cjs +369 -0
- package/dist/chunk-BXVNR4E2.js +399 -0
- package/dist/chunk-C7DGE6MY.cjs +1456 -0
- package/dist/chunk-DUUCVLC3.cjs +254 -0
- package/dist/chunk-FAHI53KO.cjs +125 -0
- package/dist/chunk-G7KVJ7NF.js +369 -0
- package/dist/chunk-GNEUSRGP.js +52 -0
- package/dist/chunk-IGNEAOLT.cjs +130 -0
- package/dist/chunk-IS5XFUDB.js +125 -0
- package/dist/chunk-JLYC76XL.js +2448 -0
- package/dist/chunk-KQOABC2O.cjs +52 -0
- package/dist/chunk-OVMK33AC.cjs +104 -0
- package/dist/chunk-OWYK2IGV.js +250 -0
- package/dist/chunk-PQSQN4CN.js +126 -0
- package/dist/chunk-QF6AG3M5.cjs +410 -0
- package/dist/chunk-QSAMLXML.js +1456 -0
- package/dist/chunk-VEKYRKPF.cjs +399 -0
- package/dist/chunk-Y6H7W7PI.cjs +2451 -0
- package/dist/chunk-YKSYW77R.js +410 -0
- package/dist/chunk-Z2Y65YOY.cjs +7 -0
- package/dist/chunk-ZJQRNIK7.js +104 -0
- package/dist/cli-3J7EYI6G.cjs +651 -0
- package/dist/cli-FIJLKAGI.js +649 -0
- package/dist/cli-JQEIE7RQ.js +120 -0
- package/dist/cli-K3OS2QQH.cjs +122 -0
- package/dist/cli-OSYG6LJD.cjs +89 -0
- package/dist/cli-TXRW5PG6.js +89 -0
- package/dist/cli.cjs +81 -0
- package/dist/cli.js +81 -0
- package/dist/config-5KEQLN6L.cjs +13 -0
- package/dist/config-PJPYKDLQ.js +13 -0
- package/dist/graph-IH56SCPK.js +8 -0
- package/dist/graph-ZUXXCJ5A.cjs +8 -0
- package/dist/index.cjs +481 -0
- package/dist/index.d.cts +461 -0
- package/dist/index.d.ts +461 -0
- package/dist/index.js +479 -0
- package/dist/locate-5XFSXJ5J.cjs +15 -0
- package/dist/locate-NKSUGL3A.js +15 -0
- package/dist/refactor-5FWSZIBN.cjs +19 -0
- package/dist/refactor-BOB3SZSA.js +19 -0
- package/dist/scan-4R7GQG2W.cjs +9 -0
- package/dist/scan-VF54GAAX.js +9 -0
- package/dist/ui/probe/server.cjs +505 -0
- package/dist/ui/probe/server.js +507 -0
- package/dist/vite.cjs +12 -0
- package/dist/vite.d.cts +12 -0
- package/dist/vite.d.ts +12 -0
- package/dist/vite.js +12 -0
- package/package.json +82 -9
- package/src/cli.ts +107 -0
- package/src/index.ts +54 -0
- package/src/read/cli.ts +650 -0
- package/src/read/compact.ts +286 -0
- package/src/read/config.ts +62 -0
- package/src/read/graph.ts +182 -0
- package/src/read/index.ts +12 -0
- package/src/read/inject.ts +121 -0
- package/src/read/locate.ts +104 -0
- package/src/read/panel.ts +335 -0
- package/src/read/pipeline.ts +78 -0
- package/src/read/refactor.ts +576 -0
- package/src/read/render.ts +1118 -0
- package/src/read/scan.ts +61 -0
- package/src/read/seam.ts +0 -0
- package/src/read/security.ts +171 -0
- package/src/read/signals.ts +333 -0
- package/src/read/state.ts +71 -0
- package/src/read/stategraph.ts +205 -0
- package/src/read/types.ts +162 -0
- package/src/read/vite.ts +77 -0
- package/src/ui/babel/line-profiler.ts +197 -0
- package/src/ui/babel/source-loc.ts +68 -0
- package/src/ui/bridge/cdp-bridge.ts +138 -0
- package/src/ui/bridge/compile-probe.ts +80 -0
- package/src/ui/bridge/transport.ts +26 -0
- package/src/ui/bridge/vite-bridge.ts +116 -0
- package/src/ui/client/client-patch.ts +899 -0
- package/src/ui/client/client.ts +2562 -0
- package/src/ui/core/action.ts +747 -0
- package/src/ui/core/candidates.ts +348 -0
- package/src/ui/core/canvas.ts +305 -0
- package/src/ui/core/check.ts +34 -0
- package/src/ui/core/compact.ts +314 -0
- package/src/ui/core/detail.ts +244 -0
- package/src/ui/core/diff.ts +253 -0
- package/src/ui/core/emit.ts +198 -0
- package/src/ui/core/knob-exec.ts +137 -0
- package/src/ui/core/perf.ts +254 -0
- package/src/ui/core/types.ts +164 -0
- package/src/ui/core/util.ts +221 -0
- package/src/ui/index.ts +5 -0
- package/src/ui/probe/cli.ts +139 -0
- package/src/ui/probe/server.ts +468 -0
- package/src/ui/self/act.ts +47 -0
- package/src/ui/self/discover.ts +101 -0
- package/src/ui/self/grow.ts +121 -0
- package/src/ui/self/install.ts +100 -0
- package/src/ui/self/probe.ts +105 -0
- package/src/ui/self/screen-hook.ts +44 -0
- package/src/ui/self/self.ts +48 -0
- package/src/ui/self/store-refs.ts +123 -0
- package/src/ui/self/store-schema.ts +65 -0
- package/src/ui/self/synth.ts +37 -0
- package/src/ui/server/cli.ts +102 -0
- package/src/ui/server/dispatch.ts +276 -0
- package/src/ui/server/help-text.ts +237 -0
- package/src/ui/server/knob-schema.ts +87 -0
- package/src/ui/server/plugin.ts +1151 -0
- package/src/vite.ts +39 -0
- package/index.js +0 -2
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { Parser, Tree } from 'web-tree-sitter'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
import { Language, Query } from 'web-tree-sitter'
|
|
6
|
+
|
|
7
|
+
const queryCache = new Map<Language, Map<string, Query>>()
|
|
8
|
+
|
|
9
|
+
function cachedQuery(lang: Language, source: string): Query {
|
|
10
|
+
let byLang = queryCache.get(lang)
|
|
11
|
+
if (!byLang) {
|
|
12
|
+
byLang = new Map()
|
|
13
|
+
queryCache.set(lang, byLang)
|
|
14
|
+
}
|
|
15
|
+
let q = byLang.get(source)
|
|
16
|
+
if (!q) {
|
|
17
|
+
q = new Query(lang, source)
|
|
18
|
+
byLang.set(source, q)
|
|
19
|
+
}
|
|
20
|
+
return q
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const SEPARATOR = '⋮----'
|
|
24
|
+
|
|
25
|
+
// True if the trimmed signature ends with an empty param list: "( )" with only whitespace inside.
|
|
26
|
+
function isNoParamFn(trimmed: string): boolean {
|
|
27
|
+
if (!trimmed.endsWith(')'))
|
|
28
|
+
return false
|
|
29
|
+
let i = trimmed.length - 2 // before the ')'
|
|
30
|
+
while (i >= 0 && (trimmed[i] === ' ' || trimmed[i] === '\t')) i--
|
|
31
|
+
return i >= 0 && trimmed[i] === '('
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// True if a comment line is a section separator: "//" + optional whitespace + "──".
|
|
35
|
+
function isSectionComment(text: string): boolean {
|
|
36
|
+
if (!text.startsWith('//'))
|
|
37
|
+
return false
|
|
38
|
+
let i = 2
|
|
39
|
+
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i++
|
|
40
|
+
return text.startsWith('──', i)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const IMPORT_QUERY = `
|
|
44
|
+
(import_statement
|
|
45
|
+
source: (string (string_fragment) @source))
|
|
46
|
+
`
|
|
47
|
+
|
|
48
|
+
export const TS_QUERY = `
|
|
49
|
+
(export_statement (function_declaration)) @fn
|
|
50
|
+
(export_statement (lexical_declaration (variable_declarator value: (arrow_function)))) @fn
|
|
51
|
+
(export_statement (generator_function_declaration)) @fn
|
|
52
|
+
(type_alias_declaration) @internal_type
|
|
53
|
+
(export_statement (type_alias_declaration)) @type
|
|
54
|
+
(interface_declaration) @internal_type
|
|
55
|
+
(export_statement (interface_declaration)) @type
|
|
56
|
+
(enum_declaration) @internal_type
|
|
57
|
+
(export_statement (enum_declaration)) @type
|
|
58
|
+
(class_declaration) @class
|
|
59
|
+
(export_statement (class_declaration)) @class
|
|
60
|
+
(export_statement value: (assignment_expression)) @export_val
|
|
61
|
+
(export_statement (lexical_declaration)) @export_val
|
|
62
|
+
(export_statement source: (string)) @reexport
|
|
63
|
+
(comment) @comment
|
|
64
|
+
`
|
|
65
|
+
|
|
66
|
+
interface Chunk { content: string, startRow: number, endRow: number }
|
|
67
|
+
|
|
68
|
+
const WASM_MAP: Record<string, string> = {
|
|
69
|
+
'.ts': 'tree-sitter-tsx.wasm',
|
|
70
|
+
'.tsx': 'tree-sitter-tsx.wasm',
|
|
71
|
+
'.mts': 'tree-sitter-tsx.wasm',
|
|
72
|
+
'.js': 'tree-sitter-tsx.wasm',
|
|
73
|
+
'.jsx': 'tree-sitter-tsx.wasm',
|
|
74
|
+
'.mjs': 'tree-sitter-tsx.wasm',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Known WASM package locations to probe
|
|
78
|
+
const WASM_PROBE_PATHS = [
|
|
79
|
+
'@repomix/tree-sitter-wasms/out',
|
|
80
|
+
'@anthropic-ai/tree-sitter-wasms/out',
|
|
81
|
+
'@anthropic-ai/codemap-tree-sitter-wasms/out',
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
const langCache = new Map<string, Language>()
|
|
85
|
+
let wasmWarnedOnce = false
|
|
86
|
+
|
|
87
|
+
function findWasmDir(wasmDir?: string): string | null {
|
|
88
|
+
if (wasmDir)
|
|
89
|
+
return existsSync(wasmDir) ? wasmDir : null
|
|
90
|
+
// Check relative to cwd (project has it installed)
|
|
91
|
+
for (const probe of WASM_PROBE_PATHS) {
|
|
92
|
+
const full = resolve('node_modules', probe)
|
|
93
|
+
if (existsSync(full))
|
|
94
|
+
return full
|
|
95
|
+
}
|
|
96
|
+
// Check from package's own location (npx / global install)
|
|
97
|
+
try {
|
|
98
|
+
const req = createRequire(import.meta.url)
|
|
99
|
+
return dirname(req.resolve('@repomix/tree-sitter-wasms/out/tree-sitter-tsx.wasm'))
|
|
100
|
+
}
|
|
101
|
+
catch {}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function getLang(ext: string, wasmDir?: string): Promise<Language | null> {
|
|
106
|
+
const wasm = WASM_MAP[ext]
|
|
107
|
+
if (!wasm)
|
|
108
|
+
return null
|
|
109
|
+
const dir = findWasmDir(wasmDir)
|
|
110
|
+
if (!dir) {
|
|
111
|
+
if (!wasmWarnedOnce) {
|
|
112
|
+
wasmWarnedOnce = true
|
|
113
|
+
const pkgs = WASM_PROBE_PATHS.map(p => p.split('/')[0]).join(', ')
|
|
114
|
+
console.warn(`[repodex] ⚠ tree-sitter wasm not found. Install one of: ${pkgs}`)
|
|
115
|
+
}
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
const wasmPath = join(dir, wasm)
|
|
119
|
+
if (!existsSync(wasmPath))
|
|
120
|
+
return null
|
|
121
|
+
const cached = langCache.get(wasmPath)
|
|
122
|
+
if (cached)
|
|
123
|
+
return cached
|
|
124
|
+
const lang = await Language.load(wasmPath)
|
|
125
|
+
langCache.set(wasmPath, lang)
|
|
126
|
+
return lang
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function cleanFnSignature(text: string): string {
|
|
130
|
+
const lines = text.split('\n')
|
|
131
|
+
let braceDepth = 0
|
|
132
|
+
let parenDepth = 0
|
|
133
|
+
let angleDepth = 0
|
|
134
|
+
let afterArrow = false
|
|
135
|
+
let arrowI = 0
|
|
136
|
+
let arrowJ = 0
|
|
137
|
+
let prevTopChar = ''
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < lines.length; i++) {
|
|
140
|
+
for (let j = 0; j < lines[i].length; j++) {
|
|
141
|
+
const ch = lines[i][j]
|
|
142
|
+
if (ch === ' ' || ch === '\t')
|
|
143
|
+
continue
|
|
144
|
+
if (ch === '(') {
|
|
145
|
+
parenDepth++
|
|
146
|
+
afterArrow = false
|
|
147
|
+
}
|
|
148
|
+
else if (ch === ')') {
|
|
149
|
+
parenDepth--
|
|
150
|
+
afterArrow = false
|
|
151
|
+
}
|
|
152
|
+
else if (ch === '<') {
|
|
153
|
+
angleDepth++
|
|
154
|
+
afterArrow = false
|
|
155
|
+
}
|
|
156
|
+
else if (ch === '>') {
|
|
157
|
+
if (angleDepth > 0)
|
|
158
|
+
angleDepth--
|
|
159
|
+
afterArrow = false
|
|
160
|
+
}
|
|
161
|
+
else if (ch === '{') {
|
|
162
|
+
if (afterArrow) {
|
|
163
|
+
lines[arrowI] = lines[arrowI].slice(0, arrowJ).trimEnd()
|
|
164
|
+
return lines.slice(0, arrowI + 1).join('\n')
|
|
165
|
+
}
|
|
166
|
+
if (parenDepth <= 0 && braceDepth <= 0 && angleDepth <= 0 && prevTopChar !== ':') {
|
|
167
|
+
lines[i] = lines[i].slice(0, j).trimEnd()
|
|
168
|
+
return lines.slice(0, i + 1).join('\n')
|
|
169
|
+
}
|
|
170
|
+
braceDepth++
|
|
171
|
+
afterArrow = false
|
|
172
|
+
}
|
|
173
|
+
else if (ch === '}') {
|
|
174
|
+
if (braceDepth > 0)
|
|
175
|
+
braceDepth--
|
|
176
|
+
afterArrow = false
|
|
177
|
+
}
|
|
178
|
+
else if (ch === '=' && lines[i][j + 1] === '>') {
|
|
179
|
+
if (parenDepth <= 0 && braceDepth <= 0 && angleDepth <= 0) {
|
|
180
|
+
lines[i] = lines[i].slice(0, j).trimEnd()
|
|
181
|
+
return lines.slice(0, i + 1).join('\n')
|
|
182
|
+
}
|
|
183
|
+
afterArrow = true
|
|
184
|
+
arrowI = i
|
|
185
|
+
arrowJ = j
|
|
186
|
+
j++ // skip >
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
afterArrow = false
|
|
190
|
+
}
|
|
191
|
+
if (parenDepth <= 0 && braceDepth <= 0 && angleDepth <= 0)
|
|
192
|
+
prevTopChar = ch
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return text.split('\n')[0]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface CompactFlags {
|
|
199
|
+
skipNoParamFn?: boolean
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function compact(code: string, parser: Parser, lang: Language, flags: CompactFlags = {}, existingTree?: Tree): string {
|
|
203
|
+
const { skipNoParamFn = true } = flags
|
|
204
|
+
const tree = existingTree ?? parser.parse(code)
|
|
205
|
+
if (!tree)
|
|
206
|
+
return ''
|
|
207
|
+
const query = cachedQuery(lang, TS_QUERY)
|
|
208
|
+
const captures = query.captures(tree.rootNode)
|
|
209
|
+
const ownTree = !existingTree
|
|
210
|
+
captures.sort((a, b) => a.node.startPosition.row - b.node.startPosition.row)
|
|
211
|
+
|
|
212
|
+
const chunks: Chunk[] = []
|
|
213
|
+
const seen = new Set<number>()
|
|
214
|
+
|
|
215
|
+
for (const { name, node } of captures) {
|
|
216
|
+
const startRow = node.startPosition.row
|
|
217
|
+
if (seen.has(startRow))
|
|
218
|
+
continue
|
|
219
|
+
seen.add(startRow)
|
|
220
|
+
|
|
221
|
+
let content: string
|
|
222
|
+
if (name === 'fn' || name === 'class') {
|
|
223
|
+
content = cleanFnSignature(node.text)
|
|
224
|
+
}
|
|
225
|
+
else if (name === 'comment') {
|
|
226
|
+
if (isSectionComment(node.text))
|
|
227
|
+
content = node.text
|
|
228
|
+
else continue
|
|
229
|
+
}
|
|
230
|
+
else if (name === 'reexport') {
|
|
231
|
+
content = node.text
|
|
232
|
+
}
|
|
233
|
+
else if (name === 'export_val') {
|
|
234
|
+
if (node.descendantsOfType('arrow_function').length > 0) {
|
|
235
|
+
content = cleanFnSignature(node.text)
|
|
236
|
+
}
|
|
237
|
+
else if (node.text.includes('\n')) {
|
|
238
|
+
content = node.text.split('\n')[0]
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
content = node.text
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (name === 'internal_type' || name === 'type') {
|
|
245
|
+
content = node.text
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
content = node.text
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const trimmed = content.trim()
|
|
252
|
+
if (skipNoParamFn && (name === 'fn' || name === 'export_val') && isNoParamFn(trimmed))
|
|
253
|
+
continue
|
|
254
|
+
chunks.push({ content: trimmed, startRow, endRow: node.endPosition.row })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Merge chunks within 1 blank line of each other (endRow + 2 covers 0–1 row gaps)
|
|
258
|
+
const GAP = 2
|
|
259
|
+
const merged: Chunk[] = []
|
|
260
|
+
for (const chunk of chunks) {
|
|
261
|
+
const last = merged[merged.length - 1]
|
|
262
|
+
if (last && last.endRow + GAP >= chunk.startRow) {
|
|
263
|
+
last.content += `\n${chunk.content}`
|
|
264
|
+
last.endRow = chunk.endRow
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
merged.push({ ...chunk })
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const result = merged.map(c => c.content).join(`\n${SEPARATOR}\n`)
|
|
272
|
+
if (ownTree)
|
|
273
|
+
tree.delete()
|
|
274
|
+
return result
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function extractImports(code: string, parser: Parser, lang: Language, existingTree?: Tree): string[] {
|
|
278
|
+
const tree = existingTree ?? parser.parse(code)
|
|
279
|
+
if (!tree)
|
|
280
|
+
return []
|
|
281
|
+
const query = cachedQuery(lang, IMPORT_QUERY)
|
|
282
|
+
const result = query.captures(tree.rootNode).map(c => c.node.text)
|
|
283
|
+
if (!existingTree)
|
|
284
|
+
tree.delete()
|
|
285
|
+
return result
|
|
286
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { AidevConfig, Config, ReadOptions, RuntimeOptions } from './types.js'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { createJiti } from 'jiti'
|
|
6
|
+
import { DEFAULT_CONFIG, resolveModule } from './types.js'
|
|
7
|
+
|
|
8
|
+
export const CONFIG_PATH = 'aihand.config.ts'
|
|
9
|
+
const SELF_PATH = fileURLToPath(import.meta.url)
|
|
10
|
+
|
|
11
|
+
const TS_EXTS = ['.ts', '.tsx', '.js', '.jsx']
|
|
12
|
+
export const isTsFile = (p: string): boolean => TS_EXTS.some(ext => p.endsWith(ext))
|
|
13
|
+
|
|
14
|
+
// 加载 aihand.config.ts 合并进 DEFAULT_CONFIG。root 缺省 = cwd(CLI 行为);插件传 server.config.root。
|
|
15
|
+
// 抽出 cli.ts 是融合的需要:ui server 要跑同一份 read 配置去构图,不能welded 在带 process.exit 的 CLI 壳里。
|
|
16
|
+
export async function loadConfig(root: string = resolve('.')): Promise<Config> {
|
|
17
|
+
const configFile = join(root, CONFIG_PATH)
|
|
18
|
+
if (!existsSync(configFile))
|
|
19
|
+
return DEFAULT_CONFIG
|
|
20
|
+
// Resolve the bare `aihand` a user's config imports `defineConfig` from to THIS install's
|
|
21
|
+
// index. dist: read cli bundles to a sibling chunk in dist/ → dist/index.js. src (tsx dev):
|
|
22
|
+
// this file is src/read/config.ts → ../index.ts. Pick whichever exists.
|
|
23
|
+
const selfDir = dirname(SELF_PATH)
|
|
24
|
+
const aihandIndex = [join(selfDir, 'index.js'), resolve(selfDir, '../index.ts')].find(existsSync)
|
|
25
|
+
?? join(selfDir, 'index.js')
|
|
26
|
+
const jiti = createJiti(root, {
|
|
27
|
+
alias: { aihand: aihandIndex },
|
|
28
|
+
moduleCache: false,
|
|
29
|
+
fsCache: false,
|
|
30
|
+
})
|
|
31
|
+
const mod = await jiti.import(configFile) as Record<string, unknown>
|
|
32
|
+
const raw = (mod.default ?? mod) as AidevConfig
|
|
33
|
+
return normalizeConfig(raw)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Three module switches → resolved nested Config. Each module: undefined/true → on+defaults,
|
|
37
|
+
// false → off, object → on + shallow override. include is the one merge (root config files
|
|
38
|
+
// from defaults stay, the config's globs add on); everything else is plain override.
|
|
39
|
+
export function normalizeConfig(raw: AidevConfig): Config {
|
|
40
|
+
const read = resolveModule<ReadOptions>(raw.read, {})
|
|
41
|
+
const d = DEFAULT_CONFIG.read
|
|
42
|
+
return {
|
|
43
|
+
read: {
|
|
44
|
+
enabled: read.enabled,
|
|
45
|
+
include: [...d.include, ...(read.include ?? ['src/**'])],
|
|
46
|
+
ignore: read.ignore ?? d.ignore,
|
|
47
|
+
fileDetailLevel: { ...d.fileDetailLevel, ...read.fileDetailLevel },
|
|
48
|
+
injectDetailLevel: read.injectDetailLevel ?? d.injectDetailLevel,
|
|
49
|
+
injectTargetFiles: read.injectTargetFiles ?? d.injectTargetFiles,
|
|
50
|
+
injectDevToolInstructions: read.injectDevToolInstructions ?? d.injectDevToolInstructions,
|
|
51
|
+
injectCodemap: read.injectCodemap ?? d.injectCodemap,
|
|
52
|
+
showNoParamFn: read.showNoParamFn ?? d.showNoParamFn,
|
|
53
|
+
treeFoldThreshold: read.treeFoldThreshold ?? d.treeFoldThreshold,
|
|
54
|
+
maxTokens: typeof read.maxTokens === 'number' ? read.maxTokens : d.maxTokens,
|
|
55
|
+
},
|
|
56
|
+
refactor: { enabled: raw.refactor !== false },
|
|
57
|
+
runtime: ((): Config['runtime'] => {
|
|
58
|
+
const r = resolveModule<RuntimeOptions>(raw.runtime, {})
|
|
59
|
+
return { enabled: r.enabled, storeMarker: r.storeMarker ?? '_loading' }
|
|
60
|
+
})(),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { Language } from 'web-tree-sitter'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { Parser, Query } from 'web-tree-sitter'
|
|
4
|
+
import { getLang } from './compact.js'
|
|
5
|
+
|
|
6
|
+
// ── Types ─────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface SymbolNode {
|
|
9
|
+
uid: string // "src/models/gen.ts::gen"
|
|
10
|
+
name: string
|
|
11
|
+
kind: 'function' | 'method' | 'class'
|
|
12
|
+
filePath: string
|
|
13
|
+
startLine: number
|
|
14
|
+
endLine: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CallGraph {
|
|
18
|
+
symbols: Record<string, SymbolNode> // uid → node
|
|
19
|
+
out: Record<string, string[]> // uid → callees (uid[])
|
|
20
|
+
in: Record<string, string[]> // uid → callers (uid[])
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Tree-sitter queries ───────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const DEFS_QUERY = `
|
|
26
|
+
(function_declaration name: (identifier) @name) @def
|
|
27
|
+
(generator_function_declaration name: (identifier) @name) @def
|
|
28
|
+
(class_declaration name: (type_identifier) @name) @def
|
|
29
|
+
(method_definition name: (property_identifier) @name) @def
|
|
30
|
+
(variable_declarator name: (identifier) @name value: (arrow_function)) @def
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
const CALLS_QUERY = `
|
|
34
|
+
(call_expression function: (identifier) @callee)
|
|
35
|
+
(call_expression function: (member_expression property: (property_identifier) @callee))
|
|
36
|
+
(new_expression constructor: (identifier) @callee)
|
|
37
|
+
`
|
|
38
|
+
|
|
39
|
+
// ── Internal helpers ──────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface RawDef {
|
|
42
|
+
name: string
|
|
43
|
+
kind: 'function' | 'method' | 'class'
|
|
44
|
+
startLine: number
|
|
45
|
+
endLine: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface RawAttributed {
|
|
49
|
+
callerDef: RawDef
|
|
50
|
+
callee: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractFileGraph(
|
|
54
|
+
code: string,
|
|
55
|
+
parser: Parser,
|
|
56
|
+
defsQuery: Query,
|
|
57
|
+
callsQuery: Query,
|
|
58
|
+
): { defs: RawDef[], attributed: RawAttributed[] } {
|
|
59
|
+
const tree = parser.parse(code)
|
|
60
|
+
if (!tree)
|
|
61
|
+
return { defs: [], attributed: [] }
|
|
62
|
+
|
|
63
|
+
// Extract defs via matches() to pair @def + @name
|
|
64
|
+
const defs: RawDef[] = []
|
|
65
|
+
for (const match of defsQuery.matches(tree.rootNode)) {
|
|
66
|
+
const defCapture = match.captures.find(c => c.name === 'def')
|
|
67
|
+
const nameCapture = match.captures.find(c => c.name === 'name')
|
|
68
|
+
if (!defCapture || !nameCapture)
|
|
69
|
+
continue
|
|
70
|
+
const KIND_MAP: Record<string, 'class' | 'method' | 'function'> = {
|
|
71
|
+
class_declaration: 'class',
|
|
72
|
+
method_definition: 'method',
|
|
73
|
+
}
|
|
74
|
+
const kind = KIND_MAP[defCapture.node.type] ?? 'function'
|
|
75
|
+
defs.push({
|
|
76
|
+
name: nameCapture.node.text,
|
|
77
|
+
kind,
|
|
78
|
+
startLine: defCapture.node.startPosition.row,
|
|
79
|
+
endLine: defCapture.node.endPosition.row,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Extract calls via captures()
|
|
84
|
+
const rawCalls: Array<{ callee: string, row: number }> = []
|
|
85
|
+
for (const { node } of callsQuery.captures(tree.rootNode))
|
|
86
|
+
rawCalls.push({ callee: node.text, row: node.startPosition.row })
|
|
87
|
+
|
|
88
|
+
// Interval overlap: attribute each call to its innermost enclosing def
|
|
89
|
+
const attributed: RawAttributed[] = []
|
|
90
|
+
for (const call of rawCalls) {
|
|
91
|
+
let best: RawDef | null = null
|
|
92
|
+
let bestSize = Infinity
|
|
93
|
+
for (const def of defs) {
|
|
94
|
+
if (call.row >= def.startLine && call.row <= def.endLine) {
|
|
95
|
+
const size = def.endLine - def.startLine
|
|
96
|
+
if (size < bestSize) {
|
|
97
|
+
best = def
|
|
98
|
+
bestSize = size
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (best)
|
|
103
|
+
attributed.push({ callerDef: best, callee: call.callee })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
tree.delete()
|
|
107
|
+
return { defs, attributed }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Public API ────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export async function buildCallGraph(filePaths: string[], contents?: Map<string, string>): Promise<CallGraph> {
|
|
113
|
+
await Parser.init()
|
|
114
|
+
const parser = new Parser()
|
|
115
|
+
|
|
116
|
+
// Derive language from the first file whose extension has a known wasm
|
|
117
|
+
let lang: Language | null = null
|
|
118
|
+
for (const fp of filePaths) {
|
|
119
|
+
const ext = fp.slice(fp.lastIndexOf('.'))
|
|
120
|
+
lang = await getLang(ext)
|
|
121
|
+
if (lang)
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
if (!lang)
|
|
125
|
+
return { symbols: {}, out: {}, in: {} }
|
|
126
|
+
|
|
127
|
+
parser.setLanguage(lang)
|
|
128
|
+
|
|
129
|
+
// Create queries once, outside the file loop
|
|
130
|
+
const defsQuery = new Query(lang, DEFS_QUERY)
|
|
131
|
+
const callsQuery = new Query(lang, CALLS_QUERY)
|
|
132
|
+
|
|
133
|
+
const symbols: Record<string, SymbolNode> = {}
|
|
134
|
+
const nameToUids = new Map<string, string[]>()
|
|
135
|
+
const fileGraphs: Array<{ filePath: string, attributed: RawAttributed[] }> = []
|
|
136
|
+
|
|
137
|
+
// Phase 1: collect all defs
|
|
138
|
+
for (const filePath of filePaths) {
|
|
139
|
+
let code: string
|
|
140
|
+
if (contents?.has(filePath)) {
|
|
141
|
+
code = contents.get(filePath)!
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
try {
|
|
145
|
+
code = readFileSync(filePath, 'utf-8')
|
|
146
|
+
}
|
|
147
|
+
catch { continue }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const { defs, attributed } = extractFileGraph(code, parser, defsQuery, callsQuery)
|
|
151
|
+
for (const def of defs) {
|
|
152
|
+
const uid = `${filePath}::${def.name}:${def.startLine}`
|
|
153
|
+
symbols[uid] = { uid, name: def.name, kind: def.kind, filePath, startLine: def.startLine, endLine: def.endLine }
|
|
154
|
+
if (!nameToUids.has(def.name))
|
|
155
|
+
nameToUids.set(def.name, [])
|
|
156
|
+
nameToUids.get(def.name)!.push(uid)
|
|
157
|
+
}
|
|
158
|
+
fileGraphs.push({ filePath, attributed })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Phase 2: build edges (use Sets for O(1) dedup)
|
|
162
|
+
const outSets: Record<string, Set<string>> = {}
|
|
163
|
+
const inSets: Record<string, Set<string>> = {}
|
|
164
|
+
|
|
165
|
+
for (const { filePath, attributed } of fileGraphs) {
|
|
166
|
+
for (const { callerDef, callee } of attributed) {
|
|
167
|
+
const callerUid = `${filePath}::${callerDef.name}:${callerDef.startLine}`
|
|
168
|
+
for (const calleeUid of nameToUids.get(callee) ?? []) {
|
|
169
|
+
;(outSets[callerUid] ??= new Set()).add(calleeUid)
|
|
170
|
+
;(inSets[calleeUid] ??= new Set()).add(callerUid)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const out = Object.fromEntries(Object.entries(outSets).map(([k, v]) => [k, [...v]]))
|
|
176
|
+
const inEdges = Object.fromEntries(Object.entries(inSets).map(([k, v]) => [k, [...v]]))
|
|
177
|
+
|
|
178
|
+
defsQuery.delete()
|
|
179
|
+
callsQuery.delete()
|
|
180
|
+
parser.delete()
|
|
181
|
+
return { symbols, out, in: inEdges }
|
|
182
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { cleanFnSignature, compact, extractImports, getLang, IMPORT_QUERY, SEPARATOR, TS_QUERY } from './compact.js'
|
|
2
|
+
export type { CallGraph, SymbolNode } from './graph.js'
|
|
3
|
+
export { buildCallGraph } from './graph.js'
|
|
4
|
+
export { ensureMarkers, injectAll, injectFile } from './inject.js'
|
|
5
|
+
export type { LocateResult } from './locate.js'
|
|
6
|
+
export { locate, parseInspPath, renderLocate, symbolAtLine } from './locate.js'
|
|
7
|
+
export { buildBlocks, buildClassifier, formatTree, formatXml } from './render.js'
|
|
8
|
+
export { scan } from './scan.js'
|
|
9
|
+
export { scanSecrets } from './security.js'
|
|
10
|
+
export { allocate, buildImportGraph, computeCentrality, computeRecency, expandByBudget } from './signals.js'
|
|
11
|
+
export type { Config, FileBlock, FileDetailLevel, RepodexData } from './types.js'
|
|
12
|
+
export { configTemplate, DEFAULT_CONFIG, defineConfig } from './types.js'
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Config, RepodexData } from './types.js'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const START_TAG = '<!-- AIDEV:START -->'
|
|
6
|
+
const END_TAG = '<!-- AIDEV:END -->'
|
|
7
|
+
|
|
8
|
+
export type EnsureMarkersResult = 'created' | 'marked' | 'skipped'
|
|
9
|
+
|
|
10
|
+
export function ensureMarkers(filePath: string): EnsureMarkersResult {
|
|
11
|
+
if (!existsSync(filePath)) {
|
|
12
|
+
const dir = dirname(filePath)
|
|
13
|
+
if (!existsSync(dir))
|
|
14
|
+
mkdirSync(dir, { recursive: true })
|
|
15
|
+
writeFileSync(filePath, `${START_TAG}\n${END_TAG}\n`)
|
|
16
|
+
return 'created'
|
|
17
|
+
}
|
|
18
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
19
|
+
if (content.includes(START_TAG))
|
|
20
|
+
return 'skipped'
|
|
21
|
+
const sep = content.endsWith('\n') ? '' : '\n'
|
|
22
|
+
writeFileSync(filePath, `${content}${sep}\n${START_TAG}\n${END_TAG}\n`)
|
|
23
|
+
return 'marked'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEV_TOOL_HINT = '> Before changing code: `aihand` is this repo\'s read·ui·refactor tool (read the control panel / reverse-locate, drive the running page, AST refactor). Run `aihand --help` to expand.'
|
|
27
|
+
|
|
28
|
+
// Overview stays resident (what this repo is). Everything else is one command away by intent —
|
|
29
|
+
// no "pull the whole map then eyeball it" round-trip (kills the multi-layer routing).
|
|
30
|
+
const CODEMAP_HINT = [
|
|
31
|
+
'> **Locate code** (on demand, don\'t pull the whole map first):',
|
|
32
|
+
'> · change a UI behavior → `aihand read panel` (knob × store morphism: one line per control, which field it writes)',
|
|
33
|
+
'> · where files live / the layout → `aihand read tree`',
|
|
34
|
+
'> · runtime coord → static symbol → `aihand read source <file:line>` (with callers/callees)',
|
|
35
|
+
'> · the full map at once → `aihand read --stdout`',
|
|
36
|
+
].join('\n')
|
|
37
|
+
|
|
38
|
+
const DEFAULT_INJECT_FILES = ['CLAUDE.md', 'AGENTS.md']
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pure: config + data → the codemap body that belongs between the markers (assembled sections).
|
|
42
|
+
* The inject path (computeInjection) and the stdout path (aihand read --stdout) share this one
|
|
43
|
+
* renderer — "what you see == what was once injected". When injectCodemap=false the queryable
|
|
44
|
+
* map (panel/state/tree/...) collapses to a pointer; Overview stays resident. The map is a
|
|
45
|
+
* queryable product, pulled on demand via a command, paying no per-session residency tax.
|
|
46
|
+
*/
|
|
47
|
+
export function renderSections(config: Config, data: RepodexData): string {
|
|
48
|
+
const sections: string[] = []
|
|
49
|
+
// Overview is the entry point (what this repo is) — without it in context the AI has no
|
|
50
|
+
// starting point to build a topology, so "always fetch" ≠ "on demand". Always resident,
|
|
51
|
+
// never gated by injectCodemap. What IS gated: the few hundred lines of queryable
|
|
52
|
+
// panel/state/tree/sig/full products.
|
|
53
|
+
if (data.overview)
|
|
54
|
+
sections.push(`# Overview\n\n${data.overview}`)
|
|
55
|
+
if (config.read.injectCodemap) {
|
|
56
|
+
if (data.panel)
|
|
57
|
+
sections.push(`# Control Panel — 系统的控制论模型\n\n这是最高抽象层:一个洗衣机面板。系统无论内部多复杂(几千组件),其本质 = **(旋钮 × 状态) → 状态转移**。旋钮是完整接口,拨任一旋钮整个 App 进入新状态;下面的状态显示是输出端/可观测量。下面的几千组件全是这十几个控制量的展开。读它即知"这 App 能干什么、由哪些状态驱动、改某个 UI 去哪个 store 字段"。改 UI 行为前先在这里定位,再 \`aihand read source\` 展开。\n\n## 旋钮(输入端)\n\n一行一个旋钮:\`「名字」 事件 → store.{ 字段=目标值 } :行号\`——拨它把哪个 store 状态推向什么值。\n\n\`\`\`\n${data.panel}\n\`\`\``)
|
|
58
|
+
if (data.state)
|
|
59
|
+
sections.push(`## 状态显示(输出端 / 可观测量)\n\n系统由哪些状态量构成、各自初值、各自值域(\`{a|b|c}\` = 可枚举的离散态,即面板能显示的状态机;无标注 = boolean/对象等非枚举态)。旋钮拨动的就是这些量。\n\n\`\`\`\n${data.state}\n\`\`\``)
|
|
60
|
+
if (data.tree)
|
|
61
|
+
sections.push(`# File Tree\n\n${data.tree}`)
|
|
62
|
+
if (data.signatures)
|
|
63
|
+
sections.push(`# Signatures\n\n\`\`\`\n${data.signatures}\n\`\`\``)
|
|
64
|
+
if (data.full)
|
|
65
|
+
sections.push(`# Full Source\n\n\`\`\`\n${data.full}\n\`\`\``)
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
sections.push(CODEMAP_HINT)
|
|
69
|
+
}
|
|
70
|
+
if (config.read.injectDevToolInstructions)
|
|
71
|
+
sections.push(DEV_TOOL_HINT)
|
|
72
|
+
return sections.join('\n\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 纯核心:已有文件内容 → 注入后的新内容;返回 null 表示无 marker 或 repodex 段无变化。 */
|
|
76
|
+
export function computeInjection(
|
|
77
|
+
content: string,
|
|
78
|
+
config: Config,
|
|
79
|
+
data: RepodexData,
|
|
80
|
+
): string | null {
|
|
81
|
+
const si = content.indexOf(START_TAG)
|
|
82
|
+
const ei = content.indexOf(END_TAG)
|
|
83
|
+
if (si === -1 || ei === -1)
|
|
84
|
+
return null
|
|
85
|
+
|
|
86
|
+
// 只比较标记之间的 repodex 段:标记外的手写内容变动不触发重写
|
|
87
|
+
const oldBody = content.slice(si + START_TAG.length, ei)
|
|
88
|
+
const newBody = `\n\n${renderSections(config, data)}\n`
|
|
89
|
+
if (oldBody === newBody)
|
|
90
|
+
return null
|
|
91
|
+
const before = content.slice(0, si + START_TAG.length)
|
|
92
|
+
const after = content.slice(ei)
|
|
93
|
+
return `${before}${newBody}${after}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function injectFile(
|
|
97
|
+
config: Config,
|
|
98
|
+
data: RepodexData,
|
|
99
|
+
filePath: string,
|
|
100
|
+
): boolean {
|
|
101
|
+
if (!existsSync(filePath))
|
|
102
|
+
return false
|
|
103
|
+
const next = computeInjection(readFileSync(filePath, 'utf-8'), config, data)
|
|
104
|
+
if (next === null)
|
|
105
|
+
return false
|
|
106
|
+
writeFileSync(filePath, next)
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function injectAll(
|
|
111
|
+
config: Config,
|
|
112
|
+
data: RepodexData,
|
|
113
|
+
files = DEFAULT_INJECT_FILES,
|
|
114
|
+
): string[] {
|
|
115
|
+
const injected: string[] = []
|
|
116
|
+
for (const f of files) {
|
|
117
|
+
if (injectFile(config, data, f))
|
|
118
|
+
injected.push(f)
|
|
119
|
+
}
|
|
120
|
+
return injected
|
|
121
|
+
}
|