@falconwry/creator 0.36.0-alpha.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/package.json +19 -0
- package/src/ai-analyzer.ts +331 -0
- package/src/analyzer.ts +154 -0
- package/src/cli.ts +26 -0
- package/src/index.ts +16 -0
- package/src/logger.ts +58 -0
- package/src/pipeline.ts +130 -0
- package/src/types.ts +74 -0
- package/tsconfig.json +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@falconwry/creator",
|
|
3
|
+
"version": "0.36.0-alpha.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"typecheck": "tsc --noEmit",
|
|
9
|
+
"dev": "tsx src/cli.ts"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@falconwry/scanner-vue": "workspace:*",
|
|
13
|
+
"@opencode-ai/sdk": "^1.17.8"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"tsx": "^4.19.0",
|
|
17
|
+
"typescript": "^5.5.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import type { Analyzer, AnalyzedTool, RawTool, PromptContext, Scanner } from './types.js'
|
|
2
|
+
import type { Logger } from './logger.js'
|
|
3
|
+
import { RuleBasedAnalyzer } from './analyzer.js'
|
|
4
|
+
|
|
5
|
+
const MAX_RETRY = 3
|
|
6
|
+
const BATCH_SIZE = 15
|
|
7
|
+
const TIMEOUT_MS = 60_000
|
|
8
|
+
const MAX_FEEDBACK_LEN = 2000
|
|
9
|
+
|
|
10
|
+
function buildBatchPrompt(
|
|
11
|
+
batch: RawTool[],
|
|
12
|
+
context: PromptContext,
|
|
13
|
+
errorFeedback?: string,
|
|
14
|
+
): string {
|
|
15
|
+
let prompt = `你是 ${context.projectType} 的一个代码分析助手。
|
|
16
|
+
以下列出了该项目中多个封装好的 ${context.toolConcept},请为每个工具生成分析信息。
|
|
17
|
+
|
|
18
|
+
工具列表:
|
|
19
|
+
---
|
|
20
|
+
`
|
|
21
|
+
for (let i = 0; i < batch.length; i++) {
|
|
22
|
+
const tool = batch[i]
|
|
23
|
+
prompt += `序号: ${i + 1}
|
|
24
|
+
文件路径: ${tool.src}
|
|
25
|
+
行号: ${tool.startLine}-${tool.endLine}
|
|
26
|
+
代码:
|
|
27
|
+
\`\`\`
|
|
28
|
+
${tool.code}
|
|
29
|
+
\`\`\`
|
|
30
|
+
---
|
|
31
|
+
`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
prompt += `
|
|
35
|
+
请为每个工具返回一个 JSON 数组,每个元素包含以下字段:
|
|
36
|
+
1. name:工具名称(必须与上面给出的名称一致)
|
|
37
|
+
2. description:一句话概括该工具的功能(中文,50 字左右)
|
|
38
|
+
3. category:分类,必须是以下之一:${context.categories.join(' / ')}
|
|
39
|
+
4. tags:5 个左右的标签,如:${context.exampleTags.join('、')}
|
|
40
|
+
|
|
41
|
+
请严格返回 JSON 数组格式,不要包含任何其他文字。
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
if (errorFeedback) {
|
|
45
|
+
prompt += `\n注意:${errorFeedback}\n`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return prompt
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tryParseJSON(text: string): any | null {
|
|
52
|
+
// Try to extract JSON array from the response
|
|
53
|
+
const cleaned = text.trim()
|
|
54
|
+
.replace(/^```json\s*/i, '')
|
|
55
|
+
.replace(/^```\s*/i, '')
|
|
56
|
+
.replace(/\s*```$/, '')
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(cleaned)
|
|
60
|
+
} catch {
|
|
61
|
+
// Try to find a JSON array in the text
|
|
62
|
+
const match = cleaned.match(/\[\s*\{[\s\S]*\}\s*\]/)
|
|
63
|
+
if (match) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(match[0])
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateStructural(
|
|
75
|
+
output: any[],
|
|
76
|
+
batch: RawTool[],
|
|
77
|
+
): string[] {
|
|
78
|
+
const errors: string[] = []
|
|
79
|
+
|
|
80
|
+
if (!Array.isArray(output)) {
|
|
81
|
+
return ['Response is not a JSON array']
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (output.length !== batch.length) {
|
|
85
|
+
errors.push(`Expected ${batch.length} tools in response, got ${output.length}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < Math.min(output.length, batch.length); i++) {
|
|
89
|
+
const item = output[i]
|
|
90
|
+
const prefix = `Tool #${i + 1} ("${batch[i]?.name ?? '?'}"):`
|
|
91
|
+
|
|
92
|
+
if (!item.name) {
|
|
93
|
+
errors.push(`${prefix} missing "name" field`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!item.description || typeof item.description !== 'string') {
|
|
97
|
+
errors.push(`${prefix} missing or invalid "description" field`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!item.category || typeof item.category !== 'string') {
|
|
101
|
+
errors.push(`${prefix} missing "category" field`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!item.tags || !Array.isArray(item.tags)) {
|
|
105
|
+
errors.push(`${prefix} missing or invalid "tags" field`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return errors
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function truncateFeedback(text: string): string {
|
|
113
|
+
if (text.length <= MAX_FEEDBACK_LEN) return text
|
|
114
|
+
return text.slice(0, MAX_FEEDBACK_LEN - 3) + '...'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function analyzeBatch(
|
|
118
|
+
batch: RawTool[],
|
|
119
|
+
context: PromptContext,
|
|
120
|
+
scanner: Scanner,
|
|
121
|
+
log: Logger,
|
|
122
|
+
retryCount: number,
|
|
123
|
+
errorFeedback?: string,
|
|
124
|
+
): Promise<AnalyzedTool[]> {
|
|
125
|
+
const { createOpencodeClient } = await import('@opencode-ai/sdk')
|
|
126
|
+
|
|
127
|
+
const client = createOpencodeClient({ baseUrl: 'http://localhost:4096' } as any)
|
|
128
|
+
|
|
129
|
+
const sessionResult = await client.session.create({
|
|
130
|
+
body: { title: 'falconwry-analyze' },
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const sessionId = sessionResult.data?.id
|
|
134
|
+
if (!sessionId) {
|
|
135
|
+
throw new Error('Failed to create session')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const promptText = buildBatchPrompt(batch, context, errorFeedback)
|
|
140
|
+
const controller = new AbortController()
|
|
141
|
+
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
|
142
|
+
|
|
143
|
+
const result = await client.session.prompt({
|
|
144
|
+
path: { id: sessionId },
|
|
145
|
+
body: {
|
|
146
|
+
model: { providerID: 'deepseek', modelID: 'deepseek-v4-pro' },
|
|
147
|
+
parts: [
|
|
148
|
+
{
|
|
149
|
+
type: 'text',
|
|
150
|
+
text: promptText,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
clearTimeout(timeout)
|
|
157
|
+
|
|
158
|
+
// Extract text from response parts
|
|
159
|
+
const parts = result.data?.parts ?? []
|
|
160
|
+
const responseText = parts
|
|
161
|
+
.filter((p: any) => p.type === 'text')
|
|
162
|
+
.map((p: any) => p.text)
|
|
163
|
+
.join('\n')
|
|
164
|
+
|
|
165
|
+
const output = tryParseJSON(responseText)
|
|
166
|
+
if (!output || !Array.isArray(output)) {
|
|
167
|
+
log.warn('AI response was not valid JSON')
|
|
168
|
+
if (retryCount < MAX_RETRY) {
|
|
169
|
+
console.warn(` ⚠️ Batch parse failed, retrying (${retryCount + 1}/${MAX_RETRY})...`)
|
|
170
|
+
return analyzeBatch(
|
|
171
|
+
batch,
|
|
172
|
+
context,
|
|
173
|
+
scanner,
|
|
174
|
+
log,
|
|
175
|
+
retryCount + 1,
|
|
176
|
+
'Response was not valid JSON. Please return ONLY a JSON array.',
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
console.warn(` ❌ Batch failed after ${MAX_RETRY} retries, using rule-based fallback`)
|
|
180
|
+
log.error(`Batch failed after ${MAX_RETRY} retries: not valid JSON`)
|
|
181
|
+
return RuleBasedAnalyzer.analyze(batch, context)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Step 1: Structural validation (creator)
|
|
185
|
+
const structuralErrors = validateStructural(output, batch)
|
|
186
|
+
if (structuralErrors.length > 0) {
|
|
187
|
+
console.warn(` ⚠️ ${structuralErrors.length} structural error(s):`)
|
|
188
|
+
for (const e of structuralErrors.slice(0, 3)) {
|
|
189
|
+
console.warn(` - ${e}`)
|
|
190
|
+
}
|
|
191
|
+
log.warn(`Structural validation failed (${structuralErrors.length} errors):`)
|
|
192
|
+
for (const e of structuralErrors) {
|
|
193
|
+
log.warn(` ${e}`)
|
|
194
|
+
}
|
|
195
|
+
if (retryCount < MAX_RETRY) {
|
|
196
|
+
console.warn(` Retrying (${retryCount + 1}/${MAX_RETRY})...`)
|
|
197
|
+
return analyzeBatch(
|
|
198
|
+
batch, context, scanner, log,
|
|
199
|
+
retryCount + 1,
|
|
200
|
+
truncateFeedback(structuralErrors.join('; ')),
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
console.warn(` ❌ Structural validation failed after ${MAX_RETRY} retries, using rule-based fallback`)
|
|
204
|
+
log.error(`Structural validation failed after ${MAX_RETRY} retries`)
|
|
205
|
+
return RuleBasedAnalyzer.analyze(batch, context)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Step 2: Content validation (scanner per tool, creator iterates)
|
|
209
|
+
const rules = scanner.getValidationRules()
|
|
210
|
+
const contentErrors: string[] = []
|
|
211
|
+
|
|
212
|
+
for (const [i, item] of output.entries()) {
|
|
213
|
+
const tool = { ...batch[i], ...item }
|
|
214
|
+
const toolErrors = scanner.validateTool(tool, rules)
|
|
215
|
+
for (const e of toolErrors) {
|
|
216
|
+
contentErrors.push(`Tool #${i + 1} ("${tool.name}"): ${e}`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (contentErrors.length > 0) {
|
|
221
|
+
// Console: first 3 errors
|
|
222
|
+
console.warn(` ⚠️ ${contentErrors.length} validation error(s):`)
|
|
223
|
+
for (const e of contentErrors.slice(0, 3)) {
|
|
224
|
+
console.warn(` - ${e}`)
|
|
225
|
+
}
|
|
226
|
+
if (contentErrors.length > 3) {
|
|
227
|
+
console.warn(` ... and ${contentErrors.length - 3} more (see .falconry/log/creator.log)`)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Log: all errors
|
|
231
|
+
log.warn(`Content validation failed (${contentErrors.length} errors):`)
|
|
232
|
+
for (const e of contentErrors) {
|
|
233
|
+
log.warn(` ${e}`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (retryCount < MAX_RETRY) {
|
|
237
|
+
console.warn(` Retrying (${retryCount + 1}/${MAX_RETRY})...`)
|
|
238
|
+
return analyzeBatch(
|
|
239
|
+
batch, context, scanner, log,
|
|
240
|
+
retryCount + 1,
|
|
241
|
+
truncateFeedback(contentErrors.join('; ')),
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
console.warn(` ❌ Validation failed after ${MAX_RETRY} retries, using rule-based fallback`)
|
|
245
|
+
log.error(`Validation failed after ${MAX_RETRY} retries`)
|
|
246
|
+
return RuleBasedAnalyzer.analyze(batch, context)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return output.map((item: any, i: number) => ({
|
|
250
|
+
...batch[i],
|
|
251
|
+
description: item.description,
|
|
252
|
+
category: item.category,
|
|
253
|
+
tags: item.tags,
|
|
254
|
+
}))
|
|
255
|
+
} finally {
|
|
256
|
+
// Clean up session
|
|
257
|
+
try {
|
|
258
|
+
await client.session.delete({ path: { id: sessionId } })
|
|
259
|
+
} catch {
|
|
260
|
+
// Ignore cleanup errors
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function chunkArray<T>(arr: T[], size: number): T[][] {
|
|
266
|
+
const chunks: T[][] = []
|
|
267
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
268
|
+
chunks.push(arr.slice(i, i + size))
|
|
269
|
+
}
|
|
270
|
+
return chunks
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function createOpenCodeAnalyzer(scanner: Scanner, log: Logger): Analyzer {
|
|
274
|
+
return {
|
|
275
|
+
async analyze(tools: RawTool[], context: PromptContext): Promise<AnalyzedTool[]> {
|
|
276
|
+
if (tools.length === 0) return []
|
|
277
|
+
|
|
278
|
+
// Pre-flight: check if OpenCode server is reachable
|
|
279
|
+
try {
|
|
280
|
+
const { createOpencodeClient } = await import('@opencode-ai/sdk')
|
|
281
|
+
const client = createOpencodeClient({ baseUrl: 'http://localhost:4096' } as any)
|
|
282
|
+
await client.session.list()
|
|
283
|
+
} catch {
|
|
284
|
+
throw new Error(
|
|
285
|
+
'OpenCode server not available at localhost:4096. ' +
|
|
286
|
+
'Start the server or the analysis will fall back to rules.',
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const batches = chunkArray(tools, BATCH_SIZE)
|
|
291
|
+
const results: AnalyzedTool[] = []
|
|
292
|
+
|
|
293
|
+
log.info(`AI analysis started: ${tools.length} tools in ${batches.length} batch(es)`)
|
|
294
|
+
|
|
295
|
+
for (let i = 0; i < batches.length; i++) {
|
|
296
|
+
const batch = batches[i]
|
|
297
|
+
console.log(` 🧠 AI analyzing batch ${i + 1}/${batches.length} (${batch.length} tools)...`)
|
|
298
|
+
log.info(`Batch ${i + 1}/${batches.length}: ${batch.length} tools`)
|
|
299
|
+
try {
|
|
300
|
+
const analyzed = await analyzeBatch(batch, context, scanner, log, 0)
|
|
301
|
+
results.push(...analyzed)
|
|
302
|
+
log.info(`Batch ${i + 1} complete: ${analyzed.length} tools analyzed`)
|
|
303
|
+
} catch (err: any) {
|
|
304
|
+
console.warn(` ⚠️ Batch ${i + 1} failed: ${err.message}`)
|
|
305
|
+
console.warn(` ⬇ Falling back to rule-based analysis for this batch`)
|
|
306
|
+
log.warn(`Batch ${i + 1} failed: ${err.message}, falling back to rules`)
|
|
307
|
+
const fallback = await RuleBasedAnalyzer.analyze(batch, context)
|
|
308
|
+
results.push(...fallback)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
log.info(`AI analysis complete: ${results.length} tools`)
|
|
313
|
+
return results
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Legacy static export for compatibility
|
|
319
|
+
export const OpenCodeAnalyzer: Analyzer = createOpenCodeAnalyzer(
|
|
320
|
+
{
|
|
321
|
+
name: 'noop',
|
|
322
|
+
scanFiles: () => [],
|
|
323
|
+
scanRegistrations: () => [],
|
|
324
|
+
scanDependencies: () => ({}),
|
|
325
|
+
buildRegistry: () => [],
|
|
326
|
+
getPromptContext: () => ({ projectType: '', toolConcept: '', categories: [], exampleTags: [] }),
|
|
327
|
+
getValidationRules: () => ({ description: { minLength: 0, maxLength: 999 }, tags: { minCount: 0, maxCount: 99 }, nameMatch: 'loose', allowedCategories: [] }),
|
|
328
|
+
validateTool: () => [],
|
|
329
|
+
},
|
|
330
|
+
{ debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
331
|
+
)
|
package/src/analyzer.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { RawTool, AnalyzedTool, Analyzer, PromptContext } from './types.js'
|
|
2
|
+
|
|
3
|
+
const KEYWORD_MAP: Record<string, string> = {
|
|
4
|
+
date: '日期',
|
|
5
|
+
time: '时间',
|
|
6
|
+
format: '格式化',
|
|
7
|
+
currency: '货币',
|
|
8
|
+
number: '数字',
|
|
9
|
+
button: '按钮',
|
|
10
|
+
table: '表格',
|
|
11
|
+
modal: '弹窗',
|
|
12
|
+
dialog: '弹窗',
|
|
13
|
+
request: '请求',
|
|
14
|
+
fetch: '请求',
|
|
15
|
+
pagination: '分页',
|
|
16
|
+
page: '分页',
|
|
17
|
+
focus: '聚焦',
|
|
18
|
+
badge: '角标',
|
|
19
|
+
icon: '图标',
|
|
20
|
+
data: '数据',
|
|
21
|
+
base: '基础',
|
|
22
|
+
upload: '上传',
|
|
23
|
+
download: '下载',
|
|
24
|
+
select: '选择',
|
|
25
|
+
input: '输入',
|
|
26
|
+
form: '表单',
|
|
27
|
+
validate: '验证',
|
|
28
|
+
toast: '提示',
|
|
29
|
+
loading: '加载',
|
|
30
|
+
error: '错误',
|
|
31
|
+
search: '搜索',
|
|
32
|
+
filter: '过滤',
|
|
33
|
+
sort: '排序',
|
|
34
|
+
copy: '复制',
|
|
35
|
+
clipboard: '剪贴板',
|
|
36
|
+
storage: '存储',
|
|
37
|
+
cookie: 'Cookie',
|
|
38
|
+
router: '路由',
|
|
39
|
+
auth: '认证',
|
|
40
|
+
permission: '权限',
|
|
41
|
+
user: '用户',
|
|
42
|
+
list: '列表',
|
|
43
|
+
card: '卡片',
|
|
44
|
+
avatar: '头像',
|
|
45
|
+
progress: '进度',
|
|
46
|
+
tooltip: '提示',
|
|
47
|
+
dropdown: '下拉',
|
|
48
|
+
menu: '菜单',
|
|
49
|
+
nav: '导航',
|
|
50
|
+
tab: '标签页',
|
|
51
|
+
tree: '树',
|
|
52
|
+
chart: '图表',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CATEGORY_SUFFIX: Record<string, string> = {
|
|
56
|
+
component: '组件',
|
|
57
|
+
composable: '组合式函数',
|
|
58
|
+
function: '工具',
|
|
59
|
+
directive: '指令',
|
|
60
|
+
mixin: '混入',
|
|
61
|
+
plugin: '插件',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const COMMON_PREFIXES = new Set([
|
|
65
|
+
'use', 'v', 'on', 'handle', 'get', 'set', 'is', 'has', 'create',
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
function splitCamelCase(name: string): string[] {
|
|
69
|
+
const words: string[] = []
|
|
70
|
+
let current = ''
|
|
71
|
+
for (const ch of name) {
|
|
72
|
+
if (ch >= 'A' && ch <= 'Z') {
|
|
73
|
+
if (current) words.push(current)
|
|
74
|
+
current = ch
|
|
75
|
+
} else {
|
|
76
|
+
current += ch
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (current) words.push(current)
|
|
80
|
+
return words
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function wordsToChinese(words: string[]): string[] {
|
|
84
|
+
const result: string[] = []
|
|
85
|
+
for (const w of words) {
|
|
86
|
+
const lower = w.toLowerCase()
|
|
87
|
+
const cn = KEYWORD_MAP[lower]
|
|
88
|
+
if (cn) {
|
|
89
|
+
// Avoid consecutive duplicates (e.g., "Modal" + "Dialog" → only one "弹窗")
|
|
90
|
+
if (result.length > 0 && result[result.length - 1] === cn) continue
|
|
91
|
+
result.push(cn)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function generateDescription(name: string, category: string, src: string): string {
|
|
98
|
+
const rawWords = splitCamelCase(name)
|
|
99
|
+
const meaningful = rawWords.filter((w) => !COMMON_PREFIXES.has(w))
|
|
100
|
+
const chineseWords = wordsToChinese(meaningful)
|
|
101
|
+
|
|
102
|
+
// For components, use the raw words if no Chinese mapping found
|
|
103
|
+
const suffix = CATEGORY_SUFFIX[category] || ''
|
|
104
|
+
if (chineseWords.length === 0) {
|
|
105
|
+
const fallback = meaningful.join('')
|
|
106
|
+
return fallback ? `${fallback}${suffix}` : name
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return chineseWords.join('') + suffix
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function generateTags(name: string, src: string): string[] {
|
|
113
|
+
const rawWords = splitCamelCase(name)
|
|
114
|
+
const meaningful = rawWords.filter((w) => !COMMON_PREFIXES.has(w))
|
|
115
|
+
const chineseWords = wordsToChinese(meaningful)
|
|
116
|
+
|
|
117
|
+
// Also add tags from the directory name
|
|
118
|
+
const dirParts = src.split('/')
|
|
119
|
+
if (dirParts.length >= 2) {
|
|
120
|
+
const parentDir = dirParts[dirParts.length - 2]
|
|
121
|
+
const dirTag = KEYWORD_MAP[parentDir.toLowerCase()]
|
|
122
|
+
if (dirTag && !chineseWords.includes(dirTag)) {
|
|
123
|
+
chineseWords.push(dirTag)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return chineseWords.length > 0 ? chineseWords : [name]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function inferCategory(name: string, src: string): string {
|
|
131
|
+
if (src.endsWith('.vue')) return 'component'
|
|
132
|
+
if (/^use[A-Z]/.test(name)) return 'composable'
|
|
133
|
+
if (/^v[A-Z]/.test(name)) return 'directive'
|
|
134
|
+
if (/mixin$/i.test(name)) return 'mixin'
|
|
135
|
+
if (src.includes('/directives/')) return 'directive'
|
|
136
|
+
if (src.includes('/composables/')) return 'composable'
|
|
137
|
+
if (src.includes('/mixins/')) return 'mixin'
|
|
138
|
+
if (src.includes('/components/')) return 'component'
|
|
139
|
+
return 'function'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const RuleBasedAnalyzer: Analyzer = {
|
|
143
|
+
async analyze(tools: RawTool[], _context: PromptContext): Promise<AnalyzedTool[]> {
|
|
144
|
+
return tools.map((tool) => {
|
|
145
|
+
const category = inferCategory(tool.name, tool.src)
|
|
146
|
+
return {
|
|
147
|
+
...tool,
|
|
148
|
+
category,
|
|
149
|
+
description: generateDescription(tool.name, category, tool.src),
|
|
150
|
+
tags: generateTags(tool.name, tool.src),
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
},
|
|
154
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runPipeline } from './pipeline.js'
|
|
3
|
+
|
|
4
|
+
const args = process.argv.slice(2)
|
|
5
|
+
const cwdIndex = args.indexOf('--cwd')
|
|
6
|
+
const cwd = cwdIndex !== -1 ? args[cwdIndex + 1] : process.cwd()
|
|
7
|
+
|
|
8
|
+
console.log('🔍 Falconwry Creator v0.36.0-alpha.1')
|
|
9
|
+
console.log(`📦 Scanner: @falconwry/scanner-vue`)
|
|
10
|
+
console.log(`📂 Project: ${cwd}\n`)
|
|
11
|
+
|
|
12
|
+
runPipeline(cwd)
|
|
13
|
+
.then(({ stats }) => {
|
|
14
|
+
console.log(`📂 File scan: ${stats.fileTools} tools`)
|
|
15
|
+
console.log(`🔗 Registrations: ${stats.regTools} tools`)
|
|
16
|
+
console.log(`📎 Dependencies: ${stats.depsEntries} resolved`)
|
|
17
|
+
console.log(`🔀 Merged: ${stats.merged}`)
|
|
18
|
+
console.log(`🔬 Filtered: ${stats.filtered}`)
|
|
19
|
+
console.log(`📝 Registry: ${stats.registry} entries`)
|
|
20
|
+
console.log(`✅ Done in ${stats.elapsed}ms`)
|
|
21
|
+
console.log(`📄 Output: .falconry/map.json`)
|
|
22
|
+
})
|
|
23
|
+
.catch((err) => {
|
|
24
|
+
console.error('❌ Error:', err.message)
|
|
25
|
+
process.exit(1)
|
|
26
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
RawTool,
|
|
3
|
+
AnalyzedTool,
|
|
4
|
+
BaseRegistry,
|
|
5
|
+
FrontendRegistry,
|
|
6
|
+
DependentsMap,
|
|
7
|
+
ValidationRules,
|
|
8
|
+
Scanner,
|
|
9
|
+
Analyzer,
|
|
10
|
+
PromptContext,
|
|
11
|
+
} from './types.js'
|
|
12
|
+
|
|
13
|
+
export { RuleBasedAnalyzer } from './analyzer.js'
|
|
14
|
+
export { OpenCodeAnalyzer, createOpenCodeAnalyzer } from './ai-analyzer.js'
|
|
15
|
+
export { runPipeline } from './pipeline.js'
|
|
16
|
+
export type { PipelineStats } from './pipeline.js'
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as nodePath from 'path'
|
|
3
|
+
|
|
4
|
+
export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'
|
|
5
|
+
|
|
6
|
+
const NAME = 'creator'
|
|
7
|
+
|
|
8
|
+
const LEVEL_ORDER: Record<LogLevel, number> = {
|
|
9
|
+
DEBUG: 0,
|
|
10
|
+
INFO: 1,
|
|
11
|
+
WARN: 2,
|
|
12
|
+
ERROR: 3,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveLevel(minLevel?: LogLevel): number {
|
|
16
|
+
const env = (process.env.FALCONRY_LOG_LEVEL?.toUpperCase() || 'INFO') as LogLevel
|
|
17
|
+
const threshold = LEVEL_ORDER[minLevel || env]
|
|
18
|
+
return threshold !== undefined ? threshold : LEVEL_ORDER.INFO
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ensureLogDir(projectRoot: string): string {
|
|
22
|
+
const dir = nodePath.join(projectRoot, '.falconry', 'log')
|
|
23
|
+
if (!fs.existsSync(dir)) {
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
25
|
+
}
|
|
26
|
+
return dir
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeLine(dir: string, file: string, line: string) {
|
|
30
|
+
try {
|
|
31
|
+
fs.appendFileSync(nodePath.join(dir, file), line + '\n', 'utf-8')
|
|
32
|
+
} catch {
|
|
33
|
+
// Silently ignore write failures
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createLogger(projectRoot: string, minLevel?: LogLevel) {
|
|
38
|
+
const dir = ensureLogDir(projectRoot)
|
|
39
|
+
const threshold = resolveLevel(minLevel)
|
|
40
|
+
const levels = Object.keys(LEVEL_ORDER)
|
|
41
|
+
|
|
42
|
+
function log(level: LogLevel, msg: string) {
|
|
43
|
+
if (LEVEL_ORDER[level] < threshold) return
|
|
44
|
+
const ts = new Date().toISOString()
|
|
45
|
+
const line = `${ts} [${level.padEnd(5)}] [${NAME}] ${msg}`
|
|
46
|
+
writeLine(dir, `${NAME}.log`, line)
|
|
47
|
+
writeLine(dir, 'all.log', line)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
debug: (msg: string) => log('DEBUG', msg),
|
|
52
|
+
info: (msg: string) => log('INFO', msg),
|
|
53
|
+
warn: (msg: string) => log('WARN', msg),
|
|
54
|
+
error: (msg: string) => log('ERROR', msg),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type Logger = ReturnType<typeof createLogger>
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as nodePath from 'path'
|
|
3
|
+
import { VueScanner } from '@falconwry/scanner-vue'
|
|
4
|
+
import { RuleBasedAnalyzer } from './analyzer.js'
|
|
5
|
+
import { createOpenCodeAnalyzer } from './ai-analyzer.js'
|
|
6
|
+
import { createLogger } from './logger.js'
|
|
7
|
+
import type { RawTool, AnalyzedTool, BaseRegistry, DependentsMap, Scanner } from './types.js'
|
|
8
|
+
|
|
9
|
+
const EXCLUDE_NAMES = new Set([
|
|
10
|
+
'App',
|
|
11
|
+
'main',
|
|
12
|
+
'createApp',
|
|
13
|
+
'default',
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
function filterNonTools(tools: RawTool[]): RawTool[] {
|
|
17
|
+
return tools.filter((tool) => {
|
|
18
|
+
if (EXCLUDE_NAMES.has(tool.name)) return false
|
|
19
|
+
// Filter out type-only exports (no real code)
|
|
20
|
+
if (tool.code.trim().startsWith('type ') || tool.code.trim().startsWith('interface ')) return false
|
|
21
|
+
// Filter out empty or tiny exports
|
|
22
|
+
if (tool.endLine - tool.startLine < 1) return false
|
|
23
|
+
return true
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mergeAndDedupe(fileTools: RawTool[], regTools: RawTool[]): RawTool[] {
|
|
28
|
+
const seen = new Set<string>()
|
|
29
|
+
const merged: RawTool[] = []
|
|
30
|
+
|
|
31
|
+
for (const tool of fileTools) {
|
|
32
|
+
const key = `${tool.src}:${tool.name}`
|
|
33
|
+
if (!seen.has(key)) {
|
|
34
|
+
seen.add(key)
|
|
35
|
+
merged.push(tool)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const tool of regTools) {
|
|
40
|
+
const key = `${tool.src}:${tool.name}`
|
|
41
|
+
if (!seen.has(key)) {
|
|
42
|
+
seen.add(key)
|
|
43
|
+
merged.push(tool)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return merged
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function runPipeline(
|
|
51
|
+
projectRoot: string,
|
|
52
|
+
scanner: Scanner = VueScanner,
|
|
53
|
+
): Promise<{ registry: BaseRegistry[]; stats: PipelineStats }> {
|
|
54
|
+
const log = createLogger(projectRoot)
|
|
55
|
+
const startTime = Date.now()
|
|
56
|
+
|
|
57
|
+
log.info('Pipeline started')
|
|
58
|
+
log.info(`Project: ${projectRoot}`)
|
|
59
|
+
log.info(`Scanner: ${scanner.name}`)
|
|
60
|
+
|
|
61
|
+
// Step 1+2: Scan files and registrations
|
|
62
|
+
log.info('Step 1: Scanning files...')
|
|
63
|
+
const t1 = Date.now()
|
|
64
|
+
const fileTools = scanner.scanFiles(projectRoot)
|
|
65
|
+
const regTools = scanner.scanRegistrations(projectRoot)
|
|
66
|
+
const deps: DependentsMap = scanner.scanDependencies(projectRoot)
|
|
67
|
+
log.info(`File scan complete: ${fileTools.length} tools (${Date.now() - t1}ms)`)
|
|
68
|
+
log.info(`Registration scan complete: ${regTools.length} registrations`)
|
|
69
|
+
log.info(`Dependency scan complete: ${Object.keys(deps).length} resolved`)
|
|
70
|
+
|
|
71
|
+
// Step 3: Merge and filter
|
|
72
|
+
const merged = mergeAndDedupe(fileTools, regTools)
|
|
73
|
+
const filtered = filterNonTools(merged)
|
|
74
|
+
log.info(`Merge: ${fileTools.length} + ${regTools.length} → ${merged.length} (filtered to ${filtered.length})`)
|
|
75
|
+
|
|
76
|
+
// Step 4: Analyze (AI first, fallback to rule-based)
|
|
77
|
+
const context = scanner.getPromptContext(projectRoot)
|
|
78
|
+
log.info(`Context: ${context.projectType}`)
|
|
79
|
+
let analyzed: AnalyzedTool[]
|
|
80
|
+
const t4 = Date.now()
|
|
81
|
+
try {
|
|
82
|
+
log.info('Analysis: trying AI via OpenCode server...')
|
|
83
|
+
analyzed = await createOpenCodeAnalyzer(scanner, log).analyze(filtered, context)
|
|
84
|
+
log.info(`AI analysis complete: ${analyzed.length} tools (${Date.now() - t4}ms)`)
|
|
85
|
+
} catch {
|
|
86
|
+
console.warn('⚠️ OpenCode server not available, falling back to rule-based analysis')
|
|
87
|
+
log.warn('OpenCode server not available, falling back to rule-based analysis')
|
|
88
|
+
analyzed = await RuleBasedAnalyzer.analyze(filtered, context)
|
|
89
|
+
log.info(`Rule-based analysis complete: ${analyzed.length} tools (${Date.now() - t4}ms)`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Step 5: Build registry
|
|
93
|
+
const registry = scanner.buildRegistry(analyzed, deps)
|
|
94
|
+
log.info(`Registry built: ${registry.length} entries`)
|
|
95
|
+
|
|
96
|
+
// Step 6: Write .falconry/map.json
|
|
97
|
+
const falconryDir = nodePath.join(projectRoot, '.falconry')
|
|
98
|
+
if (!fs.existsSync(falconryDir)) {
|
|
99
|
+
fs.mkdirSync(falconryDir, { recursive: true })
|
|
100
|
+
}
|
|
101
|
+
const mapPath = nodePath.join(falconryDir, 'map.json')
|
|
102
|
+
fs.writeFileSync(mapPath, JSON.stringify(registry, null, 2), 'utf-8')
|
|
103
|
+
log.info(`Registry written to ${mapPath}`)
|
|
104
|
+
|
|
105
|
+
const elapsed = Date.now() - startTime
|
|
106
|
+
log.info(`Pipeline complete: ${registry.length} entries in ${elapsed}ms`)
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
registry,
|
|
110
|
+
stats: {
|
|
111
|
+
fileTools: fileTools.length,
|
|
112
|
+
regTools: regTools.length,
|
|
113
|
+
depsEntries: Object.keys(deps).length,
|
|
114
|
+
merged: merged.length,
|
|
115
|
+
filtered: filtered.length,
|
|
116
|
+
registry: registry.length,
|
|
117
|
+
elapsed,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface PipelineStats {
|
|
123
|
+
fileTools: number
|
|
124
|
+
regTools: number
|
|
125
|
+
depsEntries: number
|
|
126
|
+
merged: number
|
|
127
|
+
filtered: number
|
|
128
|
+
registry: number
|
|
129
|
+
elapsed: number
|
|
130
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @falconwry/creator — 核心类型定义
|
|
2
|
+
// Scanner 包通过 creator 的导出引入
|
|
3
|
+
|
|
4
|
+
/** Scanner 产出的原始工具条目(语言无关) */
|
|
5
|
+
export type RawTool = {
|
|
6
|
+
name: string
|
|
7
|
+
src: string
|
|
8
|
+
startLine: number
|
|
9
|
+
endLine: number
|
|
10
|
+
code: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** AI Prompt 上下文,由 Scanner 提供 */
|
|
14
|
+
export type PromptContext = {
|
|
15
|
+
projectType: string
|
|
16
|
+
toolConcept: string
|
|
17
|
+
categories: string[]
|
|
18
|
+
exampleTags: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 分析后的工具条目(RawTool + AI/规则分析结果) */
|
|
22
|
+
export type AnalyzedTool = RawTool & {
|
|
23
|
+
description: string
|
|
24
|
+
category: string
|
|
25
|
+
tags: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 引用关系映射,key = `${src}:${name}` */
|
|
29
|
+
export type DependentsMap = Record<string, {
|
|
30
|
+
count: number
|
|
31
|
+
list: string[]
|
|
32
|
+
}>
|
|
33
|
+
|
|
34
|
+
/** 基础注册表字段(所有语言通用) */
|
|
35
|
+
export type BaseRegistry = {
|
|
36
|
+
name: string
|
|
37
|
+
description: string
|
|
38
|
+
src: string
|
|
39
|
+
startLine: number
|
|
40
|
+
endLine: number
|
|
41
|
+
category: string
|
|
42
|
+
tags: string[]
|
|
43
|
+
dependents: { count: number; list: string[] }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 前端 JS/TS/Vue 注册表扩展 */
|
|
47
|
+
export type FrontendRegistry = BaseRegistry & {
|
|
48
|
+
exportName: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 分析器接口 — V0.2 规则引擎,V0.3 替换为 AI */
|
|
52
|
+
export interface Analyzer {
|
|
53
|
+
analyze(tools: RawTool[], context: PromptContext): Promise<AnalyzedTool[]>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 校验规则 — 类型由 creator 定义,值由各 Scanner 提供 */
|
|
57
|
+
export type ValidationRules = {
|
|
58
|
+
description: { minLength: number; maxLength: number }
|
|
59
|
+
tags: { minCount: number; maxCount: number }
|
|
60
|
+
nameMatch: 'strict' | 'loose'
|
|
61
|
+
allowedCategories: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Scanner 插件接口 */
|
|
65
|
+
export interface Scanner {
|
|
66
|
+
name: string
|
|
67
|
+
scanFiles(projectRoot: string): RawTool[]
|
|
68
|
+
scanRegistrations(projectRoot: string): RawTool[]
|
|
69
|
+
scanDependencies(projectRoot: string): DependentsMap
|
|
70
|
+
buildRegistry(tools: AnalyzedTool[], deps: DependentsMap): BaseRegistry[]
|
|
71
|
+
getPromptContext(projectRoot: string): PromptContext
|
|
72
|
+
getValidationRules(): ValidationRules
|
|
73
|
+
validateTool(tool: AnalyzedTool, rules: ValidationRules): string[]
|
|
74
|
+
}
|