@qijenchen/design-system 0.1.0-beta.69 → 0.1.0-beta.71

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 (28) hide show
  1. package/ds-canonical/fork/governance.lock +112 -0
  2. package/ds-canonical/fork/hooks/block_prototype_imports.py +111 -0
  3. package/ds-canonical/fork/hooks/check_chrome_header_avatar_canonical.sh +95 -0
  4. package/ds-canonical/fork/hooks/check_consumer_app_invariants.sh +349 -0
  5. package/ds-canonical/fork/hooks/check_ds_anchor_preflight.sh +132 -0
  6. package/ds-canonical/fork/hooks/check_escape_marker_abuse.sh +140 -0
  7. package/ds-canonical/fork/hooks/check_field_family_invariants.sh +250 -0
  8. package/ds-canonical/fork/hooks/check_fork_product_quality.sh +36 -0
  9. package/ds-canonical/fork/hooks/check_item_list_gap.sh +54 -0
  10. package/ds-canonical/fork/hooks/check_layout_space_magic_numbers.sh +96 -0
  11. package/ds-canonical/fork/hooks/check_opacity_token_usage.sh +149 -0
  12. package/ds-canonical/fork/hooks/check_pattern_invariants.sh +196 -0
  13. package/ds-canonical/fork/hooks/check_sidebar_menu_button_implicit_wrap.sh +86 -0
  14. package/ds-canonical/fork/hooks/check_tailwind_wildcard_in_docs.sh +79 -0
  15. package/ds-canonical/fork/hooks/inject_deploy_url_after_push.sh +238 -0
  16. package/ds-canonical/fork/launchers/fork-governance-dispatcher.sh +44 -0
  17. package/ds-canonical/fork/launchers/inject_fork_governance_preamble.sh +46 -0
  18. package/ds-canonical/fork/launchers/settings-hooks.json +58 -0
  19. package/ds-canonical/fork/manifest.json +79 -0
  20. package/ds-canonical/fork/preamble.md +495 -0
  21. package/ds-canonical/hooks/check_consumer_app_invariants.sh +19 -0
  22. package/ds-canonical/references/scenario-definition.md +4 -4
  23. package/ds-canonical/skills/design-system-audit/SKILL.md +1 -1
  24. package/llms-full.txt +1 -1
  25. package/llms.txt +1 -1
  26. package/package.json +5 -1
  27. package/src/story-governance/category-matrix.json +132 -0
  28. package/src/tokens/utility-registry.json +124 -0
@@ -0,0 +1,132 @@
1
+ #!/bin/bash
2
+ # check_ds_anchor_preflight.sh — M29 mechanical enforcement(2026-05-26 backfill per user verbatim
3
+ # 「該程式化的都沒程式化,導致你他媽那麼容易便宜」+「未來其他人 fork 用其他元件也偏移」)。
4
+ #
5
+ # Purpose: PreToolUse Edit/Write/MultiEdit 偵測 production tsx wrap DS primitive
6
+ # (`<Sidebar>` / `<AppShell>` / `<DataTable>` / `<Dialog>` / `<Field>` 等)
7
+ # 沒同 turn 跑過 Grep / Read DS canonical baseline → 軟 BLOCKER 提醒 propose 前必出 3-column owner table。
8
+ #
9
+ # Scope:同 check_substantive_edit_approval_preflight.sh extended scope
10
+ # - node_modules/@qijenchen/design-system/src/**.tsx (DS internal)
11
+ # - apps/**.tsx (consumer fork-user code) ← M29 gap absorption(2026-05-26)
12
+ # - node_modules/@qijenchen/design-system/** (禁改)
13
+ #
14
+ # Excluded: *.stories.tsx (story_invariants R7/R8 already cover) / *.test.tsx / *.spec.md.
15
+ #
16
+ # Why mechanical:M29 anchor preflight 在 meta-patterns.md 喊很久,5 個文件 reference but file 0,
17
+ # 結果 2026-05-26 product-workspace App.tsx mock-drift 沒被攔 → user 抓「肌肉沒長出來」。
18
+ # 本 hook 將「propose / write 視覺結構 前必 grep DS spec.md / canonical story」從 mindset 升 mechanical。
19
+ #
20
+ # Detection:scan transcript 過去 ~30 turns:
21
+ # (a) 有 Grep / Read tool call hit `node_modules/@qijenchen/design-system/src/**/*.spec.md` OR
22
+ # `**/*.stories.tsx`(canonical baseline) → PASS
23
+ # (b) 無 → soft BLOCKER inject context 提醒 grep canonical 出 3-column owner table。
24
+ #
25
+ # 對齊 meta-patterns.md M29 「視覺/結構 propose 前必 grep DS spec.md 找 owner SSOT(M29)— 出 3-column 表」+
26
+ # .claude/rules/self-verify.md Pre-edit phase「M29 3-column owner table」。
27
+
28
+ source "$(dirname "$0")/_log-fire.sh" 2>/dev/null && log_hook_fire
29
+
30
+ set -uo pipefail
31
+ INPUT=$(cat 2>/dev/null || echo "{}")
32
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
33
+ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null)
34
+ TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null)
35
+ EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // ""' 2>/dev/null)
36
+
37
+ [ "$EVENT" != "PreToolUse" ] && exit 0
38
+ case "$TOOL" in Edit|Write|MultiEdit) ;; *) exit 0 ;; esac
39
+
40
+ # Scope:DS production code + consumer fork-user app code(不含 stories / spec / test)
41
+ case "$FILE_PATH" in
42
+ */node_modules/@qijenchen/design-system/src/*.tsx) ;;
43
+ */apps/*.tsx) ;;
44
+ */node_modules/@qijenchen/design-system/*.tsx) ;;
45
+ *) exit 0 ;;
46
+ esac
47
+
48
+ case "$FILE_PATH" in
49
+ *.stories.tsx|*.test.tsx|*.spec.md|*.spec.ts) exit 0 ;;
50
+ esac
51
+
52
+ # Extract content being written/edited
53
+ NEW_CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // .tool_input.new_string // ""' 2>/dev/null)
54
+
55
+ # DS primitive 名單(wrap 這些就觸發 anchor preflight)
56
+ DS_PRIMITIVES_RE='<(Sidebar|AppShell|DataTable|Dialog|Sheet|Popover|DropdownMenu|Field|FieldControlGroup|MenuItem|ItemAvatar|ItemLabel|ItemIcon|SegmentedControl|Tabs|TabsList|TabsTrigger|Combobox|Select|DatePicker|TimePicker|TreeView|Tooltip|Coachmark|FileViewer|ScrollArea|Avatar|Badge|Button|ChromeHeader|SurfaceHeader|SurfaceBody|SurfaceFooter|OverlaySurface|NameCard|Toast|FileUpload|DescriptionList|Chart|BulkActionBar|ActionBar|Carousel|Breadcrumb)\b'
57
+
58
+ # 沒 wrap DS primitive → 跳過(可能是純 utility / hook code)
59
+ if ! echo "$NEW_CONTENT" | grep -qE "$DS_PRIMITIVES_RE"; then
60
+ exit 0
61
+ fi
62
+
63
+ [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ] && exit 0
64
+
65
+ # Scan last ~30 turns(~600 lines transcript)— 找 Grep/Read 對 DS spec.md / canonical story 的 trace
66
+ RECENT_TRANSCRIPT=$(tail -600 "$TRANSCRIPT_PATH" 2>/dev/null)
67
+
68
+ # Pass condition(a):有 Grep tool call pattern hit spec.md / stories.tsx
69
+ HAS_CANONICAL_READ=$(echo "$RECENT_TRANSCRIPT" | \
70
+ jq -r 'select(.message.content != null) |
71
+ .message.content // empty |
72
+ if type == "array" then
73
+ (.[]? | select(.type == "tool_use") |
74
+ select(.name == "Grep" or .name == "Read" or .name == "Glob") |
75
+ .input | tostring)
76
+ else empty
77
+ end' 2>/dev/null | \
78
+ grep -cE 'node_modules/@qijenchen/design-system/src/.*\.(spec\.md|stories\.tsx)|stories\.tsx#' 2>/dev/null)
79
+ HAS_CANONICAL_READ=${HAS_CANONICAL_READ:-0}
80
+
81
+ # Pass condition(b):AI 已 written 3-column owner table OR cite `@story-baseline:` marker
82
+ HAS_3COL_OR_MARKER=$(echo "$RECENT_TRANSCRIPT" | \
83
+ grep -cE '@story-baseline:|owner spec|canonical sentence|3-column' 2>/dev/null)
84
+ HAS_3COL_OR_MARKER=${HAS_3COL_OR_MARKER:-0}
85
+
86
+ # Pass: 有任一 canonical read OR 3-column marker → silent
87
+ if [ "$HAS_CANONICAL_READ" -gt 0 ] || [ "$HAS_3COL_OR_MARKER" -gt 0 ]; then
88
+ exit 0
89
+ fi
90
+
91
+ # Override env var(audit-logged)
92
+ if [ "${CLAUDE_BYPASS_DS_ANCHOR:-0}" = "1" ]; then
93
+ mkdir -p "$(dirname "$0")/../logs" 2>/dev/null
94
+ printf '{"ts":"%s","event":"ds-anchor-bypass","file":"%s","tool":"%s"}\n' \
95
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$FILE_PATH" "$TOOL" >> "$(dirname "$0")/../logs/governance-bypass.jsonl" 2>/dev/null
96
+ exit 0
97
+ fi
98
+
99
+ REL_PATH=${FILE_PATH#"$CLAUDE_PROJECT_DIR"/}
100
+
101
+ # Soft BLOCKER(對齊 check_substantive_edit_approval_preflight.sh hybrid pattern)
102
+ cat >&2 <<EOF
103
+ 🚨 M29 DS Anchor Preflight — visual/structural edit 偵測
104
+
105
+ 📁 File: $REL_PATH
106
+ 🔧 Tool: $TOOL
107
+ 🧩 偵測到 DS primitive 在 new content:wrap pattern
108
+
109
+ ⚠️ 過去 ~30 turns 無 Grep/Read 對 \`node_modules/@qijenchen/design-system/src/**/spec.md\` 或
110
+ \`*.stories.tsx\` baseline 的 trace,且未見 \`@story-baseline:\` marker 或 3-column owner table。
111
+
112
+ → 這違反 M29 anchor preflight invariant + story-rules.md「Production-grade composition fidelity」。
113
+ → 對 fork-user 後果:憑記憶寫 simplified mock(像 2026-05-26 App.tsx SidebarTrigger 漏 / collapsible 漏 / startIcon 漏)。
114
+
115
+ 修法 — 2 選 1(對齊 check_substantive_edit_approval_preflight.sh hybrid):
116
+ (a) 先 Grep / Read DS canonical:
117
+ - \`node_modules/@qijenchen/design-system/src/<Component>/*.spec.md\`(找 owner SSOT)
118
+ - \`node_modules/@qijenchen/design-system/src/<Component>/*.stories.tsx\`(找完整佈局 baseline)
119
+ 然後 inline 寫 3-column owner table(\`owner spec\` / \`canonical sentence\` / \`conflicting code\`)
120
+ 或加 \`// @story-baseline: <path>#<StoryName>\` marker 在檔頭。
121
+ (b) Bypass:\`CLAUDE_BYPASS_DS_ANCHOR=1\` env var(audit-logged in governance-bypass.jsonl)
122
+ 僅當你**真的**已 Read canonical 但 transcript scan miss 時用,not 規則繞道。
123
+
124
+ 對應 canonical:
125
+ - \`.claude/rules/meta-patterns.md\` M29
126
+ - \`.claude/rules/self-verify.md\` Pre-edit phase
127
+ - \`.claude/references/ssot-index.md\` Step 0.1 high-risk interface owner mapping
128
+ EOF
129
+
130
+ # Soft warn (exit 0):inject context 讓 AI 自決,Stop hook backstop。
131
+ # 對齊 check_substantive_edit_approval_preflight.sh hybrid pattern(soft pre + hard stop)。
132
+ exit 0
@@ -0,0 +1,140 @@
1
+ #!/bin/bash
2
+ # check_escape_marker_abuse.sh — P0 BLOCKER (codify warning)
3
+ #
4
+ # 偵測 consumer code(.tsx/.stories.tsx)濫用 escape markers 跳 SSOT enforcement.
5
+ # Per user 2026-05-27 verbatim「不亂加 escape markers — 加就跳 enforcement」.
6
+ #
7
+ # Escape markers exist for真 exceptions(per-line documented rationale)。但 fork
8
+ # user 若大量加 escape markers (eg. ≥3 同一 file) = 違反 escape philosophy → BLOCK.
9
+ #
10
+ # Detected markers:
11
+ # - @ds-misuse-allow: (check_consumer_ds_primitive_misuse.sh escape)
12
+ # - @story-baseline-allow:(check_consumer_story_baseline.sh escape)
13
+ # - @consumer-catalog-allow:(check_consumer_no_ds_catalog.sh escape)
14
+ # - @overlay-open-skip: (check_overlay_open_focus_escape_probe.sh escape)
15
+ # - @template-customized (template canonical sync opt-out)
16
+ # - @layout-space-magic-ok:(check_layout_space_magic_numbers.sh escape)
17
+ # - @story-trait-allow: (story-baseline / catalog escape)
18
+ # - @story-trait-rationale:(check_story_invariants.sh R3 escape)
19
+ # - @story-split-rationale:(check_story_invariants.sh R2 escape)
20
+ # - @story-name-canonical-allow:(check_story_invariants.sh R4 escape)
21
+ # - @propose-cite-skip: (check_propose_cite_required.sh escape)
22
+ # - @anatomy-exempt: (story-rules escape)
23
+ # - @anatomy-exempt-next:(story-rules per-line escape)
24
+ # - @benchmark-unverified: (M22 cite)
25
+ # - @benchmark-citation-allow / @benchmark-unverified-blanket (M22 file-level cite escapes)
26
+ #
27
+ # Threshold:≥3 distinct markers OR ≥5 total occurrences in same file → BLOCK
28
+ # Forces fork user to either (a) fix root cause OR (b) refactor properly OR
29
+ # (c) explicitly cite reason in commit message via env override.
30
+ #
31
+ # 2026-05-31 升級(per 3-hardening-items 調查,折進本 hook 不新增檔):
32
+ # 1. Justification gate:escape marker 後空理由(@x-allow: 後純空白 / bare @benchmark-unverified)
33
+ # = 靜默繞過 SSOT → BLOCK(consumer + DS source 都跑)。對齊 ESLint require-description / Google NOLINT。
34
+ # 2. Scope 擴 DS source(node_modules/@qijenchen/design-system/src tsx/ts):原整段 skip 讓 DS 內 marker 不受 justification
35
+ # 約束;現 DS 跑 justification gate 但跳過 ≥3/≥5 數量 gate(DS 有大量 legit exception 避免誤殺)。
36
+ # 3. MARKER_RE 廣化(M7/M34 broad):@<x>-allow 家族用廣義 regex 自動納管,免 enum drift。
37
+
38
+ source "$(dirname "$0")/_log-fire.sh" 2>/dev/null && log_hook_fire
39
+
40
+ set -uo pipefail
41
+
42
+ INPUT=$(cat 2>/dev/null || echo "{}")
43
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
44
+
45
+ case "${TOOL:-}" in
46
+ Edit|Write|MultiEdit) ;;
47
+ *) exit 0 ;;
48
+ esac
49
+
50
+ FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null)
51
+ # Scope 分流(2026-05-31 加 DS source — 原整段 skip 讓 DS 內 143 個 marker 不受任何 justification 約束):
52
+ # IS_CONSUMER=apps/consumer tsx/ts → 跑 justification gate + 數量 gate(≥3/≥5)
53
+ # IS_DS=node_modules/@qijenchen/design-system/src tsx/ts → 只跑 justification gate(DS 有大量 legit exception,不套數量上限避免誤殺)
54
+ IS_CONSUMER=0; IS_DS=0
55
+ if echo "$FILE" | grep -qE '/(apps|consumer)/.*\.(tsx|ts)$'; then IS_CONSUMER=1; fi
56
+ if echo "$FILE" | grep -qE 'node_modules/@qijenchen/design-system/src/.*\.(tsx|ts)$'; then IS_DS=1; fi
57
+ if [ "$IS_CONSUMER" -eq 0 ] && [ "$IS_DS" -eq 0 ]; then exit 0; fi
58
+
59
+ CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // .tool_input.content // ""' 2>/dev/null)
60
+ [ -z "$CONTENT" ] && exit 0
61
+
62
+ # Global escape — meta-skip(env override OR explicit comment)
63
+ if [ "${CLAUDE_BYPASS_ESCAPE_MARKER_AUDIT:-0}" = "1" ]; then exit 0; fi
64
+
65
+ # ── Justification gate(2026-05-31,折進本 hook;核心 real gap)──────────────────
66
+ # Escape marker 必帶 per-line rationale。空理由 = 靜默繞過 SSOT enforcement。
67
+ # (a) @<x>-allow: 後純空白到行尾(有 marker 有冒號但無理由)
68
+ # (b) bare @benchmark-unverified 後純空白到行尾(非 -blanket / 非 ": 理由" / 非後接說明文字)
69
+ # 對齊 ESLint eslint-comments/require-description + Google NOLINT(category) 必帶說明。
70
+ EMPTY_RATIONALE=$(echo "$CONTENT" | grep -nE '@[a-z][a-z-]+-allow:[[:space:]]*$|@benchmark-unverified[[:space:]]*$' || true)
71
+ if [ -n "$EMPTY_RATIONALE" ]; then
72
+ cat >&2 << EOF
73
+ 🚨 ESCAPE-MARKER-NO-RATIONALE BLOCKER(P0,2026-05-31 folded into check_escape_marker_abuse)
74
+
75
+ File $FILE — escape marker 後無 rationale(空理由 = 靜默繞過 SSOT):
76
+ $(echo "$EMPTY_RATIONALE" | sed 's/^/ /')
77
+
78
+ 修法:marker 冒號後寫具體理由,eg.
79
+ // @ds-misuse-allow: Notion-style inline edit canonical(Field 在 cell 內編輯)
80
+ /* @benchmark-unverified: AG Grid pixel-snapshot 共識,待補 URL cite */
81
+
82
+ Escape directive 世界級共識(ESLint require-description / Google NOLINT)必帶 rationale。
83
+ EOF
84
+ exit 2
85
+ fi
86
+
87
+ # ── Sanctioned portal 檔 allowlist(2026-06-11 R2 held-item #10)──────────────
88
+ # AllDsComponents.stories.tsx 是 documented DS proxy portal(檔頭 @anatomy-exempt +
89
+ # @consumer-catalog-allow per 2026-05-27 M31 codex synthesis + @layout-space-magic-ok
90
+ # dev-artifact 豁免)— 3 個 marker 各帶 rationale 是它的 sanctioned 常態,數量 gate
91
+ # (≥3 distinct)對它必誤擋 = hook-vs-hook 張力。只豁免數量 gate;justification gate
92
+ # (空理由 BLOCK)上方已跑完照常生效,真保護不削弱。
93
+ IS_SANCTIONED_PORTAL=0
94
+ case "$FILE" in
95
+ *apps/*/src/AllDsComponents.stories.tsx) IS_SANCTIONED_PORTAL=1 ;;
96
+ esac
97
+
98
+ # ── 數量 gate(≥3 distinct / ≥5 total)— 僅 consumer(DS source 有大量 legit exception,只走上方 justification gate)──
99
+ # 2026-06-11 R2 Phase B(codex b3 抓縫):portal 無限豁免會放走「第 4、5 個新增 marker」——
100
+ # 改為提高閾值(portal ≥6 / 一般 ≥3)保留 ceiling,justification gate 不變。
101
+ if [ "$IS_SANCTIONED_PORTAL" -eq 1 ]; then DISTINCT_CAP=6; else DISTINCT_CAP=3; fi
102
+ if [ "$IS_CONSUMER" -eq 1 ]; then
103
+ # 2026-05-31 廣化 MARKER_RE(M7/M34:spec wording broad「任何 escape marker」→ hook regex 不該 narrow 只 16 種):
104
+ # @<x>-allow 家族用廣義 [a-z][a-z-]*-allow 自動納管(免每次補 enum drift)+ 非 -allow markers 顯式列。
105
+ MARKER_RE='@([a-z][a-z-]*-allow|benchmark-unverified(-blanket)?|template-customized|anatomy-exempt(-next)?|overlay-open-skip|layout-space-magic-ok|propose-cite-skip|story-(trait|split)-rationale)'
106
+ MARKERS_FOUND=$(echo "$CONTENT" | grep -oE "$MARKER_RE" | sort -u)
107
+ # 2026-05-30 fix(test-surfaced):空 MARKERS_FOUND 時 grep -c 印 "0" 已 exit 1,原 `|| echo 0`
108
+ # 會再 append 一個 "0" → "0\n0" → 下方 `[ -ge "$DISTINCT_CAP" ]` integer-expression error。改 `|| true` 不重複。
109
+ DISTINCT_COUNT=$(echo "$MARKERS_FOUND" | grep -c . || true)
110
+ [ -z "$DISTINCT_COUNT" ] && DISTINCT_COUNT=0
111
+ TOTAL_COUNT=$(echo "$CONTENT" | grep -oE "$MARKER_RE" | wc -l | tr -d ' ')
112
+
113
+ # Threshold: ≥3 distinct types OR ≥5 total
114
+ if [ "$DISTINCT_COUNT" -ge 3 ] || [ "$TOTAL_COUNT" -ge 5 ]; then
115
+ cat >&2 << EOF
116
+ 🚨 ESCAPE-MARKER-ABUSE BLOCKER(P0,user 2026-05-27 verbatim「不亂加 escape markers — 加就跳 enforcement」)
117
+
118
+ File $FILE:
119
+ Distinct escape markers: $DISTINCT_COUNT(threshold ≥3)
120
+ Total occurrences: $TOTAL_COUNT(threshold ≥5)
121
+
122
+ Markers detected:
123
+ $(echo "$MARKERS_FOUND" | sed 's/^/ /')
124
+
125
+ Escape markers 設計為「rare per-line documented exception」,不該 routine 加。
126
+ Fork user file 大量加 marker = 違反 SSOT 哲學 — 應該:
127
+
128
+ 修法 3 選 1:
129
+ (a) **重構 code** 走 DS canonical pattern(消除根因,不繞)
130
+ (b) **拆 file**:1 個 escape 對應 1 個 specific case,分散到不同 file
131
+ (c) **Override env**(極罕見,documented in commit msg):
132
+ CLAUDE_BYPASS_ESCAPE_MARKER_AUDIT=1 git commit -m "<rationale>"
133
+
134
+ per check_consumer_*.sh hooks SSOT — escape 是 emergency exit,不是 daily tool.
135
+ EOF
136
+ exit 2
137
+ fi
138
+ fi
139
+
140
+ exit 0
@@ -0,0 +1,250 @@
1
+ #!/bin/bash
2
+ # Field family unified invariant hook(2026-05-08 cluster A consolidation)
3
+ #
4
+ # Merges PreToolUse hooks(原各檔已 retire,合併入此)— 共 5 條 sub-rules:
5
+ # A.1 naked row-mode propagation(原 check_naked_row_mode_propagation,P0 BLOCKER)
6
+ # A.2 FieldControlGroup wrapper direct child(原 check_field_control_group_direct_child,P1 WARN)
7
+ # A.3 Field state ring SSOT(原 check_field_state_token_consume 3 sub-rules,P0 BLOCKER)
8
+ # A.4 disabled placeholder color(原 check_disabled_placeholder_color,P1 stderr only)
9
+ # A.5 _Group child fieldCtx.id 隔離(2026-05-31 折入,M4 AR34 regression detector,P0 BLOCKER)
10
+ #
11
+ # Why merge:皆 Field 家族 invariant,共用 INPUT parsing + Edit/Write filter pattern,
12
+ # 分散在 4 個 hook 是「散裝 SSOT」(M17 + Anthropic ≤ 15 hook best practice 違反)。
13
+ #
14
+ # Exit code precedence:BLOCK(2)> WARN(1)> INFO(0)。每 rule 可獨立觸發,worst 勝。
15
+ #
16
+ # Per-rule allowlist(各自獨立):
17
+ # A.1: `// @naked-row-mode-allow: <reason>`
18
+ # A.2: `// @fcg-wrapper-allow: <reason>` 或檔頭
19
+ # A.3: `// @field-state-ring-allow: <reason>`
20
+ # A.4: `// @disabled-color-allow: <reason>`
21
+ # A.5: `// @group-fieldctx-allow: <reason>`
22
+
23
+ source "$(dirname "$0")/_log-fire.sh" 2>/dev/null && log_hook_fire
24
+
25
+ set -uo pipefail
26
+
27
+ INPUT=$(cat)
28
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""')
29
+ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
30
+
31
+ # Tool filter — 只 Edit/Write/MultiEdit 跑
32
+ case "$TOOL" in
33
+ Edit|Write|MultiEdit) ;;
34
+ *) exit 0 ;;
35
+ esac
36
+
37
+ # 不是 .tsx / .ts 直接過(各 rule 內部還會再 narrow)
38
+ case "$FILE_PATH" in
39
+ *.tsx|*.ts) ;;
40
+ *) exit 0 ;;
41
+ esac
42
+
43
+ # 讀 merged content(舊檔 + 新 edit 拼起)— A.1 / A.3 需要整檔判 naked variant 存在性
44
+ FILE_CONTENT=""
45
+ if [ -f "$FILE_PATH" ]; then
46
+ FILE_CONTENT=$(cat "$FILE_PATH")
47
+ fi
48
+ NEW_CONTENT=$(echo "$INPUT" | jq -r '
49
+ (.tool_input.content // "") + "\n" +
50
+ (.tool_input.new_string // "") + "\n" +
51
+ ([.tool_input.edits[]? | .new_string] | join("\n"))
52
+ ' 2>/dev/null || echo "")
53
+
54
+ # A.2 / A.4 只看 NEW_CONTENT(diff-level signal),A.1 / A.3 看 MERGED
55
+ MERGED_CONTENT="${FILE_CONTENT}
56
+ ${NEW_CONTENT}"
57
+
58
+ [ -z "${MERGED_CONTENT//[[:space:]]/}" ] && exit 0
59
+
60
+ WORST=0
61
+ record_worst() { local lvl=$1; [ "$lvl" -gt "$WORST" ] && WORST=$lvl; }
62
+
63
+ # ── A.1 naked row-mode propagation(P0 BLOCKER)─────────────────────────────────
64
+ case "$FILE_PATH" in
65
+ *components/*.tsx)
66
+ case "$FILE_PATH" in
67
+ */field-wrapper.tsx|*/textarea.tsx) ;; # SSOT host skip
68
+ *)
69
+ if ! echo "$MERGED_CONTENT" | grep -q '@naked-row-mode-allow' \
70
+ && echo "$MERGED_CONTENT" | grep -E "variant:\s*['\"]naked['\"]|variant=\{?['\"]naked['\"]" >/dev/null \
71
+ && echo "$MERGED_CONTENT" | tr '\n' ' ' | grep -E "(inline-flex|flex)[^\"'\`]*items-center" >/dev/null \
72
+ && ! echo "$MERGED_CONTENT" | grep -q "nakedCellRowModeAlign"; then
73
+ cat >&2 <<EOF
74
+
75
+ ┄┄┄ A.1 check_field_family_invariants — naked row-mode propagation BLOCKER ┄┄┄
76
+
77
+ [P0] ${FILE_PATH}
78
+ 偵測到此檔消費 \`variant="naked"\` + 內部 wrapper hardcode \`items-center\`,
79
+ 但**未** import / apply \`nakedCellRowModeAlign\` SSOT。
80
+
81
+ ⚠️ M19 canonical:naked variant 元件所有內部 wrapper 必 propagate host cell
82
+ \`data-row-mode\`(autoRow→items-start / fixed→items-center)。
83
+
84
+ 修法:
85
+ 1. import { nakedCellRowModeAlign } from '@/design-system/components/Field/field-wrapper'
86
+ 2. wrapper className 加上 SSOT(eg \`cn('flex-1 min-w-0 inline-flex items-center', nakedCellRowModeAlign)\`)
87
+ 3. 例外:行尾 \`// @naked-row-mode-allow: <reason>\`
88
+
89
+ EOF
90
+ record_worst 2
91
+ fi
92
+ ;;
93
+ esac
94
+ ;;
95
+ esac
96
+
97
+ # ── A.2 FieldControlGroup wrapper direct child(P1 WARN)────────────────────────
98
+ case "$FILE_PATH" in
99
+ *.tsx)
100
+ if echo "$NEW_CONTENT" | grep -q '<FieldControlGroup' \
101
+ && ! echo "$NEW_CONTENT" | grep -q '@fcg-wrapper-allow'; then
102
+ SUSPECT=$(printf '%s' "$NEW_CONTENT" | awk '
103
+ /<FieldControlGroup/ { inFCG=1; next }
104
+ /<\/FieldControlGroup>/ { inFCG=0; next }
105
+ inFCG && /^[[:space:]]*<(div|span)[[:space:]>]/ {
106
+ if ($0 !~ /display:[[:space:]]*contents/ && $0 !~ /@fcg-wrapper-allow/) print NR ":" $0
107
+ }
108
+ ')
109
+ if [ -n "$SUSPECT" ]; then
110
+ cat >&2 <<EOF
111
+
112
+ ┄┄┄ A.2 check_field_family_invariants — FieldControlGroup wrapper WARN ┄┄┄
113
+
114
+ [P1] ${FILE_PATH}
115
+ 偵測到 FieldControlGroup 內有 \`<div>\` / \`<span>\` wrapper(可能破壞 CSS \`[&>*]\` variants):
116
+ ${SUSPECT}
117
+
118
+ 修法 3 擇 1:
119
+ 1. 移除 wrapper,Field control 直接是 FieldControlGroup direct child
120
+ 2. 透過 prop forward className(eg \`<FilterValuePicker className="flex-1 min-w-0">\`)
121
+ 3. wrapper 用 \`display:contents\` / 加 \`// @fcg-wrapper-allow: <reason>\`
122
+
123
+ EOF
124
+ record_worst 1
125
+ fi
126
+ fi
127
+ ;;
128
+ esac
129
+
130
+ # ── A.3 Field state ring SSOT(P0 BLOCKER,3 sub-rules)─────────────────────────
131
+ case "$FILE_PATH" in
132
+ *components/*.tsx)
133
+ case "$FILE_PATH" in
134
+ */field-wrapper.tsx|*/textarea.tsx|*.stories.tsx|*.test.*|*.spec.tsx) ;; # SSOT/test skip
135
+ *)
136
+ if ! echo "$NEW_CONTENT" | grep -q '@field-state-ring-allow'; then
137
+ # A.3.1 舊 box-shadow inset
138
+ if echo "$NEW_CONTENT" | grep -E "(hover|focus-within|data-\[state=open\]):shadow-\[inset" >/dev/null; then
139
+ cat >&2 <<'EOF'
140
+
141
+ ┄┄┄ A.3.1 check_field_family_invariants — Field state ring shadow inset BLOCKER ┄┄┄
142
+
143
+ [P0] naked variant state ring 用 `box-shadow inset` — v9 retire pattern。
144
+ 修法:讓 Field default state machine 自動繼承(border-based);cell hover 用 `nakedCellEditableDisplayHover` const。
145
+
146
+ EOF
147
+ record_worst 2
148
+ fi
149
+ # A.3.2 自寫 outline state ring
150
+ if echo "$NEW_CONTENT" | grep -E "(hover|focus-within|focus-visible|data-\[state=open\]):outline-(border|primary)" >/dev/null; then
151
+ cat >&2 <<'EOF'
152
+
153
+ ┄┄┄ A.3.2 check_field_family_invariants — Field state ring outline BLOCKER ┄┄┄
154
+
155
+ [P0] 自寫 `hover:outline-border` / `focus-within:outline-primary` — v13 canonical 禁。
156
+ 修法:Field default 自動繼承 / cell 用 `nakedCellEditableDisplayHover` const。
157
+
158
+ EOF
159
+ record_worst 2
160
+ fi
161
+ # A.3.3 per-control open=blue override(v13.5)
162
+ # 2026-06-11 deep-audit R2(n=38)精準化(M7 broad-vs-narrow 3-column):
163
+ # Spec wording:v13.3 禁「open 時 per-control 把 border 轉 primary(藍)」。
164
+ # Hook regex(舊):substring 無 boundary + 不分註解行。
165
+ # Gap:誤命中 (a) `data-[state=open]:border-primary-hover` canonical(button.tsx:99,109
166
+ # overlay trigger 維持 hover 樣式,-hover/-active 是合法 token 後綴);(b) v13.3 retire
167
+ # 註解內引用舊 pattern 文字(combobox.tsx:725 / select.tsx:594)。
168
+ # 修:純註解行剝離 + border-primary 加字尾 boundary([^-a-zA-Z]|$)。
169
+ # DS-wide counter-example scan 2026-06-11:新 regex 全 components/ 0 hit(真違規 shape
170
+ # `open && 'border-primary'` / `data-[state=open]:border-primary` 結尾 仍 BLOCK,fixture 驗證)。
171
+ A33_CONTENT=$(echo "$NEW_CONTENT" | grep -vE '^[[:space:]]*(//|\*|/\*)')
172
+ if echo "$A33_CONTENT" | grep -E "(open|isOpen) +&& +.{0,40}('border-primary'|\"border-primary\")" >/dev/null \
173
+ || echo "$A33_CONTENT" | grep -E "data-\[state=open\]:border-primary([^-a-zA-Z]|$)" >/dev/null; then
174
+ cat >&2 <<'EOF'
175
+
176
+ ┄┄┄ A.3.3 check_field_family_invariants — per-control open=blue BLOCKER ┄┄┄
177
+
178
+ [P0] per-control `open && 'border-primary'` / `data-[state=open]:border-primary` — v13.3 canonical「focus dominates everything」禁。
179
+ 修法:刪 override。Radix Popover open 時 trigger 通常 focused → focus-within fires → 藍(自然 Ant 風)。改 Field default SSOT 須 spec 補 rationale。
180
+
181
+ EOF
182
+ record_worst 2
183
+ fi
184
+ fi
185
+ ;;
186
+ esac
187
+ ;;
188
+ esac
189
+
190
+ # ── A.4 disabled placeholder color(P1 stderr,exit 0 不 block)──────────────────
191
+ if ! echo "$NEW_CONTENT" | grep -q '@disabled-color-allow'; then
192
+ SUSPECT_DP=""
193
+ if echo "$NEW_CONTENT" | grep -E "placeholder:text-fg-muted" >/dev/null \
194
+ && ! echo "$NEW_CONTENT" | grep -E "(disabled:placeholder:text-fg-disabled|group-data-\[field-mode=disabled\].*placeholder:text-fg-disabled|resolvedMode\s*===\s*'disabled'.*text-fg-disabled)" >/dev/null; then
195
+ SUSPECT_DP="$SUSPECT_DP [placeholder:text-fg-muted 無 disabled override]"
196
+ fi
197
+ if echo "$NEW_CONTENT" | tr '\n' ' ' | grep -E '<span[^>]*"text-fg-muted"[^>]*>[[:space:]]*\{[^}]*placeholder' >/dev/null 2>&1 \
198
+ && ! echo "$NEW_CONTENT" | grep -E "resolvedMode\s*===\s*'disabled'" >/dev/null; then
199
+ SUSPECT_DP="$SUSPECT_DP [<span text-fg-muted>{placeholder} 不分 mode]"
200
+ fi
201
+ if [ -n "$SUSPECT_DP" ]; then
202
+ cat >&2 <<EOF
203
+
204
+ ┄┄┄ A.4 check_field_family_invariants — disabled placeholder color WARN ┄┄┄
205
+
206
+ [P1] ${FILE_PATH}
207
+ ${SUSPECT_DP}
208
+ 修法:disabled:placeholder:text-fg-disabled / group-data-[field-mode=disabled]/field:placeholder:text-fg-disabled / JSX 條件
209
+ 詳:tokens/color/color.spec.md「Disabled state precedence canonical」/ M24
210
+ 例外:行尾 \`// @disabled-color-allow: <reason>\`
211
+
212
+ EOF
213
+ # A.4 原 hook exit 0(stderr only),保持向後兼容不升 WORST
214
+ fi
215
+ fi
216
+
217
+ # ── A.5 _Group child fieldCtx.id 隔離(P0 BLOCKER,2026-05-31 折入,M4 AR34 regression detector)──
218
+ # M4:_Group 元件(CheckboxGroup/RadioGroup/SwitchGroup)的 child item 不可共用 fieldCtx.id —— 否則
219
+ # group 內所有 label 被抑制 + 點 label 只 toggle 第一個(AR34 root bug)。正解:item 在 group 內走自己
220
+ # 的 generatedId,不 fall back 到共用 fieldCtx.id。偵測 AR34 regression shape(MERGED 整檔,3-signal AND):
221
+ # (1) 消費 *GroupContext (2) bare `idProp ?? fieldCtx?.id ?? generatedId` fallback
222
+ # (3) 缺 `insideGroup ? generatedId` / `inGroup ? generatedId` group guard
223
+ case "$FILE_PATH" in
224
+ *components/*.tsx)
225
+ if ! echo "$MERGED_CONTENT" | grep -q '@group-fieldctx-allow' \
226
+ && echo "$MERGED_CONTENT" | grep -qE 'useContext\([A-Za-z_]*GroupContext\)' \
227
+ && echo "$MERGED_CONTENT" | grep -qE 'idProp[[:space:]]*\?\?[[:space:]]*fieldCtx\?\.id[[:space:]]*\?\?[[:space:]]*generatedId' \
228
+ && ! echo "$MERGED_CONTENT" | grep -qE '(insideGroup|inGroup)[[:space:]]*\?[[:space:]]*generatedId'; then
229
+ cat >&2 <<EOF
230
+
231
+ ┄┄┄ A.5 check_field_family_invariants — _Group child fieldCtx.id 隔離 BLOCKER ┄┄┄
232
+
233
+ [P0] ${FILE_PATH}
234
+ 偵測到 _Group child item 消費 GroupContext + bare \`idProp ?? fieldCtx?.id ?? generatedId\` fallback,
235
+ 但**缺** group guard(\`insideGroup ? generatedId\`)= M4 AR34 regression shape。
236
+
237
+ ⚠️ M4 canonical:Group 內 item 不可共用 fieldCtx.id(否則所有 label 被抑制 + 點 label 只 toggle 第一個)。
238
+
239
+ 修法:
240
+ inputId = idProp ?? (insideGroup ? generatedId : (fieldCtx?.id ?? generatedId))
241
+ 即 group 內走自己的 generatedId,不 fall back 共用 fieldCtx.id。
242
+ 例外:行尾 \`// @group-fieldctx-allow: <reason>\`
243
+
244
+ EOF
245
+ record_worst 2
246
+ fi
247
+ ;;
248
+ esac
249
+
250
+ exit $WORST
@@ -0,0 +1,36 @@
1
+ #!/bin/bash
2
+ # check_fork_product_quality.sh — REPLACE for check_substantive_edit_approval_preflight.sh (fork-mode)
3
+ #
4
+ # WHY REPLACE: 原 approval-gate 在 fork hard-block(exit 2)所有 apps/** 產品 edit → 鎖死 fork
5
+ # 使用者(連寫產品 code 都不行)。fork 使用者「寫產品」是正常開發,不該被 DS-author 的「設計
6
+ # 改動需 approval」儀式擋住。本 hook 改成 quality-POSITIVE soft inject:exit 0、永不 brick,只在
7
+ # 編輯產品 code 時提醒「先消費 DS 元件 + 讀 DS spec」。真正的品質 enforcement 由其他 fork-mode
8
+ # 治理 hook(primitive-misuse / layout-space / opacity-token / ds-anchor)負責,不靠 approval 閘。
9
+ #
10
+ # 對齊 C-prime 共識:world-class 產出靠「SSOT/anchor/primitive 機械檢查 fire」保證,
11
+ # 不靠「把一般 edit 擋在 approval 關鍵字後面」。
12
+
13
+ set -uo pipefail
14
+ INPUT=$(cat 2>/dev/null || echo "{}")
15
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
16
+ case "${TOOL:-}" in Edit|Write|MultiEdit) ;; *) exit 0 ;; esac
17
+
18
+ FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null)
19
+ # 只在 fork 產品 code 提示;DS 內部 / node_modules / 非 product 不管
20
+ echo "$FILE" | grep -qE '(^|/)apps/.+\.(tsx|ts|css)$' || exit 0
21
+ echo "$FILE" | grep -qE 'node_modules/|packages/design-system/' && exit 0
22
+
23
+ # 一個 session 只提示一次(避免每次 edit 都吵)
24
+ MARK="${TMPDIR:-/tmp}/.ds-fork-quality-hinted-${CLAUDE_SESSION_ID:-session}"
25
+ [ -f "$MARK" ] && exit 0
26
+ : > "$MARK" 2>/dev/null || true
27
+
28
+ cat >&2 << 'EOF'
29
+ 💡 DS 產品開發提示(soft,不阻擋):你正在編輯產品 code(apps/**)。
30
+ 為確保世界級且一致的產出,改動前請:
31
+ - 優先消費既有 DS 元件(import from "@qijenchen/design-system"),勿手刻 raw HTML / 重造已有元件
32
+ - 組裝某元件前,先讀它的 spec/story:node_modules/@qijenchen/design-system/src/**/<name>.spec.md
33
+ - 用 design token(間距/色值/字級/圓角/陰影),勿硬寫
34
+ (其餘 fork 治理 hook 會機械把關 primitive 誤用 / 硬寫間距 / opacity token 等)
35
+ EOF
36
+ exit 0
@@ -0,0 +1,54 @@
1
+ #!/bin/bash
2
+ # check_item_list_gap.sh — M16 mechanical enforcement(2026-05-26 backfill).
3
+ #
4
+ # Purpose:PreToolUse Edit/Write — 偵測 production tsx 含 standalone card/pill 渲染
5
+ # pattern(`<FileItem>` / `<MenuItem rich>` / 卡片型 standalone)without gap-N or space-y-N
6
+ # parent → soft warn requires explicit gap canonical per item-anatomy.spec.md。
7
+ #
8
+ # 3 條公式(per M16):
9
+ # - 同類 standalone 卡片 → 必 gap
10
+ # - 同類 permanent flush(連續貼齊)→ 0 gap OK,但必 codify in spec
11
+ # - 混合語言 → 必取最保守 gap
12
+
13
+ source "$(dirname "$0")/_log-fire.sh" 2>/dev/null && log_hook_fire
14
+
15
+ set -uo pipefail
16
+ INPUT=$(cat 2>/dev/null || echo "{}")
17
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
18
+ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null)
19
+ EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // ""' 2>/dev/null)
20
+ NEW=$(echo "$INPUT" | jq -r '.tool_input.content // .tool_input.new_string // ""' 2>/dev/null)
21
+
22
+ [ "$EVENT" != "PreToolUse" ] && exit 0
23
+ case "$TOOL" in Edit|Write|MultiEdit) ;; *) exit 0 ;; esac
24
+ case "$FILE_PATH" in *.tsx) ;; *) exit 0 ;; esac
25
+ case "$FILE_PATH" in *.stories.tsx|*.test.tsx) exit 0 ;; esac
26
+
27
+ # Heuristic:multiple <FileItem> / <MenuItem variant="rich"> / standalone card pattern + parent 無 gap-N
28
+ # 2026-05-26 fix:grep -c 計行數不計次,用 -o count 真實 occurrence
29
+ STANDALONE_RE='<(FileItem|MenuItem[^>]+variant=["'\'']rich["'\''])'
30
+ COUNT=$(echo "$NEW" | grep -oE "$STANDALONE_RE" 2>/dev/null | wc -l | tr -d ' ')
31
+ COUNT=${COUNT:-0}
32
+
33
+ [ "$COUNT" -lt 2 ] && exit 0
34
+
35
+ # Has gap-N or space-y-N near the standalone pattern?
36
+ if echo "$NEW" | grep -qE '(gap-[0-9]|space-y-[0-9])'; then
37
+ exit 0
38
+ fi
39
+
40
+ cat >&2 <<EOF
41
+ ⚠️ M16 Item-list gap canonical
42
+
43
+ → 偵測到 $COUNT 個 standalone item(FileItem / MenuItem rich)未見 gap-N / space-y-N parent class。
44
+
45
+ M16 要求(per item-anatomy.spec.md):
46
+ - 同類 standalone → 必 gap(常見 gap-2 / gap-3)
47
+ - 同類 permanent flush(連續貼齊)→ 0 gap OK,但必 spec codify
48
+ - 混合語言 → 必取最保守 gap
49
+
50
+ 修法:parent container 加 \`gap-2\` / \`space-y-2\` 或 codify 連續貼齊 in 元件 spec。
51
+ 對應 canonical:meta-patterns.md M16 + patterns/element-anatomy/item-anatomy.spec.md。
52
+ EOF
53
+
54
+ exit 0