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.
Files changed (113) hide show
  1. package/README.md +152 -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-3J7EYI6G.cjs +651 -0
  25. package/dist/cli-FIJLKAGI.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,104 @@
1
+ // 一体化的反向态射 —— 运行时坐标 → 静态符号上下文(FUSION.md §2)。
2
+ //
3
+ // 正向半边是控制面板(旋钮 → store 态 → 转移)。反向半边回答用户原话:
4
+ // 「页面 UI 变化时,直接映射到代码原因去改」。四投影共享主键 `相对文件:行`
5
+ // (静态 SymbolNode{filePath,startLine,endLine} ⋉ 运行时 data-insp-path),
6
+ // 缝合它们的只是一个 find —— symbolAtLine。没有第四个数据结构,没有 FusionGraph。
7
+ //
8
+ // 按符号名匹配会跨文件同名串号;symbolAtLine 拿到精确 uid,callers/callees 按 uid 取
9
+ // (graph.in/out),不经名字,绝不串别文件同名符号。
10
+
11
+ import type { CallGraph, SymbolNode } from './graph.js'
12
+ import { fileOfInspPath } from './seam.js'
13
+
14
+ // 运行时坐标 → 静态符号。融合体唯一真态射。
15
+ // 多命中(嵌套:组件函数体 + 内嵌 handler arrow 都覆盖同一行)取区间最小者 = 最内层符号,
16
+ // 那才是「这一行真正属于谁」。
17
+ export function symbolAtLine(g: CallGraph, file: string, line: number): SymbolNode | undefined {
18
+ let best: SymbolNode | undefined
19
+ for (const s of Object.values(g.symbols)) {
20
+ if (s.filePath !== file || line < s.startLine || line > s.endLine)
21
+ continue
22
+ if (!best || (s.endLine - s.startLine) < (best.endLine - best.startLine))
23
+ best = s
24
+ }
25
+ return best
26
+ }
27
+
28
+ // data-insp-path = `rel:line:col:Name`(也可能只是 `rel:line`)→ {file, line}。
29
+ // 禁正则:file 段复用 seam 的「砍末三段」(对绝对路径盘符冒号也稳);line 紧跟 file 段。
30
+ // 形态 A `rel:line`(2 段)→ file=parts[0], line=parts[1]
31
+ // 形态 B `rel:line:col:Name`(≥4 段)→ file=前 N-3 段, line=第 N-2 段(即 file 段后第一段)
32
+ export function parseInspPath(p: string): { file: string, line: number } {
33
+ const parts = p.split(':')
34
+ const file = fileOfInspPath(p)
35
+ const lineStr = parts.length <= 3 ? parts[1] : parts[parts.length - 3]
36
+ return { file, line: Number(lineStr) }
37
+ }
38
+
39
+ export interface LocateResult {
40
+ symbol: SymbolNode
41
+ callers: SymbolNode[]
42
+ callees: SymbolNode[]
43
+ }
44
+
45
+ // symbolAtLine → 按 uid 取 callers/callees(精确,非名字,绝不串别文件同名符号)。
46
+ export function locate(g: CallGraph, file: string, line: number): LocateResult | null {
47
+ const symbol = symbolAtLine(g, file, line)
48
+ if (!symbol)
49
+ return null
50
+ const byUid = (uids: string[] | undefined) => (uids ?? []).map(u => g.symbols[u]).filter(Boolean)
51
+ return {
52
+ symbol,
53
+ callers: byUid(g.in[symbol.uid]),
54
+ callees: byUid(g.out[symbol.uid]),
55
+ }
56
+ }
57
+
58
+ // CLI/JSON 渲染:locate 结果 → 文本。json 时直接 JSON.stringify。
59
+ // 主键 file:line(1-based) —— 两个渲染投影(文本/HTML)共用同一坐标口径。
60
+ const at = (s: SymbolNode) => `${s.filePath}:${s.startLine + 1}`
61
+
62
+ export function renderLocate(r: LocateResult, json = false): string {
63
+ if (json)
64
+ return JSON.stringify(r, null, 2)
65
+ const lines = [`${r.symbol.name} ${r.symbol.kind} ${at(r.symbol)}`]
66
+ if (r.callers.length) {
67
+ lines.push(`\nCallers (${r.callers.length}):`)
68
+ for (const c of r.callers)
69
+ lines.push(` ${c.name} ${at(c)}`)
70
+ }
71
+ if (r.callees.length) {
72
+ lines.push(`\nCallees (${r.callees.length}):`)
73
+ for (const c of r.callees)
74
+ lines.push(` ${c.name} ${at(c)}`)
75
+ }
76
+ if (!r.callers.length && !r.callees.length)
77
+ lines.push(' (no call edges found)')
78
+ return lines.join('\n')
79
+ }
80
+
81
+ // 反向态射的 HTML 投影 —— 浮层让人点页面元素直接看代码原因,且每个 caller/callee 带
82
+ // data-src-path 可继续点向上跳调用链(「直接映射」最后一跳的渲染端)。最瘦:只出结构 +
83
+ // 可点锚点,样式由浮层容器统一管。client-patch 内联一份镜像(不能 import),这里是真值,
84
+ // 那里靠 aihand 真页面验证镜像一致。esc 由调用方注入(浏览器用 textContent,Node 测用简单转义)。
85
+ export function sourcePanelHtml(r: LocateResult, esc: (s: string) => string = escHtml): string {
86
+ const row = (c: SymbolNode) => `<div class="aihand-src-edge" data-src-path="${esc(at(c))}">${esc(c.name)} <span class="aihand-src-at">${esc(at(c))}</span></div>`
87
+ const out = [`<div class="aihand-src-head">${esc(r.symbol.name)} <span class="aihand-src-kind">${esc(r.symbol.kind)}</span> <span class="aihand-src-at">${esc(at(r.symbol))}</span></div>`]
88
+ if (r.callers.length)
89
+ out.push(`<div class="aihand-src-sec">Callers (${r.callers.length})</div>`, ...r.callers.map(row))
90
+ if (r.callees.length)
91
+ out.push(`<div class="aihand-src-sec">Callees (${r.callees.length})</div>`, ...r.callees.map(row))
92
+ if (!r.callers.length && !r.callees.length)
93
+ out.push(`<div class="aihand-src-empty">(no call edges found)</div>`)
94
+ return out.join('')
95
+ }
96
+
97
+ // 禁正则:字符替换走 split/join 词法,不用 /g。顺序固定 & 先行(否则二次转义)。
98
+ function escHtml(s: string): string {
99
+ return s
100
+ .split('&').join('&amp;')
101
+ .split('<').join('&lt;')
102
+ .split('>').join('&gt;')
103
+ .split('"').join('&quot;')
104
+ }
@@ -0,0 +1,335 @@
1
+ // 控制面涌现 — 系统的控制论模型第一层(洗衣机面板)。
2
+ //
3
+ // 一个旋钮 = 一个 JSX 元素挂了 on* handler,且 handler 体里写了某个 store 字段。
4
+ // 拨这个旋钮 → 系统状态变。旋钮名来自同元素的 label/title(免费,无需 LLM)。
5
+ //
6
+ // 命门验证(FUSION.md §0):旋钮 ⋈ 运行时 /screen knobs 的 join 键是 `label + 文件`,
7
+ // 不是 `文件:行`(host 行 vs handler 行在组件边界处错位)。故 Knob 同时带 label 与坐标。
8
+ //
9
+ // store 识别:词法启发式——标识符以 `Store` 结尾且被 `X.field = …` 赋值。确定性、零正则、
10
+ // 零跨文件追踪。漏判的代价是少一个旋钮,不会误报(结尾约束 + 实际写约束双重门)。
11
+
12
+ import type { Language, Node } from 'web-tree-sitter'
13
+ import { readFileSync } from 'node:fs'
14
+ import { Parser, Query } from 'web-tree-sitter'
15
+ import { getLang } from './compact.js'
16
+
17
+ // 一次状态转移:拨旋钮把 field 推向 to。控制论里旋钮是态射 `状态 →[旋钮] 新状态`,
18
+ // field 是定义域、to 是值域。「AI 对话」→ mode='chat'、「群聊」→ mode='im' 由此区分。
19
+ export interface Transition {
20
+ field: string // store 字段
21
+ to: string // 目标值表达式('im' / !showSideBar / …)
22
+ args?: string // call 形态(field 以 '()' 结尾)的括号内实参原文;assignment 不填。
23
+ // 带参方法(setReply(msg))与无参方法(openPanel())不同命:无参可绿色通道直调,带参依赖运行时上下文必拒。
24
+ }
25
+
26
+ export interface Knob {
27
+ label: string | null // 旋钮名(label/title 文本),join 键之一;null = 仅图标无文本
28
+ tag: string // host 标签 / 组件名(<button> / NavItem)
29
+ event: string // onClick / onChange / onValueChange …
30
+ transitions: Transition[] // 拨它触发的状态转移(field → to);同 field 多写取末次
31
+ store: string // 写入的 store 实例名(appUIStore / imStore …)
32
+ filePath: string // join 键之一
33
+ line: number // handler 写 store 那行(1-based)
34
+ }
35
+
36
+ // 一个 JSX 属性:name + value。value 是 jsx_expression({...})。
37
+ const ATTR_QUERY = `
38
+ (jsx_attribute
39
+ (property_identifier) @name
40
+ (jsx_expression) @val) @attr
41
+ `
42
+
43
+ // 折叠多行赋值右侧的空白成单空格(逐字符扫描,禁正则)。
44
+ function collapseWs(s: string): string {
45
+ const tokens: string[] = []
46
+ let cur = ''
47
+ for (const ch of s) {
48
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
49
+ if (cur) { tokens.push(cur); cur = '' }
50
+ }
51
+ else { cur += ch }
52
+ }
53
+ if (cur) tokens.push(cur)
54
+ return tokens.join(' ')
55
+ }
56
+
57
+ // 一次 store 写入:store.field = to。to 是赋值右侧文本(转移目标)——
58
+ // 旋钮的态射值域:`mode = 'im'` 的 `'im'`、`showSideBar = !showSideBar` 的 `!showSideBar`。
59
+ interface StoreWrite {
60
+ store: string
61
+ field: string
62
+ to: string // 右侧表达式原文('im' / !showSideBar / x + 1 …);缩进折叠成单空格
63
+ args?: string // call 形态的实参原文(括号内);assignment 不填
64
+ }
65
+
66
+ // handler 体里所有 store 状态写入:两种形态都是「拨它→store 进新态」的旋钮,缺一则该旋钮静默丢失。
67
+ // ① `IDENT.field = …`(assignment 左侧 member,IDENT 以 Store 结尾)——抽右侧 = 转移目标。
68
+ // ② `IDENT.method(…)`(call 在 *Store 上)——方法体在别的文件改 store 字段(如 memoryStore.openPanel()
69
+ // → this.panelOpen=true)。目标字段藏在方法里、跨文件,但调用点已静态声明「这是 store 的状态转移」,
70
+ // 不做跨文件解析,把方法名当转移渲染成 `store.{ openPanel() }`——忠实且不编造它静态不可知的字段值。
71
+ // 不把 method 当 field 收进来,memory 面板这类旋钮就永远不在控制面里(投影自称「每个旋钮」却悄悄漏)。
72
+ function storeWritesIn(node: Node): StoreWrite[] {
73
+ const out: StoreWrite[] = []
74
+ const walk = (n: Node) => {
75
+ if (n.type === 'assignment_expression') {
76
+ const left = n.childForFieldName('left')
77
+ const right = n.childForFieldName('right')
78
+ if (left?.type === 'member_expression') {
79
+ const obj = left.childForFieldName('object')
80
+ const prop = left.childForFieldName('property')
81
+ if (obj?.type === 'identifier' && obj.text.endsWith('Store') && prop)
82
+ out.push({ store: obj.text, field: prop.text, to: collapseWs(right?.text ?? '') })
83
+ }
84
+ }
85
+ else if (n.type === 'call_expression') {
86
+ const fn = n.childForFieldName('function')
87
+ if (fn?.type === 'member_expression') {
88
+ const obj = fn.childForFieldName('object')
89
+ const method = fn.childForFieldName('property')
90
+ if (obj?.type === 'identifier' && obj.text.endsWith('Store') && method) {
91
+ const argsNode = n.childForFieldName('arguments') // (a, b) 含括号
92
+ const inner = argsNode ? collapseWs(argsNode.text).slice(1, -1).trim() : ''
93
+ out.push({ store: obj.text, field: `${method.text}()`, to: '', args: inner })
94
+ }
95
+ }
96
+ }
97
+ for (let i = 0; i < n.namedChildCount; i++) walk(n.namedChild(i)!)
98
+ }
99
+ walk(node)
100
+ return out
101
+ }
102
+
103
+ // 在 JSX 开标签里找 label/title 属性的文本(含三元 {a ? '收起' : '展开'} 取首个 string)。
104
+ function labelOf(opening: Node): string | null {
105
+ for (let i = 0; i < opening.namedChildCount; i++) {
106
+ const a = opening.namedChild(i)!
107
+ if (a.type !== 'jsx_attribute') continue
108
+ if (a.namedChild(0)?.text !== 'label' && a.namedChild(0)?.text !== 'title') continue
109
+ const val = a.namedChild(1)
110
+ if (!val) continue
111
+ if (val.type === 'string') return val.text.slice(1, -1)
112
+ let found: string | null = null
113
+ const dig = (n: Node) => {
114
+ if (found) return
115
+ if (n.type === 'string') { found = n.text.slice(1, -1); return }
116
+ for (let j = 0; j < n.namedChildCount; j++) dig(n.namedChild(j)!)
117
+ }
118
+ dig(val)
119
+ if (found) return found
120
+ }
121
+ // 无 label/title 属性 → 退回 JSX 子节点的直接文本(如 <button>全部</button> 的「全部」)。
122
+ // join 键的运行时一侧(DOM elementLabel)读的就是可见文本,故静态侧也必须以文本为准,
123
+ // 否则文本内容型按钮的 label 恒为 null,与 live DOM 的文本 label 永不相交。
124
+ const element = opening.parent // jsx_element(opening 的父),其下有 jsx_text 子节点
125
+ if (element?.type === 'jsx_element') {
126
+ const parts: string[] = []
127
+ for (let i = 0; i < element.namedChildCount; i++) {
128
+ const child = element.namedChild(i)!
129
+ if (child.type === 'jsx_text') {
130
+ const t = collapseWs(child.text).trim()
131
+ if (t) parts.push(t)
132
+ }
133
+ }
134
+ if (parts.length) return parts.join(' ')
135
+ }
136
+ return null
137
+ }
138
+
139
+ // 纯核心:一个文件的源码 → 它声明的所有旋钮。可单测、可 fixture。
140
+ export function extractKnobs(filePath: string, code: string, lang: Language): Knob[] {
141
+ const parser = new Parser()
142
+ parser.setLanguage(lang)
143
+ const tree = parser.parse(code)
144
+ if (!tree) return []
145
+
146
+ const knobs: Knob[] = []
147
+ for (const c of new Query(lang, ATTR_QUERY).captures(tree.rootNode)) {
148
+ if (c.name !== 'attr') continue
149
+ const event = c.node.namedChild(0)?.text
150
+ if (!event || !event.startsWith('on')) continue
151
+ const writes = storeWritesIn(c.node)
152
+ if (!writes.length) continue // handler 没碰 store → 不是状态旋钮(导航等不算)
153
+
154
+ const opening = c.node.parent! // jsx_opening_element / jsx_self_closing_element
155
+ // 同一 field 多次写取末次(顺序执行的终态);保序去重。
156
+ const byField = new Map<string, { to: string, args?: string }>()
157
+ for (const w of writes) byField.set(w.field, { to: w.to, args: w.args })
158
+ const transitions: Transition[] = [...byField].map(([field, v]) =>
159
+ v.args !== undefined ? { field, to: v.to, args: v.args } : { field, to: v.to })
160
+ knobs.push({
161
+ label: labelOf(opening),
162
+ tag: opening.childForFieldName('name')?.text ?? '?',
163
+ event,
164
+ transitions,
165
+ store: writes[0].store,
166
+ filePath,
167
+ line: c.node.startPosition.row + 1,
168
+ })
169
+ }
170
+ return knobs
171
+ }
172
+
173
+ // 转移渲染:field=to(toggle 等 to 自明时省略,只显 field)。`mode='im'` / `showSideBar=!showSideBar`。
174
+ export function fmtTransitions(ts: Transition[]): string {
175
+ return ts.map(t => (t.to ? `${t.field}=${t.to}` : t.field)).join(', ')
176
+ }
177
+
178
+ // 注入用紧凑渲染:控制面 → markdown 文本,按文件分组。一行一个旋钮:
179
+ // 「名字」 event → store.{ field=to } :line
180
+ // 这是系统的控制论模型——AI 读它即知"这 App 能干什么、拨它把哪个状态推向什么、改它去哪"。
181
+ export function formatPanel(knobs: Knob[]): string {
182
+ const byFile = new Map<string, Knob[]>()
183
+ for (const k of knobs) {
184
+ const arr = byFile.get(k.filePath) ?? []
185
+ arr.push(k)
186
+ byFile.set(k.filePath, arr)
187
+ }
188
+ const lines: string[] = []
189
+ for (const [file, ks] of byFile) {
190
+ lines.push(file)
191
+ for (const k of ks) {
192
+ const name = k.label ? `「${k.label}」` : `<${k.tag}>`
193
+ lines.push(` ${name} ${k.event} → ${k.store}.{ ${fmtTransitions(k.transitions)} } :${k.line}`)
194
+ }
195
+ }
196
+ return lines.join('\n')
197
+ }
198
+
199
+ // ───────────────────────────────────────────────────────────────────────────
200
+ // 态射分类:把一个 Transition 的值域 `to` 判成可执行算子的种类。
201
+ // 绿色通道(executeKnob)按 kind 分派;context 类诚实拒绝,绝不假装能字面 replay。
202
+ // 逐字符 / `includes` 子串判定,禁正则(本仓铁律,同 collapseWs)。
203
+
204
+ export type KnobKind = 'assign' | 'toggle' | 'call' | 'context'
205
+ export type KnobArity = 'nullary' | 'param'
206
+
207
+ export interface KnobOp {
208
+ field: string // store 字段(call 形态含尾 '()')
209
+ kind: KnobKind
210
+ arity: KnobArity
211
+ to?: string // assign/nullary 的字面值原文;assign/param 留空(值由运行时 ?value= 填)
212
+ reason?: string // context 类:为何不可字面 replay(给 AI 看的拒绝理由)
213
+ }
214
+
215
+ // 标识符字符:字母/数字/下划线/$。成员路径只含这些 + '.'。
216
+ function isIdentChar(ch: string): boolean {
217
+ return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
218
+ || (ch >= '0' && ch <= '9') || ch === '_' || ch === '$'
219
+ }
220
+
221
+ // to 是不是自洽字面量:true/false/null 整串 / 引号配对字符串 / 纯数字(含负号小数)。
222
+ function isLiteral(to: string): boolean {
223
+ if (to === 'true' || to === 'false' || to === 'null') return true
224
+ if (to.length >= 2) {
225
+ const q = to[0]
226
+ if ((q === '\'' || q === '"' || q === '`') && to[to.length - 1] === q) {
227
+ // 中间不能再有同种未转义引号(排除 `a' + x + 'b` 拼接)
228
+ for (let i = 1; i < to.length - 1; i++) {
229
+ if (to[i] === q && to[i - 1] !== '\\') return false
230
+ }
231
+ return true
232
+ }
233
+ }
234
+ // 纯数字:可选前导 -,至多一个 .
235
+ let dot = 0
236
+ for (let i = 0; i < to.length; i++) {
237
+ const ch = to[i]
238
+ if (ch === '-' && i === 0) continue
239
+ if (ch === '.') { dot++; if (dot > 1) return false; continue }
240
+ if (ch < '0' || ch > '9') return false
241
+ }
242
+ return to.length > 0 && to !== '-' && to !== '.'
243
+ }
244
+
245
+ // 单一成员路径:只含标识符字符与 '.',非空,不以 '.' 开头/结尾,无连续 '.'。
246
+ function isMemberPath(s: string): boolean {
247
+ if (!s || s[0] === '.' || s[s.length - 1] === '.') return false
248
+ let prevDot = false
249
+ for (const ch of s) {
250
+ if (ch === '.') { if (prevDot) return false; prevDot = true; continue }
251
+ if (!isIdentChar(ch)) return false
252
+ prevDot = false
253
+ }
254
+ return true
255
+ }
256
+
257
+ // to 是 onChange 回调参数占位符:单段/双段成员路径,段数≤2,首段不以 Store 结尾。
258
+ // `v` / `checked` / `provider.id` / `e2.target` 里 `e2.target` 会被 context 的 e.target 先截走。
259
+ function isPlaceholder(to: string): boolean {
260
+ if (!isMemberPath(to)) return false
261
+ const segs = to.split('.')
262
+ if (segs.length > 2) return false
263
+ if (segs[0].endsWith('Store')) return false // store 读(appUIStore.x)不是回调参数
264
+ return true
265
+ }
266
+
267
+ // 末段字段名:'!appUIStore.showGroupInfo' → 'showGroupInfo','!show' → 'show'。
268
+ function lastSeg(path: string): string {
269
+ const i = path.lastIndexOf('.')
270
+ return i < 0 ? path : path.slice(i + 1)
271
+ }
272
+
273
+ export function classifyTransition(t: Transition): KnobOp {
274
+ const field = t.field
275
+ const to = t.to
276
+
277
+ // ① call 形态:field 以 '()' 结尾。无参 → 可绿色通道直调;带参 → 依赖运行时上下文,拒。
278
+ if (field.endsWith('()')) {
279
+ if (t.args && t.args.length > 0)
280
+ return { field, kind: 'context', arity: 'nullary', reason: `方法带参 ${field.slice(0, -2)}(${t.args}),实参依赖运行时上下文` }
281
+ return { field, kind: 'call', arity: 'nullary' }
282
+ }
283
+
284
+ // ② 字面量赋值:store[field] = parseLiteral(to)。
285
+ if (isLiteral(to)) return { field, kind: 'assign', arity: 'nullary', to }
286
+
287
+ // ③ toggle:to 形如 !memberPath。
288
+ if (to.startsWith('!')) {
289
+ const inner = to.slice(1)
290
+ if (isMemberPath(inner)) return { field: lastSeg(inner), kind: 'toggle', arity: 'nullary' }
291
+ }
292
+
293
+ // ④ 回调参数占位符:值由运行时 ?value= 注入。
294
+ if (isPlaceholder(to)) return { field, kind: 'assign', arity: 'param' }
295
+
296
+ // ⑤ 其余依赖运行时上下文,诚实拒绝。reason 命中具体特征。
297
+ // 顺序按「特征具体度」从高到低:数组运算(含内联 => )比裸箭头函数更具体,先判,否则
298
+ // `items.filter(x => x.on)` 会被裸 `=>` 截成「箭头函数」误读。
299
+ let reason = '依赖运行时上下文'
300
+ if (to.includes('e.target') || to.includes('.target.')) reason = '依赖事件对象 e.target'
301
+ else if (to.includes('.filter(') || to.includes('.map(') || to.includes('.find(')) reason = '数组运算,依赖当前集合'
302
+ else if (to.includes('...')) reason = '展开运算符,依赖当前快照'
303
+ else if (to.includes('=>')) reason = '值是箭头函数/闭包'
304
+ else if (to.includes('?') && to.includes(':')) reason = '三元表达式,依赖运行时分支'
305
+ else if (to.includes('.message') || to.includes('.id') || to.includes('.name')) reason = '读运行时对象属性'
306
+ return { field, kind: 'context', arity: 'nullary', reason }
307
+ }
308
+
309
+ // 整个旋钮(可能多 transition)的综合判定:任一 context → 不可执行;任一 param → 整体需参。
310
+ export function classifyKnob(transitions: Transition[]): { ops: KnobOp[], arity: KnobArity, executable: boolean } {
311
+ const ops = transitions.map(classifyTransition)
312
+ const executable = ops.length > 0 && ops.every(o => o.kind !== 'context')
313
+ const arity: KnobArity = ops.some(o => o.arity === 'param') ? 'param' : 'nullary'
314
+ return { ops, arity, executable }
315
+ }
316
+
317
+ // 全仓聚合:一批文件 → 整个 App 的控制面。
318
+ export async function buildPanel(filePaths: string[], contents?: Map<string, string>): Promise<Knob[]> {
319
+ await Parser.init()
320
+ const tsx = await getLang('.tsx')
321
+ if (!tsx) return []
322
+
323
+ const panel: Knob[] = []
324
+ for (const fp of filePaths) {
325
+ if (!fp.endsWith('.tsx') && !fp.endsWith('.jsx')) continue
326
+ let code: string
327
+ if (contents?.has(fp)) code = contents.get(fp)!
328
+ else {
329
+ try { code = readFileSync(fp, 'utf-8') }
330
+ catch { continue }
331
+ }
332
+ panel.push(...extractKnobs(fp, code, tsx))
333
+ }
334
+ return panel
335
+ }
@@ -0,0 +1,78 @@
1
+ import type { FileBlock, FileDetailLevel } from './types.js'
2
+ import ignore from 'ignore'
3
+
4
+ export const estimateTokens = (s: string) => Math.ceil(s.length / 4)
5
+
6
+ /**
7
+ * Sum estimated tokens of all non-empty root-file contents. Used in budget + display.
8
+ * Trims content first to match formatOverview's normalization (otherwise the two
9
+ * disagree on size for trailing-newline content, and budget math drifts).
10
+ */
11
+ export function computeOverviewTokens(rootBlocks: FileBlock[]): number {
12
+ return rootBlocks.reduce((sum, b) => sum + estimateTokens(b.content.trim()), 0)
13
+ }
14
+
15
+ /**
16
+ * Budget for the signatures section.
17
+ *
18
+ * allocate() internally accounts for per-file tree entries (TREE_ENTRY_TOKENS×N),
19
+ * so we MUST NOT subtract the rendered src tree tokens here — that would double-count.
20
+ * We only subtract the static parts: overview + root file tree lines + a small header.
21
+ */
22
+ export function computeSignaturesBudget(
23
+ maxTokens: number,
24
+ overviewTokens: number,
25
+ rootFileCount: number,
26
+ headerTokens = 30,
27
+ ): number {
28
+ const rootTreeTokens = rootFileCount * 4 // approx tokens per root file tree line
29
+ return maxTokens - overviewTokens - rootTreeTokens - headerTokens
30
+ }
31
+
32
+ /**
33
+ * Files explicitly named by user via fileDetailLevel globs.
34
+ * allocate() must not demote these — user intent is a hard constraint.
35
+ */
36
+ export function computePinnedSet(
37
+ filePaths: string[],
38
+ fileDetailLevel: Record<string, FileDetailLevel>,
39
+ ): Set<string> {
40
+ const globs = Object.keys(fileDetailLevel)
41
+ if (!globs.length)
42
+ return new Set()
43
+ const matcher = ignore().add(globs)
44
+ return new Set(filePaths.filter(p => matcher.ignores(p)))
45
+ }
46
+
47
+ /** Filter src blocks to those whose display level is 'compact' — goes into signatures section. */
48
+ export function selectSignatureBlocks(
49
+ srcBlocks: FileBlock[],
50
+ classifyDisplay: (path: string) => FileDetailLevel,
51
+ ): FileBlock[] {
52
+ return srcBlocks.filter(b => classifyDisplay(b.path) === 'compact')
53
+ }
54
+
55
+ /** Build raw 'full'-level blocks from paths whose display level is 'full'. */
56
+ export function selectFullBlocks(
57
+ paths: string[],
58
+ contents: Map<string, string>,
59
+ classifyDisplay: (path: string) => FileDetailLevel,
60
+ ): FileBlock[] {
61
+ return paths
62
+ .filter(p => classifyDisplay(p) === 'full' && contents.get(p)?.trim())
63
+ .map(p => ({ path: p, level: 'full' as const, content: contents.get(p)! }))
64
+ }
65
+
66
+ /** Format root blocks into the overview section text. */
67
+ export function formatOverview(blocks: FileBlock[]): string {
68
+ return blocks
69
+ .map(b => ({ path: b.path, content: b.content.trim() }))
70
+ .filter(b => b.content)
71
+ .map((b) => {
72
+ const lines = b.content.split('\n')
73
+ return lines.length > 1
74
+ ? `${b.path}:\n ${lines.join('\n ')}`
75
+ : `${b.path}: ${b.content}`
76
+ })
77
+ .join('\n')
78
+ }