@dev-workflow/skill 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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/bin/cli.mjs +9 -0
  4. package/package.json +39 -0
  5. package/skills/artifact/README.md +23 -0
  6. package/skills/execution/devFlow/SKILL.md +341 -0
  7. package/skills/execution/devFlow/agents/openai.yaml +7 -0
  8. package/skills/execution/devFlow/domains/backend/references/api-tech.md +173 -0
  9. package/skills/execution/devFlow/domains/backend/scripts/check_api_tech_doc.mjs +170 -0
  10. package/skills/execution/devFlow/domains/backend/scripts/check_api_tech_doc.test.mjs +89 -0
  11. package/skills/execution/devFlow/domains/backend/templates/api-tech.md +164 -0
  12. package/skills/execution/devFlow/domains/frontend/references/contract-check.md +270 -0
  13. package/skills/execution/devFlow/domains/frontend/references/foundation-freeze.md +92 -0
  14. package/skills/execution/devFlow/domains/frontend/references/lightweight-flow.md +55 -0
  15. package/skills/execution/devFlow/domains/frontend/references/page-build.md +268 -0
  16. package/skills/execution/devFlow/domains/frontend/references/page-tech.md +414 -0
  17. package/skills/execution/devFlow/domains/frontend/references/product-design-specs/field-common.md +69 -0
  18. package/skills/execution/devFlow/domains/frontend/references/product-design-specs/fields-complex.md +105 -0
  19. package/skills/execution/devFlow/domains/frontend/references/product-design-specs/fields-id-code.md +44 -0
  20. package/skills/execution/devFlow/domains/frontend/references/product-design-specs/fields-number-date.md +106 -0
  21. package/skills/execution/devFlow/domains/frontend/references/product-design-specs/fields-relation.md +144 -0
  22. package/skills/execution/devFlow/domains/frontend/references/product-design-specs/fields-selectors.md +102 -0
  23. package/skills/execution/devFlow/domains/frontend/references/product-design-specs/fields-text.md +130 -0
  24. package/skills/execution/devFlow/domains/frontend/references/product-design-specs/index.md +42 -0
  25. package/skills/execution/devFlow/domains/frontend/references/product-design-specs/interaction.md +61 -0
  26. package/skills/execution/devFlow/domains/frontend/scripts/check_page_tech_doc.mjs +82 -0
  27. package/skills/execution/devFlow/domains/frontend/scripts/contract_check_static.mjs +145 -0
  28. package/skills/execution/devFlow/domains/frontend/scripts/generate_foundation_summary.mjs +224 -0
  29. package/skills/execution/devFlow/domains/frontend/templates/contract-report.md +66 -0
  30. package/skills/execution/devFlow/domains/frontend/templates/mini-plan-l0.md +7 -0
  31. package/skills/execution/devFlow/domains/frontend/templates/mini-plan-l1.md +9 -0
  32. package/skills/execution/devFlow/domains/frontend/templates/page-build/component.tsx.tpl +8 -0
  33. package/skills/execution/devFlow/domains/frontend/templates/page-build/constants.ts.tpl +5 -0
  34. package/skills/execution/devFlow/domains/frontend/templates/page-build/index.ts.tpl +1 -0
  35. package/skills/execution/devFlow/domains/frontend/templates/page-build/route.tsx.tpl +11 -0
  36. package/skills/execution/devFlow/domains/frontend/templates/page-build/service.ts.tpl +17 -0
  37. package/skills/execution/devFlow/domains/frontend/templates/page-build/types.ts.tpl +11 -0
  38. package/skills/execution/devFlow/domains/frontend/templates/page-tech.md +103 -0
  39. package/skills/execution/devFlow/domains/requirement/references/prd-review.md +129 -0
  40. package/skills/execution/devFlow/domains/requirement/templates/prd-review.md +57 -0
  41. package/skills/execution/figmaSync/SKILL.md +349 -0
  42. package/skills/execution/figmaSync/chapters/apply.md +177 -0
  43. package/skills/execution/figmaSync/chapters/plan.md +359 -0
  44. package/skills/execution/figmaSync/chapters/prepare.md +47 -0
  45. package/skills/execution/figmaSync/examples/native-css.md +129 -0
  46. package/skills/execution/figmaSync/examples/plan-doc-sample.md +39 -0
  47. package/skills/execution/figmaSync/scripts/_shared/session-log.mjs +82 -0
  48. package/skills/execution/figmaSync/scripts/_shared/token-resolver.mjs +132 -0
  49. package/skills/execution/figmaSync/scripts/_shared/token-source.mjs +145 -0
  50. package/skills/execution/figmaSync/scripts/figma-sync-report.mjs +448 -0
  51. package/skills/execution/figmaSync/scripts/icon-inventory.mjs +165 -0
  52. package/skills/execution/figmaSync/scripts/lookup-var.mjs +184 -0
  53. package/skills/execution/figmaSync/scripts/match-token.mjs +210 -0
  54. package/skills/execution/figmaSync/scripts/prepare-check.mjs +290 -0
  55. package/skills/execution/figmaSync/scripts/verify-plan.mjs +458 -0
  56. package/skills/execution/figmaSync/snapshots/README.md +13 -0
  57. package/skills/execution/figmaSync/snapshots/last-sync.md +1538 -0
  58. package/skills/execution/figmaSync/templates/PLAN.md.tpl +227 -0
  59. package/skills/review/consistency-checker/SKILL.md +112 -0
  60. package/skills/review/consistency-checker/rules/README.md +16 -0
  61. package/src/cli/agents.mjs +16 -0
  62. package/src/cli/copy.mjs +95 -0
  63. package/src/cli/install.mjs +131 -0
  64. package/src/cli/prompts.mjs +66 -0
  65. package/tools/lark/README.md +78 -0
  66. package/tools/lark/references/lark-doc.md +156 -0
  67. package/tools/lark/references/lark-read.md +235 -0
  68. package/tools/lark/references/prepare.md +147 -0
  69. package/tools/lark/scripts/lark_api.mjs +404 -0
  70. package/tools/lark/scripts/lark_api.test.mjs +63 -0
  71. package/tools/lark/scripts/lark_check_permissions.mjs +52 -0
  72. package/tools/lark/scripts/lark_publish_doc.mjs +299 -0
  73. package/tools/lark/scripts/lark_read_docx.mjs +328 -0
  74. package/tools/lark/scripts/markdown_to_lark_blocks.mjs +397 -0
@@ -0,0 +1,448 @@
1
+ /**
2
+ * Figma Sync Report
3
+ *
4
+ * 把本次 figmaSync 任务的「AI 做了什么」与「产物长什么样」交叉量化输出。
5
+ *
6
+ * 用法:
7
+ * pnpm figma:report # 默认 plan 命令、写到 .agent/skills/figmaSync/
8
+ * pnpm figma:report --command=plan|apply # 报告文件名前缀:session-report-<command>.md
9
+ * pnpm figma:report --target-dir=src/foo # 同时把报告复制一份到指定目录
10
+ * pnpm figma:report --plan=src/foo/PLAN.md # 解析该 PLAN.md §4 表格,统计 variables 来源
11
+ * pnpm figma:report --reset # 清空 session log(任务开始时调)
12
+ *
13
+ * 主指标(新版排序):
14
+ * 1. 硬编码计数(产物侧)—— 需要解释
15
+ * 2. CSS variables 命中数(解析 PLAN §4 表格)
16
+ * 3. 脚本调用率 lookupVar / matchToken —— 辅助参考
17
+ *
18
+ * 告警条件:
19
+ * - 硬编码 > 0 → ⚠️
20
+ * - Fast Path 覆盖率 < 50% 且 PLAN.md §4 无 variables 来源 → ⚠️
21
+ */
22
+ import { execSync } from 'node:child_process'
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+
26
+ import { readSessionLog, resetSessionLog } from './_shared/session-log.mjs'
27
+
28
+ const DEFAULT_REPORT_DIR = '.agent/skills/figmaSync'
29
+ const FAST_PATH_LOW_THRESHOLD = 0.5
30
+
31
+ function parseArgs(argv) {
32
+ const out = { reset: false, command: 'plan', targetDir: null, plan: null }
33
+ for (const arg of argv.slice(2)) {
34
+ if (arg === '--reset') out.reset = true
35
+ else if (arg.startsWith('--command=')) out.command = arg.slice('--command='.length)
36
+ else if (arg.startsWith('--target-dir=')) out.targetDir = arg.slice('--target-dir='.length)
37
+ else if (arg.startsWith('--plan=')) out.plan = arg.slice('--plan='.length)
38
+ }
39
+ if (!['plan', 'apply'].includes(out.command)) out.command = 'plan'
40
+ return out
41
+ }
42
+
43
+ function getChangedFiles() {
44
+ try {
45
+ const out = execSync('git status --porcelain', { encoding: 'utf-8' })
46
+ return out
47
+ .split('\n')
48
+ .filter(Boolean)
49
+ .map((line) => ({
50
+ status: line.slice(0, 2).trim(),
51
+ file: line.slice(3).replace(/^"|"$/g, '')
52
+ }))
53
+ .filter((c) => /\.(ts|tsx|css|md)$/.test(c.file))
54
+ .filter((c) => fs.existsSync(c.file))
55
+ } catch {
56
+ return []
57
+ }
58
+ }
59
+
60
+ function auditFile(filePath) {
61
+ const source = fs.readFileSync(filePath, 'utf-8')
62
+ const apexImports = []
63
+ const apexImportRe = /import\s+\{([^}]+)\}\s+from\s+['"]@frontend\/apex-ui--react['"]/g
64
+ let m
65
+ while ((m = apexImportRe.exec(source)) !== null) {
66
+ const names = m[1]
67
+ .split(',')
68
+ .map((n) => n.replace(/\bas\s+\w+/, '').trim())
69
+ .filter(Boolean)
70
+ apexImports.push(...names)
71
+ }
72
+
73
+ const cssVariables = {}
74
+ const cssVarRe = /var\(\s*(--[a-zA-Z0-9_-]+)/g
75
+ while ((m = cssVarRe.exec(source)) !== null) {
76
+ cssVariables[m[1]] = (cssVariables[m[1]] ?? 0) + 1
77
+ }
78
+
79
+ const hardcodedRe = /(['"`])(#[0-9a-fA-F]{3,8})\1|\b(\d{2,4})px\b/g
80
+ const hardcoded = []
81
+ while ((m = hardcodedRe.exec(source)) !== null) {
82
+ const raw = m[2] ?? m[3] + 'px'
83
+ const before = source.lastIndexOf('\n', m.index)
84
+ const lineIdx = source.slice(0, m.index).split('\n').length
85
+ const snippet = source.slice(before + 1, source.indexOf('\n', m.index)).trim()
86
+ hardcoded.push({ value: raw, line: lineIdx, snippet })
87
+ }
88
+
89
+ const commonRefs = []
90
+ const commonImportRe = /from\s+['"]([^'"]*src\/common[^'"]*|[^'"]*\/common\/[^'"]*)['"]/g
91
+ while ((m = commonImportRe.exec(source)) !== null) commonRefs.push(m[1])
92
+
93
+ const pandaForbidden = []
94
+ const forbiddenRe = /@pandacss|styled-system|css\(|cva\(|sva\(/g
95
+ while ((m = forbiddenRe.exec(source)) !== null) {
96
+ const lineIdx = source.slice(0, m.index).split('\n').length
97
+ pandaForbidden.push({ value: m[0], line: lineIdx })
98
+ }
99
+
100
+ return { apexImports, cssVariables, hardcoded, commonRefs, pandaForbidden }
101
+ }
102
+
103
+ function aggregate(files) {
104
+ const acc = {
105
+ apexImports: {},
106
+ cssVariables: {},
107
+ hardcoded: [],
108
+ commonRefs: {},
109
+ pandaForbidden: [],
110
+ files: []
111
+ }
112
+ for (const file of files) {
113
+ const r = auditFile(file)
114
+ if (
115
+ r.apexImports.length === 0 &&
116
+ Object.keys(r.cssVariables).length === 0 &&
117
+ r.hardcoded.length === 0 &&
118
+ r.commonRefs.length === 0 &&
119
+ r.pandaForbidden.length === 0
120
+ )
121
+ continue
122
+ acc.files.push(file)
123
+ for (const name of r.apexImports) acc.apexImports[name] = (acc.apexImports[name] ?? 0) + 1
124
+ for (const [k, v] of Object.entries(r.cssVariables))
125
+ acc.cssVariables[k] = (acc.cssVariables[k] ?? 0) + v
126
+ for (const h of r.hardcoded) acc.hardcoded.push({ ...h, file })
127
+ for (const ref of r.commonRefs) acc.commonRefs[ref] = (acc.commonRefs[ref] ?? 0) + 1
128
+ for (const hit of r.pandaForbidden) acc.pandaForbidden.push({ ...hit, file })
129
+ }
130
+ return acc
131
+ }
132
+
133
+ function auditForbiddenFiles(changedFiles) {
134
+ const forbidden = [
135
+ 'src/routeTree.gen.ts',
136
+ 'src/services/request.ts',
137
+ 'src/shared/wujie-bridge.ts',
138
+ 'src/stores/shell-store.ts'
139
+ ]
140
+ return changedFiles
141
+ .map((c) => c.file)
142
+ .filter((file) => forbidden.includes(file) || file.startsWith('src/common/'))
143
+ }
144
+
145
+ function tallyLog(log) {
146
+ const summary = {
147
+ lookupVar: { hit: 0, miss: 0, byLayer: {} },
148
+ matchToken: { hit: 0, miss: 0, byLayer: {} },
149
+ misses: []
150
+ }
151
+ for (const entry of log) {
152
+ const target =
153
+ entry.script === 'lookup-var'
154
+ ? summary.lookupVar
155
+ : entry.script === 'match-token'
156
+ ? summary.matchToken
157
+ : null
158
+ if (!target) continue
159
+ if (entry.hit) {
160
+ target.hit++
161
+ const layer = entry.layer ?? 'unknown'
162
+ target.byLayer[layer] = (target.byLayer[layer] ?? 0) + 1
163
+ } else {
164
+ target.miss++
165
+ summary.misses.push({ script: entry.script, input: entry.input })
166
+ }
167
+ }
168
+ return summary
169
+ }
170
+
171
+ /**
172
+ * 解析 PLAN.md §4 「CSS 变量映射表」,统计「命中方式」列里 variables 来源出现次数。
173
+ * 表格契约(PLAN.md.tpl 强制):列顺序为 来源 | 类型 | CSS variable | /theme 路径 | 原始值 | 命中方式 | nodeId | designContextStatus。
174
+ * 命中方式取值约定:
175
+ * - "Fast Path 0 (variables)" → MCP get_variable_defs 直接命中
176
+ * - "Fast Path 1" → lookup-var 命中
177
+ * - "Slow Path" → match-token 命中
178
+ * - "未命中" → 降级
179
+ */
180
+ function parsePlanTokenTable(planPath) {
181
+ if (!planPath || !fs.existsSync(planPath)) {
182
+ return { source: 'none', stats: null }
183
+ }
184
+ const text = fs.readFileSync(planPath, 'utf-8')
185
+ // 切 §4 整段:从 "## 4. CSS 变量映射表" 到下一个 "## " 之间(不含下一个标题)
186
+ const headRe = /^##\s+4\.\s+CSS\s+变量映射表\s*$/m
187
+ const headMatch = headRe.exec(text)
188
+ if (!headMatch) return { source: planPath, stats: null }
189
+ const start = headMatch.index + headMatch[0].length
190
+ const tail = text.slice(start)
191
+ const nextRe = /^##\s/m
192
+ const nextMatch = nextRe.exec(tail)
193
+ const section = nextMatch ? tail.slice(0, nextMatch.index) : tail
194
+
195
+ const lineRe = /^\|[^\n]+\|$/gm
196
+ const rows = section.match(lineRe) ?? []
197
+ const stats = {
198
+ 'Fast Path 0 (variables)': 0,
199
+ 'Fast Path 1 (lookup-var)': 0,
200
+ 'Slow Path (value-match)': 0,
201
+ 未命中: 0
202
+ }
203
+ for (const row of rows) {
204
+ if (/^\|\s*-+/.test(row)) continue
205
+ if (/来源(Figma|(Figma))|命中方式|CSS variable/.test(row)) continue
206
+ if (/^\|\s*<|^\|\s*\(占位/.test(row)) continue
207
+ const cells = row
208
+ .split('|')
209
+ .slice(1, -1)
210
+ .map((c) => c.trim())
211
+ const hitWay = cells[5] ?? ''
212
+ for (const key of Object.keys(stats)) {
213
+ if (hitWay.includes(key)) stats[key]++
214
+ }
215
+ }
216
+ return { source: planPath, stats }
217
+ }
218
+
219
+ function renderReport({
220
+ log,
221
+ audit,
222
+ planTokens,
223
+ generatedAt,
224
+ command,
225
+ changedFiles,
226
+ forbiddenFiles
227
+ }) {
228
+ const lines = []
229
+ lines.push(`# figmaSync Session Report (${command}) — ${generatedAt}`)
230
+ lines.push('')
231
+
232
+ lines.push('## Review 摘要')
233
+ lines.push('')
234
+ lines.push(`- 改动文件:${changedFiles.length} 个`)
235
+ lines.push(
236
+ `- 禁止 / 高风险文件命中:${forbiddenFiles.length} 个${forbiddenFiles.length === 0 ? ' ✅' : ' ⚠️'}`
237
+ )
238
+ lines.push(
239
+ `- Panda / styled-system 禁用内容:${audit.pandaForbidden.length} 处${audit.pandaForbidden.length === 0 ? ' ✅' : ' ⚠️'}`
240
+ )
241
+ lines.push(
242
+ `- 硬编码嫌疑:${audit.hardcoded.length} 处${audit.hardcoded.length === 0 ? ' ✅' : ' ⚠️'}`
243
+ )
244
+ lines.push('')
245
+
246
+ lines.push('## 改动文件')
247
+ lines.push('')
248
+ if (changedFiles.length === 0) {
249
+ lines.push('- 未检测到工作树改动')
250
+ } else {
251
+ for (const file of changedFiles) lines.push(`- ${file.status || '??'} ${file.file}`)
252
+ }
253
+ lines.push('')
254
+
255
+ lines.push('## 成功项 / 跳过项 / 风险项')
256
+ lines.push('')
257
+ const success = []
258
+ const skipped = []
259
+ const risks = []
260
+ if (Object.keys(audit.cssVariables).length > 0) success.push('使用 CSS variables')
261
+ else skipped.push('未检测到 CSS variable 引用')
262
+ if (Object.keys(audit.apexImports).length > 0) success.push('复用 Apex UI 组件')
263
+ else skipped.push('未检测到 Apex UI import')
264
+ if (Object.keys(audit.commonRefs).length > 0) success.push('复用 common module')
265
+ if (audit.hardcoded.length > 0) risks.push('存在硬编码嫌疑,需要解释或确认')
266
+ if (audit.pandaForbidden.length > 0) risks.push('存在 Panda / styled-system 禁用内容')
267
+ if (forbiddenFiles.length > 0) risks.push('命中禁止或高风险文件,需要 Human 再确认')
268
+ lines.push(`- 成功项:${success.length > 0 ? success.join(';') : '无自动识别项'}`)
269
+ lines.push(`- 跳过项:${skipped.length > 0 ? skipped.join(';') : '无'}`)
270
+ lines.push(`- 风险项:${risks.length > 0 ? risks.join(';') : '无'}`)
271
+ lines.push('')
272
+
273
+ if (forbiddenFiles.length > 0) {
274
+ lines.push('## 禁止 / 高风险文件')
275
+ lines.push('')
276
+ for (const file of forbiddenFiles) lines.push(`- ${file}`)
277
+ lines.push('')
278
+ }
279
+
280
+ // ---------- 主指标 ----------
281
+ lines.push('## 🎯 主指标')
282
+ lines.push('')
283
+ const hardcodedCount = audit.hardcoded.length
284
+ lines.push(`- 硬编码嫌疑:${hardcodedCount} ${hardcodedCount === 0 ? '✅' : '⚠️ 需解释'}`)
285
+
286
+ let variablesHits = 0
287
+ if (planTokens.stats) {
288
+ variablesHits = planTokens.stats['Fast Path 0 (variables)'] ?? 0
289
+ const total = Object.values(planTokens.stats).reduce((a, b) => a + b, 0)
290
+ lines.push(`- PLAN §4 CSS variable 命中分布(共 ${total} 行):`)
291
+ for (const [key, n] of Object.entries(planTokens.stats)) {
292
+ lines.push(` - ${key}: ${n}`)
293
+ }
294
+ } else if (planTokens.source !== 'none') {
295
+ lines.push(`- PLAN §4 CSS variable 表格解析失败(${planTokens.source})`)
296
+ } else {
297
+ lines.push('- PLAN §4 CSS variable 表格:未指定 --plan 路径,跳过解析')
298
+ }
299
+
300
+ const fastTotal = log.lookupVar.hit + log.lookupVar.miss
301
+ const allLookupTotal = fastTotal + log.matchToken.hit + log.matchToken.miss
302
+ const fastCoverage = allLookupTotal > 0 ? log.lookupVar.hit / allLookupTotal : null
303
+ if (fastCoverage !== null) {
304
+ const pct = Math.round(fastCoverage * 100)
305
+ lines.push(`- 脚本调用率(辅助参考):lookup-var ${pct}% / 总查询 ${allLookupTotal}`)
306
+ if (fastCoverage < FAST_PATH_LOW_THRESHOLD && variablesHits === 0) {
307
+ lines.push(
308
+ ' - ⚠️ 调用率 < 50% 且 PLAN §4 无 variables 来源;AI 既没查脚本也没标 variables 命中'
309
+ )
310
+ }
311
+ } else {
312
+ lines.push('- 脚本调用率(辅助参考):本 session 未触发匹配脚本')
313
+ }
314
+ lines.push('')
315
+
316
+ // ---------- AI 行为 ----------
317
+ lines.push('## 🔎 AI 调用轨迹')
318
+ lines.push('')
319
+ const total = log.lookupVar.hit + log.lookupVar.miss + log.matchToken.hit + log.matchToken.miss
320
+ if (total === 0) {
321
+ lines.push('- 本 session 未调用 lookup-var / match-token')
322
+ } else {
323
+ lines.push(`- lookup-var 命中:${log.lookupVar.hit} / ${fastTotal}`)
324
+ for (const [layer, n] of Object.entries(log.lookupVar.byLayer)) {
325
+ lines.push(` - ${layer}.* × ${n}`)
326
+ }
327
+ const slowTotal = log.matchToken.hit + log.matchToken.miss
328
+ if (slowTotal > 0) {
329
+ lines.push(`- match-token 命中:${log.matchToken.hit} / ${slowTotal}`)
330
+ for (const [layer, n] of Object.entries(log.matchToken.byLayer)) {
331
+ lines.push(` - 推荐 ${layer} 层 × ${n}`)
332
+ }
333
+ } else {
334
+ lines.push('- match-token 本次未触发')
335
+ }
336
+ if (log.misses.length > 0) {
337
+ lines.push('- 未命中输入:')
338
+ for (const miss of log.misses) {
339
+ lines.push(` - \`${miss.script}\` ← \`${miss.input}\``)
340
+ }
341
+ }
342
+ }
343
+ lines.push('')
344
+
345
+ // ---------- 产物审计 ----------
346
+ lines.push('## 📦 产物审计')
347
+ lines.push('')
348
+ if (audit.files.length === 0) {
349
+ lines.push('- 未在工作树中检测到相关 ts/tsx 改动')
350
+ } else {
351
+ lines.push(`改动文件:${audit.files.length} 个`)
352
+ for (const f of audit.files) lines.push(` - ${f}`)
353
+ lines.push('')
354
+
355
+ const apexEntries = Object.entries(audit.apexImports)
356
+ if (apexEntries.length > 0) {
357
+ lines.push(`apex-ui 组件复用:${apexEntries.length} 种`)
358
+ for (const [name, n] of apexEntries) lines.push(` - ${name} × ${n}`)
359
+ lines.push('')
360
+ } else {
361
+ lines.push('apex-ui 组件复用:0 ⚠️ 检查是否漏用组件库')
362
+ lines.push('')
363
+ }
364
+
365
+ const variableEntries = Object.entries(audit.cssVariables)
366
+ if (variableEntries.length > 0) {
367
+ lines.push('CSS variable 引用:')
368
+ for (const [name, n] of variableEntries) lines.push(` - ${name} × ${n}`)
369
+ lines.push('')
370
+ }
371
+
372
+ const commonEntries = Object.entries(audit.commonRefs)
373
+ if (commonEntries.length > 0) {
374
+ lines.push('common component / common module 复用:')
375
+ for (const [ref, n] of commonEntries) lines.push(` - ${ref} × ${n}`)
376
+ lines.push('')
377
+ }
378
+
379
+ if (audit.hardcoded.length > 0) {
380
+ lines.push(`硬编码嫌疑:${audit.hardcoded.length} 处`)
381
+ for (const h of audit.hardcoded) {
382
+ lines.push(` - ${h.file}:${h.line} \`${h.value}\` — ${h.snippet}`)
383
+ }
384
+ lines.push('')
385
+ } else {
386
+ lines.push('硬编码嫌疑:0 ✅')
387
+ lines.push('')
388
+ }
389
+
390
+ if (audit.pandaForbidden.length > 0) {
391
+ lines.push(`Panda 禁用内容:${audit.pandaForbidden.length} 处 ⚠️`)
392
+ for (const hit of audit.pandaForbidden) {
393
+ lines.push(` - ${hit.file}:${hit.line} \`${hit.value}\``)
394
+ }
395
+ lines.push('')
396
+ }
397
+ }
398
+
399
+ return lines.join('\n') + '\n'
400
+ }
401
+
402
+ function writeReport({ report, command, targetDir }) {
403
+ const fileName = `session-report-${command}.md`
404
+ const primary = path.join(DEFAULT_REPORT_DIR, fileName)
405
+ fs.mkdirSync(DEFAULT_REPORT_DIR, { recursive: true })
406
+ fs.writeFileSync(primary, report)
407
+ const written = [primary]
408
+
409
+ if (targetDir) {
410
+ fs.mkdirSync(targetDir, { recursive: true })
411
+ const copy = path.join(targetDir, fileName)
412
+ fs.writeFileSync(copy, report)
413
+ written.push(copy)
414
+ }
415
+ return written
416
+ }
417
+
418
+ function main() {
419
+ const args = parseArgs(process.argv)
420
+ if (args.reset) {
421
+ resetSessionLog()
422
+ console.log('[figma:report] session log reset.')
423
+ return
424
+ }
425
+
426
+ const log = tallyLog(readSessionLog())
427
+ const changedFiles = getChangedFiles()
428
+ const audit = aggregate(
429
+ changedFiles.filter((c) => /\.(ts|tsx|css)$/.test(c.file)).map((c) => c.file)
430
+ )
431
+ const planTokens = parsePlanTokenTable(args.plan)
432
+ const forbiddenFiles = auditForbiddenFiles(changedFiles)
433
+ const report = renderReport({
434
+ log,
435
+ audit,
436
+ planTokens,
437
+ command: args.command,
438
+ changedFiles,
439
+ forbiddenFiles,
440
+ generatedAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
441
+ })
442
+
443
+ const written = writeReport({ report, command: args.command, targetDir: args.targetDir })
444
+ console.log(report)
445
+ for (const w of written) console.log(`[figma:report] saved -> ${w}`)
446
+ }
447
+
448
+ main()
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Icon Inventory
3
+ *
4
+ * 扫描仓库中真实存在的 icon 资产源,供 figmaSync plan 做语义匹配。
5
+ *
6
+ * 用法:
7
+ * node .agent/skills/figmaSync/scripts/icon-inventory.mjs
8
+ * node .agent/skills/figmaSync/scripts/icon-inventory.mjs --pretty
9
+ */
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+
13
+ const ROOT = process.cwd()
14
+ const ICON_EXTENSIONS = new Set(['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.tsx', '.ts'])
15
+ const STATIC_EXTENSIONS = new Set(['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif'])
16
+ const SEARCH_ROOTS = ['assets', 'icons', 'public', 'src']
17
+ const IGNORE_DIRS = new Set([
18
+ '.git',
19
+ '.agent',
20
+ 'dist',
21
+ 'node_modules',
22
+ 'styled-system',
23
+ 'coverage',
24
+ '.turbo',
25
+ '.vite'
26
+ ])
27
+
28
+ function main() {
29
+ const pretty = process.argv.includes('--pretty')
30
+ const roots = SEARCH_ROOTS.filter((dir) => fs.existsSync(path.join(ROOT, dir)))
31
+ const files = []
32
+
33
+ for (const root of roots) {
34
+ walk(path.join(ROOT, root), files)
35
+ }
36
+
37
+ const icons = files
38
+ .map((file) => toIconRecord(file))
39
+ .flat()
40
+ .sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path))
41
+
42
+ const out = {
43
+ roots,
44
+ count: icons.length,
45
+ icons,
46
+ note:
47
+ icons.length === 0
48
+ ? '仓库未发现可用 icon 源;请用户提供其他 icon 导入方案,或确认不关心 icon。'
49
+ : null
50
+ }
51
+
52
+ console.log(JSON.stringify(out, null, pretty ? 2 : 0))
53
+ }
54
+
55
+ function walk(dir, files) {
56
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
57
+ for (const entry of entries) {
58
+ if (entry.name.startsWith('.')) continue
59
+ if (entry.isDirectory()) {
60
+ if (IGNORE_DIRS.has(entry.name)) continue
61
+ const next = path.join(dir, entry.name)
62
+ if (isIconSearchDir(next)) walk(next, files)
63
+ continue
64
+ }
65
+ if (!entry.isFile()) continue
66
+ const file = path.join(dir, entry.name)
67
+ const ext = path.extname(entry.name).toLowerCase()
68
+ if (!ICON_EXTENSIONS.has(ext)) continue
69
+ if (isInAllowedIconPath(file) || looksLikeIconFile(file)) {
70
+ files.push(file)
71
+ }
72
+ }
73
+ }
74
+
75
+ function isIconSearchDir(absDir) {
76
+ const rel = toPosix(path.relative(ROOT, absDir))
77
+ if (!rel) return true
78
+ const parts = rel.split('/')
79
+ if (parts.some((p) => IGNORE_DIRS.has(p))) return false
80
+ if (parts[0] === 'assets' || parts[0] === 'icons' || parts[0] === 'public') return true
81
+ if (parts[0] === 'src') return true
82
+ return false
83
+ }
84
+
85
+ function isInAllowedIconPath(absFile) {
86
+ const rel = toPosix(path.relative(ROOT, absFile))
87
+ const parts = rel.split('/')
88
+ if (parts[0] === 'assets' || parts[0] === 'icons' || parts[0] === 'public') return true
89
+ return parts[0] === 'src' && (parts.includes('assets') || parts.includes('icons'))
90
+ }
91
+
92
+ function looksLikeIconFile(absFile) {
93
+ const basename = path.basename(absFile, path.extname(absFile))
94
+ return /\bicon\b/i.test(basename) || /(^|[-_])ic([-_]|$)/i.test(basename)
95
+ }
96
+
97
+ function toIconRecord(absFile) {
98
+ const ext = path.extname(absFile).toLowerCase()
99
+ const rel = toPosix(path.relative(ROOT, absFile))
100
+ if (STATIC_EXTENSIONS.has(ext)) {
101
+ const baseName = path.basename(absFile, ext)
102
+ return [
103
+ {
104
+ name: baseName,
105
+ normalizedName: normalizeName(baseName),
106
+ type: ext.slice(1),
107
+ path: rel,
108
+ exportName: null
109
+ }
110
+ ]
111
+ }
112
+
113
+ const source = fs.readFileSync(absFile, 'utf-8')
114
+ const exportNames = extractIconExports(source)
115
+ if (exportNames.length === 0) return []
116
+ return exportNames.map((exportName) => ({
117
+ name: exportName,
118
+ normalizedName: normalizeName(exportName),
119
+ type: 'component',
120
+ path: rel,
121
+ exportName
122
+ }))
123
+ }
124
+
125
+ function extractIconExports(source) {
126
+ const names = new Set()
127
+ const patterns = [
128
+ /export\s+(?:const|function)\s+([A-Z][A-Za-z0-9]*Icon|Icon[A-Z][A-Za-z0-9]*)\b/g,
129
+ /export\s*\{\s*([^}]+)\s*\}/g
130
+ ]
131
+
132
+ let m
133
+ while ((m = patterns[0].exec(source)) !== null) {
134
+ names.add(m[1])
135
+ }
136
+
137
+ while ((m = patterns[1].exec(source)) !== null) {
138
+ for (const raw of m[1].split(',')) {
139
+ const name = raw
140
+ .trim()
141
+ .replace(/\s+as\s+.+$/, '')
142
+ .trim()
143
+ if (/([A-Z][A-Za-z0-9]*Icon|Icon[A-Z][A-Za-z0-9]*)$/.test(name)) names.add(name)
144
+ }
145
+ }
146
+
147
+ return [...names]
148
+ }
149
+
150
+ function normalizeName(name) {
151
+ return name
152
+ .replace(/Icon$/i, '')
153
+ .replace(/^Icon/i, '')
154
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
155
+ .replace(/[_\s]+/g, '-')
156
+ .replace(/[^a-zA-Z0-9-]/g, '')
157
+ .toLowerCase()
158
+ .replace(/^-+|-+$/g, '')
159
+ }
160
+
161
+ function toPosix(p) {
162
+ return p.split(path.sep).join('/')
163
+ }
164
+
165
+ main()