aihand 0.0.1 → 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.
Files changed (113) hide show
  1. package/README.md +136 -2
  2. package/dist/chunk-2NTK7H4W.js +10 -0
  3. package/dist/chunk-3X4FTHLC.cjs +369 -0
  4. package/dist/chunk-BXVNR4E2.js +399 -0
  5. package/dist/chunk-C7DGE6MY.cjs +1456 -0
  6. package/dist/chunk-DUUCVLC3.cjs +254 -0
  7. package/dist/chunk-FAHI53KO.cjs +125 -0
  8. package/dist/chunk-G7KVJ7NF.js +369 -0
  9. package/dist/chunk-GNEUSRGP.js +52 -0
  10. package/dist/chunk-IGNEAOLT.cjs +130 -0
  11. package/dist/chunk-IS5XFUDB.js +125 -0
  12. package/dist/chunk-JLYC76XL.js +2448 -0
  13. package/dist/chunk-KQOABC2O.cjs +52 -0
  14. package/dist/chunk-OVMK33AC.cjs +104 -0
  15. package/dist/chunk-OWYK2IGV.js +250 -0
  16. package/dist/chunk-PQSQN4CN.js +126 -0
  17. package/dist/chunk-QF6AG3M5.cjs +410 -0
  18. package/dist/chunk-QSAMLXML.js +1456 -0
  19. package/dist/chunk-VEKYRKPF.cjs +399 -0
  20. package/dist/chunk-Y6H7W7PI.cjs +2451 -0
  21. package/dist/chunk-YKSYW77R.js +410 -0
  22. package/dist/chunk-Z2Y65YOY.cjs +7 -0
  23. package/dist/chunk-ZJQRNIK7.js +104 -0
  24. package/dist/cli-FDS2C2CZ.cjs +651 -0
  25. package/dist/cli-HHRGYPSM.js +649 -0
  26. package/dist/cli-JQEIE7RQ.js +120 -0
  27. package/dist/cli-K3OS2QQH.cjs +122 -0
  28. package/dist/cli-OSYG6LJD.cjs +89 -0
  29. package/dist/cli-TXRW5PG6.js +89 -0
  30. package/dist/cli.cjs +81 -0
  31. package/dist/cli.js +81 -0
  32. package/dist/config-5KEQLN6L.cjs +13 -0
  33. package/dist/config-PJPYKDLQ.js +13 -0
  34. package/dist/graph-IH56SCPK.js +8 -0
  35. package/dist/graph-ZUXXCJ5A.cjs +8 -0
  36. package/dist/index.cjs +481 -0
  37. package/dist/index.d.cts +461 -0
  38. package/dist/index.d.ts +461 -0
  39. package/dist/index.js +479 -0
  40. package/dist/locate-5XFSXJ5J.cjs +15 -0
  41. package/dist/locate-NKSUGL3A.js +15 -0
  42. package/dist/refactor-5FWSZIBN.cjs +19 -0
  43. package/dist/refactor-BOB3SZSA.js +19 -0
  44. package/dist/scan-4R7GQG2W.cjs +9 -0
  45. package/dist/scan-VF54GAAX.js +9 -0
  46. package/dist/ui/probe/server.cjs +505 -0
  47. package/dist/ui/probe/server.js +507 -0
  48. package/dist/vite.cjs +12 -0
  49. package/dist/vite.d.cts +12 -0
  50. package/dist/vite.d.ts +12 -0
  51. package/dist/vite.js +12 -0
  52. package/package.json +82 -9
  53. package/src/cli.ts +107 -0
  54. package/src/index.ts +54 -0
  55. package/src/read/cli.ts +650 -0
  56. package/src/read/compact.ts +286 -0
  57. package/src/read/config.ts +62 -0
  58. package/src/read/graph.ts +182 -0
  59. package/src/read/index.ts +12 -0
  60. package/src/read/inject.ts +121 -0
  61. package/src/read/locate.ts +104 -0
  62. package/src/read/panel.ts +335 -0
  63. package/src/read/pipeline.ts +78 -0
  64. package/src/read/refactor.ts +576 -0
  65. package/src/read/render.ts +1118 -0
  66. package/src/read/scan.ts +61 -0
  67. package/src/read/seam.ts +0 -0
  68. package/src/read/security.ts +171 -0
  69. package/src/read/signals.ts +333 -0
  70. package/src/read/state.ts +71 -0
  71. package/src/read/stategraph.ts +205 -0
  72. package/src/read/types.ts +162 -0
  73. package/src/read/vite.ts +77 -0
  74. package/src/ui/babel/line-profiler.ts +197 -0
  75. package/src/ui/babel/source-loc.ts +68 -0
  76. package/src/ui/bridge/cdp-bridge.ts +138 -0
  77. package/src/ui/bridge/compile-probe.ts +80 -0
  78. package/src/ui/bridge/transport.ts +26 -0
  79. package/src/ui/bridge/vite-bridge.ts +116 -0
  80. package/src/ui/client/client-patch.ts +899 -0
  81. package/src/ui/client/client.ts +2562 -0
  82. package/src/ui/core/action.ts +747 -0
  83. package/src/ui/core/candidates.ts +348 -0
  84. package/src/ui/core/canvas.ts +305 -0
  85. package/src/ui/core/check.ts +34 -0
  86. package/src/ui/core/compact.ts +314 -0
  87. package/src/ui/core/detail.ts +244 -0
  88. package/src/ui/core/diff.ts +253 -0
  89. package/src/ui/core/emit.ts +198 -0
  90. package/src/ui/core/knob-exec.ts +137 -0
  91. package/src/ui/core/perf.ts +254 -0
  92. package/src/ui/core/types.ts +164 -0
  93. package/src/ui/core/util.ts +221 -0
  94. package/src/ui/index.ts +5 -0
  95. package/src/ui/probe/cli.ts +139 -0
  96. package/src/ui/probe/server.ts +468 -0
  97. package/src/ui/self/act.ts +47 -0
  98. package/src/ui/self/discover.ts +101 -0
  99. package/src/ui/self/grow.ts +121 -0
  100. package/src/ui/self/install.ts +100 -0
  101. package/src/ui/self/probe.ts +105 -0
  102. package/src/ui/self/screen-hook.ts +44 -0
  103. package/src/ui/self/self.ts +48 -0
  104. package/src/ui/self/store-refs.ts +123 -0
  105. package/src/ui/self/store-schema.ts +65 -0
  106. package/src/ui/self/synth.ts +37 -0
  107. package/src/ui/server/cli.ts +102 -0
  108. package/src/ui/server/dispatch.ts +276 -0
  109. package/src/ui/server/help-text.ts +237 -0
  110. package/src/ui/server/knob-schema.ts +87 -0
  111. package/src/ui/server/plugin.ts +1151 -0
  112. package/src/vite.ts +39 -0
  113. 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
+ }