cardbuddy 0.14.3 → 0.16.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.
- package/dist/cli.js +0 -0
- package/dist/preview-editor/assets/index-BkbBxDTT.css +1 -0
- package/dist/preview-editor/assets/index-Bro07DTa.js +313 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +3 -0
- package/dist/vendored/card-editor/runtime/adapter.js +2 -2
- package/dist/vendored/mcp/client.mjs +9 -2
- package/dist/vendored/server/core/ai-chat.js +1702 -134
- package/dist/vendored/server/core/browser.js +12 -3
- package/dist/vendored/server/core/cardFiles.js +3 -0
- package/dist/vendored/server/core/cardHistory.js +1 -0
- package/dist/vendored/server/core/cardImportMeta.js +7 -6
- package/dist/vendored/server/core/cardOpenFolder.js +1 -0
- package/dist/vendored/server/core/cardSave.js +1 -0
- package/dist/vendored/server/core/cardSync.js +3 -0
- package/dist/vendored/server/core/cardValidate.js +16 -5
- package/dist/vendored/server/core/codeBridge.js +5 -1
- package/dist/vendored/server/core/logStream.js +1 -0
- package/dist/vendored/server/core/sliceStatic.js +24 -4
- package/dist/vendored/server/core/sliceUpload.js +1 -0
- package/dist/vendored/server/core/turnJournal.js +6 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +257 -201
- package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +14 -82
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/diagnosis.mjs +207 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/layout-correction-sim.mjs +62 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/paradigm-router.mjs +76 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker-runner.mjs +31 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker.mjs +138 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +24 -20
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/telemetry.mjs +46 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +4 -1
- package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +279 -90
- package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -2
- package/dist/vendored/skills/card-skill-trainer/scripts/sink_rules.mjs +127 -0
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +208 -380
- package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +1 -15
- package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +11 -20
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +233 -61
- package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +202 -4
- package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +87 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +42 -33
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +44 -8
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +26 -6
- package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +54 -0
- package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +6 -6
- package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +33 -4
- package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +133 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +226 -19
- package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +277 -27
- package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +147 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +1015 -54
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +23 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +0 -37
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/python.mjs +99 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/adaptive-width.mjs +105 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/bindings.mjs +260 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/expr.mjs +52 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/image.mjs +96 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/registry.mjs +166 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/structure.mjs +127 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/walk.mjs +61 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/scope_alias.mjs +125 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5727 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4214 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1110 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +322 -71
- package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +87 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/readiness.mjs +634 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +5 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -11
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +272 -46
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
- package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +188 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +23 -15
- package/dist/vendored/skills/shared/exit-codes.md +79 -0
- package/dist/vendored/skills/shared/spec-contract.md +118 -0
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +43 -20
- package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +4 -4
- package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -9
- package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +14 -25
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +546 -91
- package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -5
- package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +217 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +64 -34
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-Cm0USikc.js +0 -307
- package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
- package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +0 -122
- package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +0 -153
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rules/registry.mjs —— 卡片门禁规则的**唯一权威注册表**(lint_card / preflight 全体)。
|
|
3
|
+
*
|
|
4
|
+
* ── 为什么必须有它 ───────────────────────────────────────────────────────────
|
|
5
|
+
* `SKILL.md` 与 `generation_pitfalls.md §9` 一直告诉模型「按规则号查文档」,但实测
|
|
6
|
+
* **lint 的 code 前缀与 pitfalls 的 R 号是两套互不相干的命名空间**,照号查会查到别的规则:
|
|
7
|
+
*
|
|
8
|
+
* code | lint 的真实语义 | pitfalls 同号 R 的语义
|
|
9
|
+
* --------------------------|------------------------------|--------------------------------
|
|
10
|
+
* r50-dollar-dollar-field | slotScope 用 $field 而非 $$.field | R50 列表项语义边界用实测行高
|
|
11
|
+
* r51-event-this-data-misuse| 运行态变量一律 this.state | R51 垂直节奏优先用 padding
|
|
12
|
+
* r57-opacity-expression | opacity 禁止表达式绑定 | R57 弥散纹理必须按位图还原
|
|
13
|
+
* r38-negative-margin | 负 marginTop 禁止 | R38 容器高度由内容撑开(其实对应 R42)
|
|
14
|
+
*
|
|
15
|
+
* 另有约三成 code 根本没有规则号(`icon-no-click` / `unknown-style-child` / `no-view` …),
|
|
16
|
+
* 查不到任何章节。所以「按号查文档」这条指引是坏的 —— 正确入口是**本注册表**。
|
|
17
|
+
*
|
|
18
|
+
* ── 字段 ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
* severity 'error' | 'warn' | 'info' 与实现里上报的 severity 一致(由门禁测试对账)
|
|
20
|
+
* stage 'lint' | 'preflight' | 'both' 哪几道闸会上报
|
|
21
|
+
* doc '<file>#<anchor>' | null 该规则**真正**的权威文档锚点;null = 尚无对应章节
|
|
22
|
+
* anchor 是目标文件里的标题文本前缀(如 'R25' / '九' / '1.1' / '2d'),
|
|
23
|
+
* 由 tools/tests/lint-rule-registry.test.mjs 机械校验其真实存在。
|
|
24
|
+
* 文件相对 skills/dsl-card-generator/references/。
|
|
25
|
+
*
|
|
26
|
+
* ── 维护约定 ─────────────────────────────────────────────────────────────────
|
|
27
|
+
* · 新增/改名 code **必须**同步本表,否则 lint-rule-registry 门禁失败(双向对账)。
|
|
28
|
+
* · `doc: null` 的条目数量**只许减不许增**(棘轮)——它代表「这条规则在文档里查不到」。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** doc 锚点里文件名的合法取值(避免打错文件名后校验静默变宽松)。 */
|
|
32
|
+
export const DOC_DIR = 'skills/dsl-card-generator/references'
|
|
33
|
+
|
|
34
|
+
export const RULES = {
|
|
35
|
+
/* ── 布局 / 尺寸(generation_pitfalls.md 域)──────────────────────── */
|
|
36
|
+
'r8-double-border-radius': { severity: 'info', stage: 'lint', doc: 'generation_pitfalls.md#R8' },
|
|
37
|
+
'r9-root-nonzero-padding': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R9' },
|
|
38
|
+
'r17-leaf-no-flexshrink': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R17' },
|
|
39
|
+
'r19-space-between-no-width': { severity: 'warn', stage: 'lint', doc: 'generation_pitfalls.md#R19' },
|
|
40
|
+
'r22-width100-flexshrink0': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R22' },
|
|
41
|
+
'r22b-row-child-container-no-width': { severity: 'error', stage: 'both', doc: 'generation_pitfalls.md#R22' },
|
|
42
|
+
'r35-multi-child-all-shrink0': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R35' },
|
|
43
|
+
// 语义是「文本与装饰条重叠禁止用负外边距」= pitfalls R42 的正文,不是 R38(容器高度由内容撑开)。
|
|
44
|
+
'r38-negative-margin': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R42' },
|
|
45
|
+
|
|
46
|
+
/* ── 标题 + 装饰条(ir-format.md §2d 蓝图)──────────────────────── */
|
|
47
|
+
'r42-title-underline-align': { severity: 'error', stage: 'lint', doc: 'ir-format.md#2d' },
|
|
48
|
+
'r42-title-underline-z-order': { severity: 'error', stage: 'lint', doc: 'ir-format.md#2d' },
|
|
49
|
+
'r42-title-underline-fixed-width': { severity: 'warn', stage: 'lint', doc: 'ir-format.md#2d' },
|
|
50
|
+
'r64-title-underline-flow': { severity: 'error', stage: 'lint', doc: 'ir-format.md#2d' },
|
|
51
|
+
|
|
52
|
+
/* ── 图片 / 表达式(R25 R32 R23)────────────────────────────────── */
|
|
53
|
+
'r23-no-math-round': { severity: 'error', stage: 'both', doc: 'generation_pitfalls.md#R23' },
|
|
54
|
+
'r25-image-no-value-prop': { severity: 'error', stage: 'preflight', doc: 'generation_pitfalls.md#R25' },
|
|
55
|
+
'r25-image-from-0': { severity: 'error', stage: 'both', doc: 'generation_pitfalls.md#R25' },
|
|
56
|
+
'r25-image-from-1': { severity: 'error', stage: 'both', doc: 'generation_pitfalls.md#R25' },
|
|
57
|
+
'r25-image-from-2': { severity: 'error', stage: 'both', doc: 'generation_pitfalls.md#R25' },
|
|
58
|
+
'r32-image-no-height': { severity: 'error', stage: 'both', doc: 'generation_pitfalls.md#R32' },
|
|
59
|
+
|
|
60
|
+
/* ── 切图引用同源(R31 家族,正文在 pitfalls R31)────────────────── */
|
|
61
|
+
'r31-slice-filename-space': { severity: 'error', stage: 'both', doc: 'generation_pitfalls.md#R31' },
|
|
62
|
+
'r31b-slice-url-reserved-char': { severity: 'error', stage: 'both', doc: 'generation_pitfalls.md#R31' },
|
|
63
|
+
'r31c-slice-ref-missing-file': { severity: 'error', stage: 'both', doc: 'generation_pitfalls.md#R31' },
|
|
64
|
+
'r59-slice-semantic-name': { severity: 'warn', stage: 'lint', doc: 'slices_convention.md#1.1' },
|
|
65
|
+
'r60-slice-duplicate-content': { severity: 'warn', stage: 'lint', doc: 'slices_convention.md#3.1' },
|
|
66
|
+
|
|
67
|
+
/* ── 组件契约 / 白名单 ──────────────────────────────────────────── */
|
|
68
|
+
'r28-unknown-class': { severity: 'error', stage: 'both', doc: 'generation_pitfalls.md#R28' },
|
|
69
|
+
'r28b-unknown-prop-key': { severity: 'error', stage: 'both', doc: 'component_rules.md#通用规则' },
|
|
70
|
+
'r28b-unknown-style-group': { severity: 'error', stage: 'both', doc: 'generation_pitfalls.md#2' },
|
|
71
|
+
'r28b-unknown-style-key': { severity: 'error', stage: 'both', doc: 'generation_pitfalls.md#2' },
|
|
72
|
+
'unknown-style-child': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#2' },
|
|
73
|
+
'missing-itemValueFrom': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#1' },
|
|
74
|
+
'style-itemId-mismatch': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#2' },
|
|
75
|
+
'empty-style-itemid': { severity: 'error', stage: 'preflight', doc: 'generation_pitfalls.md#2' },
|
|
76
|
+
|
|
77
|
+
/* ── 类型开关(R44 族)─────────────────────────────────────────── */
|
|
78
|
+
'r44-border-radius-no-type': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R44' },
|
|
79
|
+
'r44-border-no-isborder': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R44' },
|
|
80
|
+
'r44-shadow-no-isshadow': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R44' },
|
|
81
|
+
'r44-gradient-in-background-color': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R44' },
|
|
82
|
+
'r44-background-type-mismatch': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R44' },
|
|
83
|
+
'r44-margin-no-type': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R44' },
|
|
84
|
+
'r44-padding-no-type': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R44' },
|
|
85
|
+
|
|
86
|
+
/* ── 值格式 ────────────────────────────────────────────────────── */
|
|
87
|
+
'fmt-border-radius-tuple': { severity: 'error', stage: 'lint', doc: 'ir-format.md#1' },
|
|
88
|
+
'fmt-padding-tuple': { severity: 'error', stage: 'lint', doc: 'ir-format.md#1' },
|
|
89
|
+
'fmt-margin-tuple': { severity: 'error', stage: 'lint', doc: 'ir-format.md#1' },
|
|
90
|
+
'fmt-image-static-path-valuefrom': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R25' },
|
|
91
|
+
|
|
92
|
+
/* ── 作用域 / 绑定(rules.md §四 运行时作用域与 Input 回写)──────────
|
|
93
|
+
* 这一组的规则号(R50 / r51 / r54 / r55 / r56)**只在 rules.md 域有效**:
|
|
94
|
+
* generation_pitfalls.md 的同号 R50–R56 是训练侧另一套语义(列表项行高 / 状态卡媒体 …),
|
|
95
|
+
* 照号去 pitfalls 查会查到完全无关的章节。 */
|
|
96
|
+
'r50-dollar-dollar-field': { severity: 'error', stage: 'lint', doc: 'rules.md#四' },
|
|
97
|
+
'r50b-unnormalized-scope-alias': { severity: 'error', stage: 'lint', doc: 'rules.md#四' },
|
|
98
|
+
'r51-event-this-data-misuse': { severity: 'error', stage: 'lint', doc: 'rules.md#四' },
|
|
99
|
+
'unused-input-param': { severity: 'info', stage: 'lint', doc: 'rules.md#三' },
|
|
100
|
+
'text-empty-binding': { severity: 'info', stage: 'lint', doc: 'rules.md#三' },
|
|
101
|
+
|
|
102
|
+
/* ── 指令 / 显隐(component_rules.md 域)────────────────────────── */
|
|
103
|
+
'group-directive-bad-form': { severity: 'error', stage: 'lint', doc: 'component_rules.md#指令' },
|
|
104
|
+
'r52-vshow-condition-name-style': { severity: 'warn', stage: 'lint', doc: 'component_rules.md#指令' },
|
|
105
|
+
'r53-vshow-needs-wrapper': { severity: 'error', stage: 'lint', doc: 'rules.md#九' },
|
|
106
|
+
|
|
107
|
+
/* ── 事件(rules.md §四 + event_patterns.md 范式)───────────────── */
|
|
108
|
+
'event-mismatch': { severity: 'warn', stage: 'lint', doc: 'event_patterns.md#eventKey' },
|
|
109
|
+
'button-no-click': { severity: 'warn', stage: 'lint', doc: 'event_patterns.md#eventKey' },
|
|
110
|
+
'button-click-by-ancestor': { severity: 'info', stage: 'lint', doc: 'event_patterns.md#eventKey' },
|
|
111
|
+
'icon-no-click': { severity: 'error', stage: 'lint', doc: 'rules.md#九' },
|
|
112
|
+
'r54-list-item-must-postmessage': { severity: 'error', stage: 'lint', doc: 'rules.md#四' },
|
|
113
|
+
'r54-globalset-no-root-listener': { severity: 'error', stage: 'lint', doc: 'rules.md#四' },
|
|
114
|
+
'r55-event-no-dollar-dollar': { severity: 'error', stage: 'lint', doc: 'rules.md#四' },
|
|
115
|
+
'r56-jsapi-no-window-guard': { severity: 'error', stage: 'lint', doc: 'rules.md#四' },
|
|
116
|
+
'r57-opacity-expression': { severity: 'error', stage: 'lint', doc: 'rules.md#九' },
|
|
117
|
+
'canvas-no-onmeasured': { severity: 'warn', stage: 'lint', doc: 'previewer_contract.md#3' },
|
|
118
|
+
|
|
119
|
+
/* ── 数字语义(rules.md §十四)──────────────────────────────────── */
|
|
120
|
+
'r61-text-numeric-literal': { severity: 'warn', stage: 'lint', doc: 'rules.md#十四' },
|
|
121
|
+
|
|
122
|
+
/* ── 数据绑定完备 / 列表数组化(ir-format.md §2f;判定共享 lib/rules/bindings.mjs)── */
|
|
123
|
+
'binding-repeater-no-scope': { severity: 'error', stage: 'lint', doc: 'ir-format.md#2e' },
|
|
124
|
+
'binding-array-unconsumed': { severity: 'warn', stage: 'lint', doc: 'ir-format.md#2e' },
|
|
125
|
+
'binding-list-static-expansion': { severity: 'warn', stage: 'lint', doc: 'ir-format.md#2e' },
|
|
126
|
+
|
|
127
|
+
/* ── 水平 margin 撞 width:100%(rules.md §十六 R63)────────────── */
|
|
128
|
+
'r63-width100-with-hmargin': { severity: 'error', stage: 'lint', doc: 'rules.md#十六' },
|
|
129
|
+
|
|
130
|
+
/* ── 卡片根样式 / 元素清单(preflight 生成前检查)───────────────── */
|
|
131
|
+
'r29-element-count': { severity: 'warn', stage: 'preflight', doc: 'generation_pitfalls.md#R29' },
|
|
132
|
+
'r30-icon-no-action': { severity: 'warn', stage: 'preflight', doc: 'generation_pitfalls.md#R30' },
|
|
133
|
+
'r28-row-as-divider': { severity: 'warn', stage: 'preflight', doc: 'generation_pitfalls.md#R28' },
|
|
134
|
+
'r33-root-missing-border': { severity: 'warn', stage: 'preflight', doc: 'generation_pitfalls.md#R33' },
|
|
135
|
+
'r33-root-missing-shadow': { severity: 'warn', stage: 'preflight', doc: 'generation_pitfalls.md#R33' },
|
|
136
|
+
'r43-card-transparent-background': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R43' },
|
|
137
|
+
|
|
138
|
+
/* ── 脚手架残留(pitfalls §7 工具链兜底)────────────────────────── */
|
|
139
|
+
'scaffold-placeholder': { severity: 'warn', stage: 'lint', doc: 'generation_pitfalls.md#7' },
|
|
140
|
+
'scaffold-placeholder-expr': { severity: 'warn', stage: 'lint', doc: 'generation_pitfalls.md#7' },
|
|
141
|
+
|
|
142
|
+
/* ── 宽度自适应(ir-format.md §2f;判定共享 lib/rules/adaptive-width.mjs)──────── */
|
|
143
|
+
'r66-fullwidth-px': { severity: 'error', stage: 'lint', doc: 'ir-format.md#2f' },
|
|
144
|
+
|
|
145
|
+
/* ── 结构与事件代码(pitfalls 教训沉淀 2026-09-25;判定共享 lib/rules/structure.mjs)── */
|
|
146
|
+
'r65-slot-multi-children': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#27' },
|
|
147
|
+
'r34-null-child': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#R34' },
|
|
148
|
+
'style-duplicate-child': { severity: 'error', stage: 'lint', doc: 'generation_pitfalls.md#20' },
|
|
149
|
+
'r50c-alertdata-whole-state': { severity: 'warn', stage: 'lint', doc: 'generation_pitfalls.md#10' },
|
|
150
|
+
'r20-icon-no-rect': { severity: 'warn', stage: 'lint', doc: 'generation_pitfalls.md#R20' },
|
|
151
|
+
'r38-root-fixed-height': { severity: 'warn', stage: 'lint', doc: 'generation_pitfalls.md#R38' },
|
|
152
|
+
|
|
153
|
+
/* ── 尚无对应文档章节(棘轮:只许减不许增)─────────────────────── */
|
|
154
|
+
'no-view': { severity: 'error', stage: 'lint', doc: null },
|
|
155
|
+
'text-no-color': { severity: 'info', stage: 'lint', doc: null },
|
|
156
|
+
'multitext-item-no-default': { severity: 'error', stage: 'lint', doc: null },
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 规则总数 / 尚无文档锚点的条目数(门禁棘轮用)。 */
|
|
160
|
+
export function registryStats() {
|
|
161
|
+
const codes = Object.keys(RULES)
|
|
162
|
+
const undoc = codes.filter((c) => !RULES[c].doc)
|
|
163
|
+
const byStage = { lint: 0, preflight: 0, both: 0 }
|
|
164
|
+
for (const c of codes) byStage[RULES[c].stage] += 1
|
|
165
|
+
return { total: codes.length, undocumented: undoc.length, undocumentedCodes: undoc.sort(), byStage }
|
|
166
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rules/structure.mjs —— 结构与事件代码的机械判据(pitfalls 教训 → 机器 gate)。
|
|
3
|
+
*
|
|
4
|
+
* 2026-09-25 沉淀:generation_pitfalls.md 里「可机械判定、但此前只有文档约束」的 6 条。
|
|
5
|
+
* 全部消费 walkNodes/hit 工厂(lib/rules/walk.mjs),lint 与 preflight 共享同一实现。
|
|
6
|
+
*
|
|
7
|
+
* | code | severity | 来源 | 判定 |
|
|
8
|
+
* |---|---|---|---|
|
|
9
|
+
* | r65-slot-multi-children | error | pitfalls §27(汇率卡事故) | slotScope 循环插槽平铺 ≥2 子节点 |
|
|
10
|
+
* | r34-null-child | error | pitfalls R34 | 组件树含 null/undefined 子节点 |
|
|
11
|
+
* | style-duplicate-child | error | pitfalls §20.4 | style 组内同名子项重复(渲染器只取其一) |
|
|
12
|
+
* | r50c-alertdata-whole-state | warn | pitfalls §10(transfersjump 事故) | 子卡 data:this.state 整体回传 / 父卡 state.x=res.data 整体赋值 |
|
|
13
|
+
* | r20-icon-no-rect | warn | pitfalls R20 | Icon 缺显式 style.rect(默认 32×32) |
|
|
14
|
+
* | r38-root-fixed-height | warn | pitfalls R38 | 根节点写死 rect.height(应由内容撑开) |
|
|
15
|
+
*/
|
|
16
|
+
import { walkNodes, hit } from './walk.mjs'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* §27:slotScope 循环模板的 slot 下禁止平铺 ≥2 个子节点。
|
|
20
|
+
* slot.components[] 是一个整体模板,会原样套到 value 数组每一项上重复渲染;
|
|
21
|
+
* 平铺 N 个子节点等价于每个 item 渲染 N 个并列块,行与分隔线互相穿插。
|
|
22
|
+
*/
|
|
23
|
+
export function collectSlotMultiChildren(view) {
|
|
24
|
+
const out = []
|
|
25
|
+
walkNodes(view, (n) => {
|
|
26
|
+
if (n.className !== 'ListLayout' && n.className !== 'GridLayout') return
|
|
27
|
+
for (const slot of n.slots || []) {
|
|
28
|
+
if (!slot.slotScope) continue
|
|
29
|
+
const kids = slot.components || []
|
|
30
|
+
if (kids.length <= 1) continue
|
|
31
|
+
const shapes = kids.map((k) => k && k.className).join('/')
|
|
32
|
+
out.push(hit('r65-slot-multi-children', 'error', n.componentId || '',
|
|
33
|
+
`${n.className} "${n.componentId || ''}" 的 slotScope 循环插槽平铺了 ${kids.length} 个子节点(${shapes})—— slot 是整体模板,原样套到每一项重复渲染,平铺等价于每项渲染 ${kids.length} 个并列块、互相穿插错位(pitfalls §27)。正路:全部元素包进一个 Column/Row 容器,slot children 仅此一个容器。`))
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** R34:组件树禁止出现 null/undefined 子节点(可选 slice 为空时提供占位,禁止 null 进插槽)。 */
|
|
40
|
+
export function collectNullChildren(view) {
|
|
41
|
+
const out = []
|
|
42
|
+
walkNodes(view, (n) => {
|
|
43
|
+
const arrays = [n.components, ...(n.slots || []).map((s) => s && s.components)]
|
|
44
|
+
for (const arr of arrays) {
|
|
45
|
+
if (!Array.isArray(arr)) continue
|
|
46
|
+
const bad = arr.filter((c) => c === null || c === undefined).length
|
|
47
|
+
if (!bad) continue
|
|
48
|
+
out.push(hit('r34-null-child', 'error', n.componentId || '',
|
|
49
|
+
`${n.className || 'view'} "${n.componentId || ''}" 的子组件数组含 ${bad} 个 null/undefined —— 运行期渲染挂起(pitfalls R34:可选 slice 命中但 PNG 未导出时,禁止 imageFromSlice 的 null 直接进插槽,须可见占位或 Icon 兜底)。`))
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** §20.4:style 组内同名子项唯一 —— 追加第二个同名子项会让渲染器取到空值/绑定串原样。 */
|
|
56
|
+
export function collectDuplicateStyleChildren(view) {
|
|
57
|
+
const out = []
|
|
58
|
+
walkNodes(view, (n) => {
|
|
59
|
+
for (const g of (n.style && n.style.properties) || []) {
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
for (const c of (g.itemChildren && g.itemChildren.properties) || []) {
|
|
62
|
+
if (seen.has(c.itemKey)) {
|
|
63
|
+
out.push(hit('style-duplicate-child', 'error', n.componentId || '',
|
|
64
|
+
`${n.className || 'view'} "${n.componentId || ''}" style 组 "${g.itemKey || '?'}" 内存在重复子项 itemKey:"${c.itemKey}" —— 渲染器对同名子项只取其一,绑定值会被空值/静态默认覆盖(pitfalls §20.4:绑定必须改写既有子项,禁止追加)。`))
|
|
65
|
+
}
|
|
66
|
+
seen.add(c.itemKey)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* §10:子卡回传按需裁剪、父卡回调按字段提取。
|
|
75
|
+
* 子卡侧:`closeAlertCard` 的 `data: this.state` 整体回传 = 整个子卡作用域 + 父卡注入 payload 一起甩给父卡。
|
|
76
|
+
* 父卡侧:`state.X = res.data`(整体 payload 直接挂 state)—— 只拦精确整体赋值,
|
|
77
|
+
* `res.data.<字段>` 不拦(无法机械区分字段提取与子对象整体赋值,交给 AI 语义复核)。
|
|
78
|
+
*/
|
|
79
|
+
export function collectAlertDataWholeState(view) {
|
|
80
|
+
const out = []
|
|
81
|
+
walkNodes(view, (n) => {
|
|
82
|
+
for (const ea of n.eventactions || []) {
|
|
83
|
+
const act = ea && ea.action
|
|
84
|
+
if (!act || act.actionType !== 'customAction') continue
|
|
85
|
+
const fn = (act.properties || []).find((p) => p.itemKey === 'function') || {}
|
|
86
|
+
const iv = fn.itemValue || {}
|
|
87
|
+
const code = String(iv.code || iv.compiled || '')
|
|
88
|
+
if (!code) continue
|
|
89
|
+
if (/closeAlertCard[\s\S]{0,120}data:\s*this\.state\b/.test(code)) {
|
|
90
|
+
out.push(hit('r50c-alertdata-whole-state', 'warn', n.componentId || '',
|
|
91
|
+
`${n.className} "${n.componentId || ''}" closeAlertCard 用 data: this.state 整体回传 —— 会把整个子卡作用域 + 父卡注入 payload(list/title/parentInstanceId…)一起甩给父卡,父卡字段错乱(pitfalls §10 transfersjump 事故)。正路:显式构造 data,只含父卡需要的字段(如 data: { selected: { acctNum: this.state.acctNum, … } })。`))
|
|
92
|
+
}
|
|
93
|
+
const m = code.match(/(?:this|that)\.state\.[A-Za-z_$][\w$]*\s*=\s*res\.data\b(?!\s*[.[])/)
|
|
94
|
+
if (m) {
|
|
95
|
+
out.push(hit('r50c-alertdata-whole-state', 'warn', n.componentId || '',
|
|
96
|
+
`${n.className} "${n.componentId || ''}" 父卡回调整体赋值(…${m[0].replace(/\s+/g, ' ')})—— res.data 未按字段显式提取就挂上 state,子卡回传一旦裁剪/扩容即字段错乱(pitfalls §10)。正路:逐字段提取(that.state.currCard = { acctNum: res.data.selected.acctNum, … })。`))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
return out
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** R20:Icon 必须带显式 style.rect(width+height),否则按 svg-symbols 默认尺寸(约 32×32)渲染。 */
|
|
104
|
+
export function collectIconNoRect(view) {
|
|
105
|
+
const out = []
|
|
106
|
+
walkNodes(view, (n) => {
|
|
107
|
+
if (n.className !== 'Icon') return
|
|
108
|
+
const rect = ((n.style || {}).properties || []).find((g) => g.itemKey === 'rect')
|
|
109
|
+
const kids = (rect && rect.itemChildren && rect.itemChildren.properties) || []
|
|
110
|
+
const has = (k) => kids.some((c) => c.itemKey === k && String(c.itemValue ?? '') !== '')
|
|
111
|
+
if (has('width') && has('height')) return
|
|
112
|
+
out.push(hit('r20-icon-no-rect', 'warn', n.componentId || '',
|
|
113
|
+
`Icon "${n.componentId || ''}" 缺显式 style.rect(${!has('width') ? 'width' : ''}${!has('width') && !has('height') ? '+' : ''}${!has('height') ? 'height' : ''})—— 渲染器按 svg-symbols 默认尺寸(约 32×32)渲染,与设计稿标注不符(pitfalls R20:Icon 尺寸必须读 spec 标注显式写入)。`))
|
|
114
|
+
})
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** R38:根节点高度由内容撑开 —— 写死 rect.height(非 auto/非表达式)在内容行数变化时裁切。 */
|
|
119
|
+
export function collectRootFixedHeight(view) {
|
|
120
|
+
if (!view || typeof view !== 'object') return []
|
|
121
|
+
const rect = ((view.style || {}).properties || []).find((g) => g.itemKey === 'rect')
|
|
122
|
+
const kids = (rect && rect.itemChildren && rect.itemChildren.properties) || []
|
|
123
|
+
const h = kids.find((c) => c.itemKey === 'height')
|
|
124
|
+
if (!h || h.itemValueFrom === '1' || h.itemValue == null || String(h.itemValue) === 'auto') return []
|
|
125
|
+
return [hit('r38-root-fixed-height', 'warn', view.componentId || '',
|
|
126
|
+
`根节点写死 rect.height="${h.itemValue}" —— 容器高度应由内容撑开(height:auto,纵向尺寸来自子内容 + gap + 标注 padding),写死整卡高度在内容行数变化时裁切或留白(pitfalls R38;禁止为了对齐截图写死整卡高度)。`)]
|
|
127
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rules/walk.mjs —— schema 节点遍历的共享实现(lint_card / preflight / readiness 单一来源)。
|
|
3
|
+
*
|
|
4
|
+
* 三个门禁脚本各自有一份 `walk`,签名与遍历序都一致(先 components 再 slots);
|
|
5
|
+
* 抽到一处避免「同一个节点集在三处算出不同命中」。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 深度优先遍历 schema 节点树。
|
|
10
|
+
* @param {object} node 起始节点(通常 schema.view)
|
|
11
|
+
* @param {(node: object, parent: object|null) => void} visitor
|
|
12
|
+
* @param {object|null} parent
|
|
13
|
+
*/
|
|
14
|
+
export function walkNodes(node, visitor, parent = null) {
|
|
15
|
+
if (!node || typeof node !== 'object') return
|
|
16
|
+
visitor(node, parent)
|
|
17
|
+
for (const c of node.components || []) walkNodes(c, visitor, node)
|
|
18
|
+
for (const s of node.slots || []) for (const c of s.components || []) walkNodes(c, visitor, node)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 收集树内全部节点(含起始节点),供只需平铺清单的消费方使用。 */
|
|
22
|
+
export function collectNodes(root) {
|
|
23
|
+
const out = []
|
|
24
|
+
walkNodes(root, (n) => out.push(n))
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 默认插槽直接子节点(Root.components 与普通节点 slots[0].components 两种形态)。 */
|
|
29
|
+
export function directChildren(node) {
|
|
30
|
+
if (!node) return []
|
|
31
|
+
const slot = (node.slots || [])[0]
|
|
32
|
+
if (slot && Array.isArray(slot.components)) return slot.components
|
|
33
|
+
return Array.isArray(node.components) ? node.components : []
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 取 component properties 里的某个 itemKey。 */
|
|
37
|
+
export function findProp(node, key) {
|
|
38
|
+
for (const p of node?.properties || []) if (p.itemKey === key) return p
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 取 style.<group>.<child> 子项节点。 */
|
|
43
|
+
export function findStyleChild(node, group, child) {
|
|
44
|
+
for (const g of node?.style?.properties || []) {
|
|
45
|
+
if (g.itemKey !== group) continue
|
|
46
|
+
for (const c of g.itemChildren?.properties || []) if (c.itemKey === child) return c
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 归一化命中对象 —— 各门禁脚本消费同一形状:
|
|
53
|
+
* lint_card → { severity, code, message, componentId }
|
|
54
|
+
* preflight → check(code, severity, message, detail)
|
|
55
|
+
*
|
|
56
|
+
* `componentId` 刻意做成**位置参数**:早先放在 extra 里时调用方漏传过一次,
|
|
57
|
+
* 表现为 lint 报「componentId 为空」而 preflight 毫无察觉(它不消费该字段)。
|
|
58
|
+
*/
|
|
59
|
+
export function hit(code, severity, componentId, message, extra = {}) {
|
|
60
|
+
return { code, severity, componentId: componentId || '', message, detail: '', ...extra }
|
|
61
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scope_alias.mjs — 列表作用域别名归一化(**唯一实现**,Python 侧 autofix_schema.py 逐条对齐)。
|
|
3
|
+
*
|
|
4
|
+
* ── 为什么是「归一化」而不是「报错禁止」 ───────────────────────────────
|
|
5
|
+
* 2026-09-24 复盘:模型写出 `$item.xxx`,根因不是模型笨 —— 是**我们自己**在系统提示词 /
|
|
6
|
+
* ir-format.md 里手写了一条不存在的契约(提交 56e9894「子树内 $item.xxx / $_index」)。
|
|
7
|
+
* 权威证据:全仓 50 张真卡的 ListLayout 插槽内绑定**全部**是扁平字段名(`$name` / `$label` /
|
|
8
|
+
* `$accountNum` / `$card_name`),`$item.` 出现 **0 次**。
|
|
9
|
+
*
|
|
10
|
+
* 上一版处理方式是「expand_ir exit=3 中止 + lint 报 ERROR + 三份文档写满 ⛔ 没有 $item」——
|
|
11
|
+
* 那等于**教错了再罚学生**:模型必须先知道正确答案才不被打回,而正确答案本来就不该由它记住。
|
|
12
|
+
*
|
|
13
|
+
* 正确做法:IR 是本编译器的输入格式,编译器对**公认别名**做归一化即可。
|
|
14
|
+
* 这不是猜测 —— `$item.x` 在 listScope 里只有一种可能语义(当前项字段 x),
|
|
15
|
+
* 因为运行时 `makeSlotScope` 的作用域是 `外层 ∪ item字段展开 ∪ {$$, _index}`,
|
|
16
|
+
* **根本没有 `item` 这个键**。所以重写是语义保持的,不是"帮模型猜"。
|
|
17
|
+
*
|
|
18
|
+
* ── 契约 ─────────────────────────────────────────────────────────
|
|
19
|
+
* 规范形(落到 schema.json 的形态):`$<字段名>` / `$$` / `$_index`
|
|
20
|
+
* 可接受别名(作者侧随便写):`$item.<字段>` → `$<字段>`;`$item` → `$$`;`$item._index` → `$_index`
|
|
21
|
+
*
|
|
22
|
+
* ── 唯一的例外闸 ───────────────────────────────────────────────────
|
|
23
|
+
* 若卡片**真的声明了**名为 `item` 的根作用域变量(dictionary.inputParams[].key === 'item'),
|
|
24
|
+
* 那么 `$item.x` 是合法取值,**一律不动**。这把「归一化」与「覆盖用户数据」彻底分开。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 归一化一条字符串。纯函数,无副作用。
|
|
29
|
+
*
|
|
30
|
+
* 改写顺序不可交换:`$item._index` 必须先于 `$item.`;
|
|
31
|
+
* 裸 `$item` 的规则必须最后,且带前瞻,避免把 `$itemFoo` 误当别名。
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ 三条正则**必须显式写 `[A-Za-z0-9_]`,不许用 `\w` / `\b`**:
|
|
34
|
+
* JS 的 `\w` 是 ASCII 集,Python 的 `\w` 默认是 Unicode 集(含中文)。
|
|
35
|
+
* 用 `\w` 会让同一串 `$item名称` 在两边得到不同结果(JS 改写、Python 不改写),
|
|
36
|
+
* 这正是「口径同源」要防的分叉。Python 侧 autofix_schema.py 逐字复用这三条。
|
|
37
|
+
*
|
|
38
|
+
* @param {string} s 原字符串
|
|
39
|
+
* @returns {{ text: string, hits: string[] }} 归一化结果与被命中的原文(去重)
|
|
40
|
+
*/
|
|
41
|
+
export function rewriteItemAlias(s) {
|
|
42
|
+
if (typeof s !== 'string' || !s.includes('$item')) return { text: s, hits: [] }
|
|
43
|
+
const before = s
|
|
44
|
+
let out = s
|
|
45
|
+
// ⚠️ 必须用**函数替换器**,不能用字符串替换串:`String.replace` 会把替换串里的
|
|
46
|
+
// `$$` 解释成「一个字面 $」(还有 `$&`/`` $` ``/`$'`/`$1` 等模式),于是
|
|
47
|
+
// `.replace(re, '$$')` 实际只产出 `$` —— 而 Python 的 `re.sub` 里 `$` 不是特殊字符,
|
|
48
|
+
// 同一份规则在两侧结果不同(本文件的测试就是这么抓到的)。
|
|
49
|
+
// 函数替换器不做任何模式展开,天然与 Python 行为对齐。
|
|
50
|
+
out = out.replace(/\$item\._index(?![A-Za-z0-9_])/g, () => '$_index')
|
|
51
|
+
out = out.replace(/\$item\./g, () => '$')
|
|
52
|
+
out = out.replace(/\$item(?![A-Za-z0-9_$])/g, () => '$$')
|
|
53
|
+
if (out === before) return { text: s, hits: [] }
|
|
54
|
+
return { text: out, hits: [before] }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 不做改写的结构键:它们承载的是 id / 名称 / 类型等标识,不是绑定表达式。
|
|
59
|
+
* (JS 与 Python 两侧必须逐字一致;改动请同步 autofix_schema.py。)
|
|
60
|
+
*/
|
|
61
|
+
export const SKIP_KEYS = new Set([
|
|
62
|
+
'id',
|
|
63
|
+
'name',
|
|
64
|
+
'itemId',
|
|
65
|
+
'itemKey',
|
|
66
|
+
'itemName',
|
|
67
|
+
'itemType',
|
|
68
|
+
'itemValueFrom',
|
|
69
|
+
'componentId',
|
|
70
|
+
'className',
|
|
71
|
+
'eventKey',
|
|
72
|
+
'eventName',
|
|
73
|
+
'actionType',
|
|
74
|
+
'slotKey',
|
|
75
|
+
'type',
|
|
76
|
+
'desc',
|
|
77
|
+
'remark',
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 递归归一化任意 IR / schema 结构里的作用域别名(原地改写)。
|
|
82
|
+
*
|
|
83
|
+
* @param {*} node 待处理结构(IR 或 schema,任意深度)
|
|
84
|
+
* @param {object} [opts]
|
|
85
|
+
* @param {Set<string>} [opts.knownRoots] 根作用域变量名集合;含 'item' 时整体跳过(见头注释「唯一的例外闸」)
|
|
86
|
+
* @param {string} [opts.at] 路径前缀(内部递归用)
|
|
87
|
+
* @returns {{ count: number, samples: Array<{ at: string, before: string, after: string }> }}
|
|
88
|
+
*/
|
|
89
|
+
export function normalizeItemAliases(node, { knownRoots, at = '' } = {}) {
|
|
90
|
+
const result = { count: 0, samples: [] }
|
|
91
|
+
if (knownRoots && knownRoots.has('item')) return result
|
|
92
|
+
walk(node, at, result)
|
|
93
|
+
return result
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function walk(node, at, result) {
|
|
97
|
+
if (typeof node === 'string') return // 由父级带 key 处理,避免丢掉 key 上下文
|
|
98
|
+
if (Array.isArray(node)) {
|
|
99
|
+
node.forEach((v, i) => {
|
|
100
|
+
if (typeof v === 'string') applyTo(node, i, `${at}[${i}]`, result)
|
|
101
|
+
else walk(v, `${at}[${i}]`, result)
|
|
102
|
+
})
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (node && typeof node === 'object') {
|
|
106
|
+
for (const [k, v] of Object.entries(node)) {
|
|
107
|
+
if (typeof v === 'string') {
|
|
108
|
+
if (SKIP_KEYS.has(k)) continue
|
|
109
|
+
applyTo(node, k, at ? `${at}.${k}` : k, result)
|
|
110
|
+
} else {
|
|
111
|
+
walk(v, at ? `${at}.${k}` : k, result)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function applyTo(container, key, at, result) {
|
|
118
|
+
const { text, hits } = rewriteItemAlias(container[key])
|
|
119
|
+
if (!hits.length) return
|
|
120
|
+
container[key] = text
|
|
121
|
+
result.count += hits.length
|
|
122
|
+
if (result.samples.length < 20) {
|
|
123
|
+
result.samples.push({ at, before: hits[0], after: text })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* (如 "99.切图/位图/卡片/详情/顶部-弥散@3x.png")前缀不同,仅 basename 一致,
|
|
14
14
|
* 故用 basename(去 @Nx 后缀)匹配,而非整体字符串相等。
|
|
15
15
|
*/
|
|
16
|
+
import { sanitizeSliceFileName } from './slice_dedupe.mjs'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* 归一化切片 basename,用于元素名 ↔ 导出文件名 匹配:
|
|
@@ -72,10 +73,21 @@ export function sliceFlatName(exportRelPath) {
|
|
|
72
73
|
return String(exportRelPath || '').replace(/\//g, '_')
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* 导出相对路径 → schema 引用路径 `/slices/<扁平名>`。
|
|
78
|
+
*
|
|
79
|
+
* **引用名 = 落盘名(单一实际来源)**:产出前一律过 `sanitizeSliceFileName()`
|
|
80
|
+
* (`lib/slice_dedupe.mjs`),与 `crop_slices.mjs` / `expand_ir.syncReferencedSlices` /
|
|
81
|
+
* `generate_cards.collectSlicesToSrcSlices` 的落盘名同源 —— 这条等式是 `r31c` 能被机械验证的前提。
|
|
82
|
+
*
|
|
83
|
+
* ⚠️ 历史实现(2026-09-21 废弃)曾把 `#` / `?` 编码成 `%23` / `%3F`,走「磁盘留字面 `#`、
|
|
84
|
+
* 靠服务端 decodeURIComponent 桥接」的老路。它有三个致命伤:
|
|
85
|
+
* ① `sliceNameFromRef()` 会先 decode、`%23` 解码回 `#`,照样被 `r31b` 拦(自己产自己拦);
|
|
86
|
+
* ② 引用名(`%23999`)与磁盘名(`#999`)**不同源**,任何一层不做 decode(CDN / 其他静态服务)
|
|
87
|
+
* 立刻 404,且盘上仍留着字面 `#` 这种到处出问题的文件名;
|
|
88
|
+
* ③ 字面 `%` 还会被二次编码成 `%2523`。
|
|
89
|
+
* 故统一为「禁用保留字符 + 消毒改名」:`警示-#999` → `警示-999`(语义化场景应为 `iconWarning@2x`)。
|
|
90
|
+
*/
|
|
76
91
|
export function sliceRefPath(exportRelPath) {
|
|
77
|
-
return '/slices/' + sliceFlatName(exportRelPath)
|
|
78
|
-
.replace(/ /g, '_')
|
|
79
|
-
.replace(/#/g, '%23')
|
|
80
|
-
.replace(/\?/g, '%3F')
|
|
92
|
+
return '/slices/' + sanitizeSliceFileName(sliceFlatName(exportRelPath))
|
|
81
93
|
}
|
|
@@ -49,6 +49,68 @@ export function checkSliceName(name) {
|
|
|
49
49
|
return { semantic: true, reason: '' }
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/* ── URL 保留字符:切图「静默不显示」的头号根因(2026-09-21 实测坐实)────────────
|
|
53
|
+
*
|
|
54
|
+
* 事故:卡片引用 `/slices/警示-#999.png`(名字沿用设计稿图层名)。`#` 在 URL 里是 fragment
|
|
55
|
+
* 分隔符,浏览器实际发出的是 `/slices/警示-`(percent-encoded),`#999.png` 根本没出网。
|
|
56
|
+
* 又因为切图未命中时静态中间件 `next()` 落到 SPA 兜底 → **HTTP 200 + HTML**,于是
|
|
57
|
+
* 「图片空白 + 网络面板全 200 + 零报错 + 门禁全绿」,30 分钟无人看出。
|
|
58
|
+
*
|
|
59
|
+
* 该集合只放「会改变 URL 解析语义」的字符 —— 中文名**不在**其中(浏览器会正常 percent-encode):
|
|
60
|
+
* # 之后整段是 fragment,不发服务器
|
|
61
|
+
* ? 之后整段是 query,不发服务器
|
|
62
|
+
* % 触发 percent-decode,非法编码会 400 / 名字被改写
|
|
63
|
+
* & 在 query 语义下是参数分隔符
|
|
64
|
+
* + 在 query 语义下被解码成空格
|
|
65
|
+
*/
|
|
66
|
+
export const SLICE_URL_RESERVED_CHARS = '#?%&+'
|
|
67
|
+
|
|
68
|
+
/** 命中 URL 保留字符 → 返回命中的字符数组(去重);否则 null。 */
|
|
69
|
+
export function findUrlReservedChars(name) {
|
|
70
|
+
const hit = [...new Set([...String(name || '')].filter((c) => SLICE_URL_RESERVED_CHARS.includes(c)))]
|
|
71
|
+
return hit.length ? hit : null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 把切图文件名消毒成 URL 安全形态(保留扩展名;中文/语义化命名不动)。
|
|
76
|
+
*
|
|
77
|
+
* **调用方必须用返回名重写 schema 引用**:落盘名与 `/slices/` 引用名必须逐字一致,
|
|
78
|
+
* 只改名不改引用 = 从「假 200」换成「真 404」,图还是不出来。
|
|
79
|
+
*
|
|
80
|
+
* @param {string} name 原始文件名(可带或不带扩展名)
|
|
81
|
+
* @returns {string} URL 安全文件名(无扩展名时补 .png)
|
|
82
|
+
*/
|
|
83
|
+
export function sanitizeSliceFileName(name) {
|
|
84
|
+
const raw = String(name || '').trim()
|
|
85
|
+
const ext = path.extname(raw)
|
|
86
|
+
const base = ext ? raw.slice(0, -ext.length) : raw
|
|
87
|
+
let safe = base
|
|
88
|
+
for (const ch of SLICE_URL_RESERVED_CHARS) {
|
|
89
|
+
if (!safe.includes(ch)) continue
|
|
90
|
+
safe = safe.split(ch).join(ch === '%' ? 'pct' : ch === '&' ? 'and' : ch === '+' ? 'plus' : '-')
|
|
91
|
+
}
|
|
92
|
+
// 空白同样会断裂 CSS url()(R31),一并收成连字符
|
|
93
|
+
safe = safe.replace(/\s+/g, '-')
|
|
94
|
+
// 控制字符与 Windows 文件名禁用字符:直接剔除
|
|
95
|
+
safe = safe.replace(/[\u0000-\u001f\u007f<>:"/\\|*]/g, '')
|
|
96
|
+
// 折叠连续连字符、去掉首尾连字符(`警示-#999` → `警示-999`)
|
|
97
|
+
safe = safe.replace(/-{2,}/g, '-').replace(/^[-.]+|[-.]+$/g, '')
|
|
98
|
+
return (safe || 'slice') + (ext || '.png')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 切图名同源判定:两侧都归一到消毒名后比较。
|
|
103
|
+
*
|
|
104
|
+
* 用途:落盘端按 **schema 里的引用名** 匹配源文件(`collectSlicesToSrcSlices` / `syncReferencedSlices`)。
|
|
105
|
+
* 引用名是消毒后的(`警示-999.png`),而要素包源文件仍是原名(`警示-#999.png`)——
|
|
106
|
+
* 直接字符串比较会**匹配不上 → 漏拷 → 引用悬空**(r31c)。归一后比较让两边都认得出来。
|
|
107
|
+
*
|
|
108
|
+
* `sanitizeSliceFileName` 幂等(无保留字符/空格的名字归一后不变),故可安全反复调用。
|
|
109
|
+
*/
|
|
110
|
+
export function sliceNameMatches(candidate, wanted) {
|
|
111
|
+
return sanitizeSliceFileName(candidate) === sanitizeSliceFileName(wanted)
|
|
112
|
+
}
|
|
113
|
+
|
|
52
114
|
/** 计算文件内容 sha256(去重的唯一权威判据)。 */
|
|
53
115
|
export function sha256File(file) {
|
|
54
116
|
return createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
@@ -145,3 +207,43 @@ export function sliceNameFromRef(ref) {
|
|
|
145
207
|
try { name = decodeURIComponent(name) } catch { /* 非法编码保持原样 */ }
|
|
146
208
|
return name
|
|
147
209
|
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 远端切图引用判定:已上传 FinMall(@filestore)、外链(http/https/protocol-relative)、内联 data URI。
|
|
213
|
+
* 这类引用天然没有本地文件,不能参与「本地 src/slices 里是否存在」的判定(否则满仓误报)。
|
|
214
|
+
*/
|
|
215
|
+
export function isRemoteSliceRef(ref) {
|
|
216
|
+
const s = String(ref || '')
|
|
217
|
+
return /^(https?:)?\/\//i.test(s) || s.includes('@filestore') || s.startsWith('data:')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 文件名去扩展名(`icon@2x.png` → `icon@2x`)。 */
|
|
221
|
+
export function stemOf(file) {
|
|
222
|
+
const s = String(file || '')
|
|
223
|
+
return path.extname(s) ? s.slice(0, -path.extname(s).length) : s
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 建切片目录的查找索引(一次 readdir,批量校验复用)。
|
|
228
|
+
* @returns {{ files: string[], names: Set<string>, stems: Set<string> }}
|
|
229
|
+
*/
|
|
230
|
+
export function buildSliceFileIndex(dir) {
|
|
231
|
+
const files = dir && fs.existsSync(dir) ? fs.readdirSync(dir) : []
|
|
232
|
+
return { files, names: new Set(files), stems: new Set(files.map(stemOf)) }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `/slices/<name>` 引用在切片目录里是否有对应文件。
|
|
237
|
+
* 无扩展名引用(`/slices/iconArrowRight`)按「同名去扩展名」再匹配一轮 —— schema 允许这么写。
|
|
238
|
+
*
|
|
239
|
+
* 存在的意义:R31 拦「名字带空格」、R31b 拦「名字带 URL 保留字符」,都是「名字写了但用不了」;
|
|
240
|
+
* 这里补「这个名字在磁盘上就没有」——渲染时 404,图片位置空白且控制台零报错。
|
|
241
|
+
* 调用方负责先用 isRemoteSliceRef 过滤远端引用。
|
|
242
|
+
* @returns {{ resolved: boolean, name: string, stem: string }}
|
|
243
|
+
*/
|
|
244
|
+
export function resolveSliceRefOnDisk(index, ref) {
|
|
245
|
+
const name = path.basename(sliceNameFromRef(ref))
|
|
246
|
+
const stem = stemOf(name)
|
|
247
|
+
if (!name) return { resolved: false, name, stem }
|
|
248
|
+
return { resolved: index.names.has(name) || index.stems.has(stem), name, stem }
|
|
249
|
+
}
|