cardbuddy 0.25.0 → 0.26.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.
@@ -0,0 +1,525 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * adapt_schema.mjs — 卡片「适配环节」执行器:把旧有 / 已过时 / 错误的 schema 形态
4
+ * 一次性扫出来并做确定性修复,避免每张卡靠人肉踩坑。
5
+ *
6
+ * 与相邻工具的边界(别重复实现):
7
+ * - validate_schema.py 结构合法性能不能渲染(只报不改)
8
+ * - autofix_schema.py 契约级机械修复:itemValueFrom / 重复 componentId /
9
+ * data.json 派生 / $item.xxx 作用域别名
10
+ * - lint_card.mjs 体验规则(漏 click、缺 color、切图命名…)
11
+ * - lint_autofix.mjs 体验 lint 的机械还债(带渲染像素守护)
12
+ * - **adapt_schema.mjs(本脚本)**:跨版本 / 跨宿主的「语义迁移与错误形态归一」——
13
+ * 重点是那些结构合法、lint 也不报,但在当前渲染契约或
14
+ * 发布链路上会出错的历史写法。
15
+ *
16
+ * 规则表见 references/schema_adaptation.md(每条规则的事故来源、判定、修复、依据)。
17
+ *
18
+ * 用法:
19
+ * node scripts/adapt_schema.mjs --id <卡id> # 只报告(默认)
20
+ * node scripts/adapt_schema.mjs --id <卡id> --write # 落盘可确定性修复项
21
+ * node scripts/adapt_schema.mjs --all # 全量库存巡检
22
+ * node scripts/adapt_schema.mjs --all --json # 机器可读(接 CI / card-doctor)
23
+ * node scripts/adapt_schema.mjs --list-rules # 打印规则表
24
+ *
25
+ * 退出码:0 = 无 error(或 --write 后清零);1 = 存在未修复 error;2 = 用法/路径错误。
26
+ * 设计约定:**不臆造语义**——需要人判断的(未声明变量取什么值、死节点该删还是该显)
27
+ * 只报告,不自动改;只有「改法唯一且等价保持」的才进 --write。
28
+ */
29
+ import fs from 'node:fs'
30
+ import path from 'node:path'
31
+
32
+ // ---------- CLI ----------
33
+ const argv = process.argv.slice(2)
34
+ const argOf = (name) => {
35
+ const i = argv.indexOf(name)
36
+ return i >= 0 ? argv[i + 1] : undefined
37
+ }
38
+ const hasFlag = (name) => argv.includes(name)
39
+
40
+ if (hasFlag('--help') || hasFlag('-h')) {
41
+ console.error(
42
+ 'Usage: node scripts/adapt_schema.mjs (--id <cardId> | --all) [--write] [--json] [--project-root <dir>]\n' +
43
+ ' node scripts/adapt_schema.mjs --list-rules'
44
+ )
45
+ process.exit(2)
46
+ }
47
+
48
+ const projectRootArg = argOf('--project-root')
49
+ if (projectRootArg) process.env.DSL_CARD_PROJECT_ROOT = path.resolve(projectRootArg)
50
+
51
+ const { cardsDir, cardSchemaPath, readJson } = await import('./lib/paths.mjs')
52
+
53
+ const targetId = argOf('--id')
54
+ const runAll = hasFlag('--all')
55
+ const writeMode = hasFlag('--write')
56
+ const asJson = hasFlag('--json')
57
+
58
+ // ---------- 规则表 ----------
59
+ /** 布局容器:这些组件的 display 由样式类决定(.ce_row / .ce_column = flex) */
60
+ const LAYOUT_CLASSES = new Set([
61
+ 'Root',
62
+ 'Row',
63
+ 'Column',
64
+ 'SingleColumn',
65
+ 'MultiColumn',
66
+ 'GridLayout',
67
+ 'ListLayout',
68
+ 'AbsoluteLayout',
69
+ ])
70
+
71
+ /** 表达式内建变量(运行时注入,无需 inputParams 声明) */
72
+ const BUILTIN_VARS = new Set(['_index', 'item', 'root', 'self'])
73
+
74
+ /**
75
+ * 规则表:id → 元信息。detect/fix 在同文件下方按 id 分派。
76
+ * level: error(会让卡出错)/ warn(有风险或体验问题)
77
+ * fix: 'auto'(--write 落盘)/ 'manual'(只报告,需语义判断)/ 'nolocal'(schema 无可修点)
78
+ */
79
+ const RULES = {
80
+ 'AD-01': {
81
+ title: '未声明变量引用(需人工确认)',
82
+ level: 'warn',
83
+ fix: 'manual',
84
+ why: '表达式引用了 inputParams 未声明的变量,运行时求值抛错或恒 undefined(实例:creditCardBill column_cj 的 $listData1/$listData2,从别卡复制残留)',
85
+ },
86
+ 'AD-02': {
87
+ title: '越界 $_index',
88
+ level: 'error',
89
+ fix: 'manual',
90
+ why: '$_index 只在列表项作用域(slotScope)内有定义;作用域外用会求值成 undefined,条件恒 false(实例:creditCardBill divider_7l/divider_ep 两个从未渲染过的死节点)',
91
+ },
92
+ 'AD-03': {
93
+ title: '布局容器上的条件显隐(发布产物 display 风险)',
94
+ level: 'warn',
95
+ fix: 'nolocal',
96
+ why: 'v-show 在发布时由平台编译成内联 display: (expr) ? \'block\' : \'none\';可见侧写死 block 会压掉布局元素的样式类 display:flex(实例:creditCardBill row_f1/row_77 在 H5 变 block)。schema 侧无 display 可改,需宿主渲染层兜底',
97
+ },
98
+ 'AD-04': {
99
+ title: 'style 里写死 display',
100
+ level: 'error',
101
+ fix: 'auto',
102
+ why: 'display 只应由 v-show 指令产生;写进 style 会被当成未知样式键静默丢弃(validate 报 ERROR),或在旧渲染器里压掉样式类',
103
+ },
104
+ 'AD-05': {
105
+ title: 'v-show 条件为空',
106
+ level: 'error',
107
+ fix: 'auto',
108
+ why: 'showType=01/02 却没有 condition.expression:编译出的 display 表达式无意义,节点显隐不可控(降级为 showType=00 等价于「无显隐控制」)',
109
+ },
110
+ 'AD-06': {
111
+ title: '旧作用域别名 $item',
112
+ level: 'warn',
113
+ fix: 'manual',
114
+ why: '运行时插槽作用域没有 item 这个键(是 $$),$item.xxx 恒 undefined;用 autofix_schema.py 规则 5 归一为扁平字段名',
115
+ },
116
+ }
117
+
118
+ if (hasFlag('--list-rules')) {
119
+ for (const [id, r] of Object.entries(RULES)) {
120
+ console.log(`${id} [${r.level}/${r.fix}] ${r.title}\n ${r.why}`)
121
+ }
122
+ // 规则表输出量小,直接退出;后面的 usage 检查不该再跑
123
+ process.exit(0)
124
+ }
125
+
126
+ if (!targetId && !runAll) {
127
+ console.error('Usage: node scripts/adapt_schema.mjs (--id <cardId> | --all) [--write] [--json]')
128
+ process.exit(2)
129
+ }
130
+
131
+ // ---------- 变量与文本采集 ----------
132
+
133
+ /** 收集 inputParams 树里声明的全部 key(含 children 嵌套) */
134
+ function collectDeclared(inputParams) {
135
+ const keys = new Set()
136
+ const walk = (items) => {
137
+ for (const p of items || []) {
138
+ if (p && p.key) keys.add(p.key)
139
+ walk(p && p.children)
140
+ }
141
+ }
142
+ walk(inputParams)
143
+ return keys
144
+ }
145
+
146
+ /** 从一段表达式文本里取出 $var 名(排除 $$ 内建与非 ASCII 标识符) */
147
+ function varsIn(text) {
148
+ const out = []
149
+ const re = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g
150
+ let m
151
+ while ((m = re.exec(text)) !== null) out.push(m[1])
152
+ return out
153
+ }
154
+
155
+ /**
156
+ * 节点上所有「会被运行时求值」的字符串,附带出处标签。
157
+ * 只取表达式/绑定,不取 itemDefaultValue(那是数据不是表达式)。
158
+ */
159
+ function expressionTexts(node) {
160
+ const out = []
161
+
162
+ for (const p of node.properties || []) {
163
+ if (p && typeof p.itemValue === 'string' && p.itemValue.trim()) {
164
+ out.push({ where: `property.${p.itemKey}`, text: p.itemValue })
165
+ }
166
+ }
167
+
168
+ for (const group of (node.style && node.style.properties) || []) {
169
+ for (const p of (group.itemChildren && group.itemChildren.properties) || []) {
170
+ if (p && typeof p.itemValue === 'string' && p.itemValue.trim()) {
171
+ out.push({ where: `style.${group.itemKey}.${p.itemKey}`, text: p.itemValue })
172
+ }
173
+ }
174
+ }
175
+
176
+ for (const d of node.directives || []) {
177
+ for (const p of (d.itemChildren && d.itemChildren.properties) || []) {
178
+ const v = p && p.itemValue
179
+ if (typeof v === 'string' && v.trim()) {
180
+ out.push({ where: `directive.${d.itemKey}.${p.itemKey}`, text: v })
181
+ } else if (v && typeof v === 'object' && typeof v.expression === 'string' && v.expression.trim()) {
182
+ out.push({ where: `directive.${d.itemKey}.${p.itemKey}.expression`, text: v.expression })
183
+ }
184
+ }
185
+ }
186
+
187
+ for (const ea of node.eventactions || []) {
188
+ const action = (ea && ea.action) || {}
189
+ for (const p of action.properties || []) {
190
+ // 事件代码是 itemValue.{code, compiled} 对象,不是字符串(早期按字符串取会静默漏检)
191
+ const iv = p && p.itemValue
192
+ const code = iv && typeof iv === 'object' ? iv.code : typeof iv === 'string' ? iv : ''
193
+ if (typeof code === 'string' && code.includes('$')) {
194
+ out.push({ where: 'eventaction.code', text: code, isCode: true })
195
+ }
196
+ }
197
+ }
198
+
199
+ return out
200
+ }
201
+
202
+ /** 取节点 v-show 指令的 { showType, condition, expression } */
203
+ function readVShow(node) {
204
+ for (const d of node.directives || []) {
205
+ if (!d || d.itemKey !== 'v-show') continue
206
+ const props = (d.itemChildren && d.itemChildren.properties) || []
207
+ const get = (k) => props.find((p) => p && p.itemKey === k)
208
+ const showType = (get('showType') || {}).itemValue
209
+ const condItem = get('condition')
210
+ const condition = condItem && condItem.itemValue
211
+ const expression =
212
+ condition && typeof condition === 'object' ? condition.expression : typeof condition === 'string' ? condition : ''
213
+ return { directive: d, showType, condition, expression: expression || '' }
214
+ }
215
+ return null
216
+ }
217
+
218
+ /**
219
+ * 收集运行时动态写入的 state 字段名。
220
+ *
221
+ * 卡片事件代码里常见两类写入(实测:monthlyBill 的 renderListData、
222
+ * creditCardAmortiztionLoan 的 hideButton),这些字段不需要也不应该在
223
+ * inputParams 声明,AD-01 必须放行,否则每张带计算字段的卡都会误报。
224
+ *
225
+ * this.setData({ renderListData: renderList })
226
+ * this.state.hideButton = false
227
+ */
228
+ function collectDynamicKeys(schema) {
229
+ const keys = new Set()
230
+ const walk = (node) => {
231
+ if (!node || typeof node !== 'object') return
232
+ for (const ea of node.eventactions || []) {
233
+ for (const p of ((ea && ea.action) || {}).properties || []) {
234
+ const iv = p && p.itemValue
235
+ const code = iv && typeof iv === 'object' ? iv.code : typeof iv === 'string' ? iv : ''
236
+ if (typeof code !== 'string' || !code) continue
237
+ let m
238
+ const reSetData = /setData\s*\(\s*\{([\s\S]*?)\}\s*\)/g
239
+ while ((m = reSetData.exec(code)) !== null) {
240
+ const reKey = /([A-Za-z_$][\w$]*)\s*:/g
241
+ let k
242
+ while ((k = reKey.exec(m[1])) !== null) keys.add(k[1])
243
+ }
244
+ const reState = /\.state\.([A-Za-z_$][\w$]*)\s*=/g
245
+ while ((m = reState.exec(code)) !== null) keys.add(m[1])
246
+ }
247
+ }
248
+ for (const slot of node.slots || []) for (const c of (slot && slot.components) || []) walk(c)
249
+ for (const c of node.components || []) walk(c)
250
+ }
251
+ walk(schema.view)
252
+ return keys
253
+ }
254
+
255
+ /** 深度遍历组件树,回调 (node, inListScope) */
256
+ function walkTree(node, inListScope, visit) {
257
+ if (!node || typeof node !== 'object') return
258
+ if (node.componentId) visit(node, inListScope)
259
+ for (const slot of node.slots || []) {
260
+ const childScope = inListScope || Boolean(slot && slot.slotScope)
261
+ for (const c of (slot && slot.components) || []) walkTree(c, childScope, visit)
262
+ }
263
+ for (const c of node.components || []) walkTree(c, inListScope, visit)
264
+ }
265
+
266
+ // ---------- 检测 ----------
267
+ function analyzeCard(id) {
268
+ const schemaPath = cardSchemaPath(id)
269
+ const schema = readJson(schemaPath, null)
270
+ if (!schema) return { id, ok: false, reason: 'schema.json 不存在或无法解析', findings: [] }
271
+
272
+ const declared = collectDeclared((schema.dictionary || {}).inputParams)
273
+ const dynamicKeys = collectDynamicKeys(schema)
274
+ const findings = []
275
+ const push = (rule, node, extra) =>
276
+ findings.push({
277
+ card: id,
278
+ rule,
279
+ level: RULES[rule].level,
280
+ fix: RULES[rule].fix,
281
+ nodeId: node.componentId,
282
+ className: node.className || '',
283
+ ...extra,
284
+ })
285
+
286
+ walkTree(schema.view, false, (node, inListScope) => {
287
+ const cn = node.className || ''
288
+
289
+ // AD-01 未声明变量(列表作用域内豁免:那里的自由变量就是 item 字段)
290
+ if (!inListScope) {
291
+ const seen = new Set()
292
+ for (const { where, text, isCode } of expressionTexts(node)) {
293
+ for (const v of varsIn(text)) {
294
+ if (BUILTIN_VARS.has(v) || declared.has(v) || seen.has(v)) continue
295
+ seen.add(v)
296
+ const dynamic = dynamicKeys.has(v)
297
+ push('AD-01', node, {
298
+ where,
299
+ detail: dynamic
300
+ ? `$${v} 未在 inputParams 声明,但事件代码里有运行时写入(动态 state 字段)——确认注入时机早于首次渲染`
301
+ : `$${v} 未在 inputParams 声明,也无运行时写入痕迹——可能是从别卡复制的残留,或外部 dataResponse 直传字段`,
302
+ evidence: text.replace(/\s+/g, ' ').slice(0, 90),
303
+ // 未声明变量一律 warn:外部 dataResponse 直传字段是合法用法,
304
+ // 机器无法与「复制残留」确定区分,交人工按 detail 判定
305
+ level: 'warn',
306
+ })
307
+ }
308
+ }
309
+ }
310
+
311
+ // AD-02 越界 $_index
312
+ if (!inListScope) {
313
+ for (const { where, text } of expressionTexts(node)) {
314
+ if (text.includes('$_index')) {
315
+ push('AD-02', node, {
316
+ where,
317
+ detail: '$_index 在列表项作用域外求值为 undefined,条件恒 false',
318
+ evidence: text.replace(/\s+/g, ' ').slice(0, 90),
319
+ })
320
+ break
321
+ }
322
+ }
323
+ }
324
+
325
+ // AD-03 布局容器 + 条件显隐 → 发布产物 display 风险
326
+ const vs = readVShow(node)
327
+ if (vs && (vs.showType === '01' || vs.showType === '02') && LAYOUT_CLASSES.has(cn)) {
328
+ push('AD-03', node, {
329
+ where: `directive.v-show.showType=${vs.showType}`,
330
+ detail: `布局容器 ${cn} 的条件显隐:发布产物可见侧写死 display:'block',会压掉 .ce_* 的 flex`,
331
+ evidence: vs.expression.replace(/\s+/g, ' ').slice(0, 90),
332
+ fixHint: 'schema 无可修点;宿主需安装 display 补丁(ai-assistant-h5 src/flame/cardStylePatch.js),或改用非布局容器包裹',
333
+ })
334
+ }
335
+
336
+ // AD-04 style 里写死 display
337
+ for (const group of (node.style && node.style.properties) || []) {
338
+ for (const p of (group.itemChildren && group.itemChildren.properties) || []) {
339
+ if (p && p.itemKey === 'display') {
340
+ push('AD-04', node, {
341
+ where: `style.${group.itemKey}.display`,
342
+ detail: 'style 不应出现 display(只由 v-show 指令产生)',
343
+ evidence: JSON.stringify(p.itemValue).slice(0, 60),
344
+ })
345
+ }
346
+ }
347
+ }
348
+
349
+ // AD-05 v-show 条件为空
350
+ if (vs && (vs.showType === '01' || vs.showType === '02') && !vs.expression.trim()) {
351
+ push('AD-05', node, {
352
+ where: 'directive.v-show.condition',
353
+ detail: `showType=${vs.showType} 但 condition.expression 为空`,
354
+ evidence: JSON.stringify(vs.condition).slice(0, 80),
355
+ })
356
+ }
357
+
358
+ // AD-06 旧作用域别名 $item(排除 $$ 与已声明同名变量)
359
+ if (!declared.has('item')) {
360
+ for (const { where, text } of expressionTexts(node)) {
361
+ if (/\$item\s*\./.test(text) || /(^|[^\w$])\$item(?![\w])/.test(text)) {
362
+ push('AD-06', node, {
363
+ where,
364
+ detail: '$item 不是运行时作用域键(应为 $$ 或扁平字段名)',
365
+ evidence: text.replace(/\s+/g, ' ').slice(0, 90),
366
+ })
367
+ break
368
+ }
369
+ }
370
+ }
371
+ })
372
+
373
+ return { id, ok: true, schemaPath, schema, findings }
374
+ }
375
+
376
+ // ---------- 修复(只处理 fix==='auto' 的规则) ----------
377
+ function applyFixes(result) {
378
+ const { schema } = result
379
+ let changed = 0
380
+ const fixed = []
381
+
382
+ walkTree(schema.view, false, (node) => {
383
+ // AD-04:删掉 style 里 display 子项
384
+ for (const group of (node.style && node.style.properties) || []) {
385
+ const props = (group.itemChildren && group.itemChildren.properties) || []
386
+ const next = props.filter((p) => !(p && p.itemKey === 'display'))
387
+ if (next.length !== props.length) {
388
+ group.itemChildren.properties = next
389
+ changed += next.length !== props.length ? 1 : 0
390
+ fixed.push({ rule: 'AD-04', nodeId: node.componentId, className: node.className })
391
+ }
392
+ }
393
+
394
+ // AD-05:showType 01/02 且条件为空 → 降级 00(等价于无显隐控制)
395
+ const vs = readVShow(node)
396
+ if (vs && (vs.showType === '01' || vs.showType === '02') && !vs.expression.trim()) {
397
+ const props = (vs.directive.itemChildren && vs.directive.itemChildren.properties) || []
398
+ const st = props.find((p) => p && p.itemKey === 'showType')
399
+ if (st) {
400
+ st.itemValue = '00'
401
+ changed += 1
402
+ fixed.push({ rule: 'AD-05', nodeId: node.componentId, className: node.className })
403
+ }
404
+ }
405
+ })
406
+
407
+ if (changed) fs.writeFileSync(result.schemaPath, JSON.stringify(schema, null, 2) + '\n')
408
+ return { changed, fixed }
409
+ }
410
+
411
+ // ---------- 输出 ----------
412
+ const GROUP_ORDER = Object.keys(RULES)
413
+
414
+ function printHuman(results, fixSummary) {
415
+ let errorCount = 0
416
+ let warnCount = 0
417
+ const byRule = new Map()
418
+
419
+ for (const r of results) {
420
+ if (!r.ok) {
421
+ console.error(`✗ ${r.id}: ${r.reason}`)
422
+ continue
423
+ }
424
+ for (const f of r.findings) {
425
+ const level = f.level || RULES[f.rule].level
426
+ if (level === 'error') errorCount += 1
427
+ else warnCount += 1
428
+ if (!byRule.has(f.rule)) byRule.set(f.rule, [])
429
+ byRule.get(f.rule).push(f)
430
+ }
431
+ }
432
+
433
+ for (const rule of GROUP_ORDER) {
434
+ const list = byRule.get(rule)
435
+ if (!list || !list.length) continue
436
+ const meta = RULES[rule]
437
+ console.log(`\n[${rule}] ${meta.title} (${list.length} 处, ${meta.level}/${meta.fix})`)
438
+ const capped = list.slice(0, 12)
439
+ for (const f of capped) {
440
+ const loc = f.where ? ` ${f.where}` : ''
441
+ console.log(` - ${f.card}${f.nodeId ? '/' + f.nodeId : ''} (${f.className})${loc}`)
442
+ if (f.evidence) console.log(` ${f.evidence}`)
443
+ if (f.detail) console.log(` → ${f.detail}`)
444
+ if (f.fixHint) console.log(` ↳ ${f.fixHint}`)
445
+ }
446
+ if (list.length > capped.length) console.log(` … 其余 ${list.length - capped.length} 处同类,见 --json`)
447
+ }
448
+
449
+ if (fixSummary && fixSummary.changed) {
450
+ console.log(`\n已落盘修复 ${fixSummary.changed} 处:`)
451
+ for (const f of fixSummary.fixed) console.log(` ✓ [${f.rule}] ${f.card || ''}${f.nodeId ? '/' + f.nodeId : ''}`)
452
+ }
453
+
454
+ const autoFixable = results.flatMap((r) => r.findings || []).filter((f) => RULES[f.rule].fix === 'auto').length
455
+ console.log(`\n汇总:error ${errorCount} / warn ${warnCount}${autoFixable ? ` / 可自动修复 ${autoFixable}(加 --write)` : ''}`)
456
+
457
+ const tips = []
458
+ if (results.some((r) => (r.findings || []).some((f) => f.rule === 'AD-06')))
459
+ tips.push('AD-06 → python3 scripts/autofix_schema.py <schema> 归一 $item 别名')
460
+ if (results.some((r) => (r.findings || []).some((f) => f.rule === 'AD-01')))
461
+ tips.push('AD-01 → 补 inputParams 声明,或确认该表达式是否从别卡复制残留(需人工判定语义)')
462
+ if (results.some((r) => (r.findings || []).some((f) => f.rule === 'AD-03')))
463
+ tips.push('AD-03 → schema 无可修点,属发布链路问题:确认目标宿主已安装 display 补丁')
464
+ if (tips.length) {
465
+ console.log('\n下一步:')
466
+ for (const t of tips) console.log(' · ' + t)
467
+ }
468
+
469
+ return { errorCount, warnCount }
470
+ }
471
+
472
+ // ---------- main ----------
473
+ let ids = []
474
+ if (runAll) {
475
+ ids = fs
476
+ .readdirSync(cardsDir, { withFileTypes: true })
477
+ .filter((d) => d.isDirectory() && fs.existsSync(path.join(cardsDir, d.name, 'schema.json')))
478
+ .map((d) => d.name)
479
+ .sort()
480
+ } else {
481
+ ids = [targetId]
482
+ }
483
+
484
+ const results = ids.map((id) => {
485
+ try {
486
+ return analyzeCard(id)
487
+ } catch (e) {
488
+ return { id, ok: false, reason: e.message, findings: [] }
489
+ }
490
+ })
491
+
492
+ let fixSummary = { changed: 0, fixed: [] }
493
+ if (writeMode) {
494
+ for (const r of results) {
495
+ if (!r.ok) continue
496
+ if (!r.findings.some((f) => RULES[f.rule].fix === 'auto')) continue
497
+ const { changed, fixed } = applyFixes(r)
498
+ fixSummary.changed += changed
499
+ fixSummary.fixed.push(...fixed.map((x) => ({ ...x, card: r.id })))
500
+ // 修完重新检测,保证报告与落盘后状态一致(幂等)
501
+ r.findings = analyzeCard(r.id).findings
502
+ }
503
+ }
504
+
505
+ if (asJson) {
506
+ const flat = results.flatMap((r) => (r.findings || []).map((f) => ({ ...f, level: f.level || RULES[f.rule].level })))
507
+ console.log(
508
+ JSON.stringify(
509
+ {
510
+ ok: !flat.some((f) => f.level === 'error'),
511
+ cards: results.filter((r) => r.ok).length,
512
+ findings: flat,
513
+ fixed: fixSummary.fixed,
514
+ rules: Object.fromEntries(Object.entries(RULES).map(([k, v]) => [k, { title: v.title, level: v.level, fix: v.fix }])),
515
+ },
516
+ null,
517
+ 2
518
+ )
519
+ )
520
+ // 用 exitCode 而非 exit:大 JSON 走管道时 process.exit 会截断未 flush 的 stdout
521
+ process.exitCode = flat.some((f) => f.level === 'error') ? 1 : 0
522
+ } else {
523
+ const { errorCount } = printHuman(results, fixSummary)
524
+ process.exitCode = errorCount > 0 ? 1 : 0
525
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cardbuddy",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Local dev service for the CardBuddy card editor — decouples card CRUD (sync/save/validate/render-status/screenshot) from the static public editor, so the editor can be deployed to any static host while each developer runs a local service against their own card source.",
5
5
  "type": "module",
6
6
  "bin": {