@chenmk/superflow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/INSTALL.en.md +106 -0
  2. package/INSTALL.md +664 -0
  3. package/LICENSE +21 -0
  4. package/README.md +142 -0
  5. package/README.zh-CN.md +117 -0
  6. package/assets/context-templates/business-rules.md +98 -0
  7. package/assets/context-templates/decisions.md +153 -0
  8. package/assets/context-templates/external-systems.md +166 -0
  9. package/assets/context-templates/incidents.md +89 -0
  10. package/assets/manifest.json +53 -0
  11. package/assets/prompts/superflow-archive.md +9 -0
  12. package/assets/prompts/superflow-clarify.md +10 -0
  13. package/assets/prompts/superflow-design.md +10 -0
  14. package/assets/prompts/superflow-docs.md +10 -0
  15. package/assets/prompts/superflow-implement.md +10 -0
  16. package/assets/prompts/superflow-pipeline.md +13 -0
  17. package/assets/prompts/superflow-verify.md +10 -0
  18. package/assets/rules/superflow-phase-guard.md +50 -0
  19. package/assets/scripts/claude-auto-backup-hook.sh +313 -0
  20. package/assets/scripts/codex-auto-backup-hook.sh +361 -0
  21. package/assets/scripts/install-sql-pre-commit.sh +44 -0
  22. package/assets/scripts/superflow-contract-hooks.sh +744 -0
  23. package/assets/scripts/superflow-delivery-check.sh +315 -0
  24. package/assets/scripts/superflow-dependency-update-hook.sh +161 -0
  25. package/assets/scripts/superflow-enforce-hook.sh +70 -0
  26. package/assets/scripts/superflow-hook-guard.sh +132 -0
  27. package/assets/scripts/superflow-integration-evidence-hook.sh +80 -0
  28. package/assets/scripts/superflow-sql-sync-hook.py +950 -0
  29. package/assets/scripts/superflow-test-report-lint.py +433 -0
  30. package/assets/scripts/superflow-verify-integration.sh +90 -0
  31. package/assets/scripts/sync-settings-json.py +52 -0
  32. package/assets/skills/api-doc-changelog/SKILL.md +193 -0
  33. package/assets/skills/openspec-apply-change/SKILL.md +156 -0
  34. package/assets/skills/openspec-archive-change/SKILL.md +114 -0
  35. package/assets/skills/openspec-explore/SKILL.md +288 -0
  36. package/assets/skills/openspec-propose/SKILL.md +110 -0
  37. package/assets/skills/superflow-archive/SKILL.md +61 -0
  38. package/assets/skills/superflow-clarify/SKILL.md +146 -0
  39. package/assets/skills/superflow-clarify/agents/openai.yaml +4 -0
  40. package/assets/skills/superflow-design/SKILL.md +83 -0
  41. package/assets/skills/superflow-design/agents/openai.yaml +4 -0
  42. package/assets/skills/superflow-docs/SKILL.md +316 -0
  43. package/assets/skills/superflow-docs/agents/openai.yaml +4 -0
  44. package/assets/skills/superflow-hotfix/SKILL.md +48 -0
  45. package/assets/skills/superflow-implement/SKILL.md +461 -0
  46. package/assets/skills/superflow-implement/agents/openai.yaml +4 -0
  47. package/assets/skills/superflow-pipeline/SKILL.md +844 -0
  48. package/assets/skills/superflow-pipeline/agents/openai.yaml +4 -0
  49. package/assets/skills/superflow-pipeline/references/api-design-template.md +431 -0
  50. package/assets/skills/superflow-pipeline/references/architecture-design-template.md +119 -0
  51. package/assets/skills/superflow-pipeline/references/batch-prompt-template.md +536 -0
  52. package/assets/skills/superflow-pipeline/references/batch-split-guide.md +140 -0
  53. package/assets/skills/superflow-pipeline/references/decision-point.md +30 -0
  54. package/assets/skills/superflow-pipeline/references/dirty-worktree.md +35 -0
  55. package/assets/skills/superflow-pipeline/references/document-templates.md +123 -0
  56. package/assets/skills/superflow-pipeline/references/feature-gated-workflow.md +124 -0
  57. package/assets/skills/superflow-pipeline/references/implementation-prompt-template.md +1056 -0
  58. package/assets/skills/superflow-pipeline/references/mock-strategy-guide.md +86 -0
  59. package/assets/skills/superflow-pipeline/references/openspec-format.md +57 -0
  60. package/assets/skills/superflow-pipeline/references/orchestration.md +639 -0
  61. package/assets/skills/superflow-pipeline/references/p0-baseline-template.md +174 -0
  62. package/assets/skills/superflow-pipeline/references/project-config.md +40 -0
  63. package/assets/skills/superflow-pipeline/references/prompt-usage-template.md +152 -0
  64. package/assets/skills/superflow-pipeline/references/quality-gate.md +299 -0
  65. package/assets/skills/superflow-pipeline/references/quality-standards.md +190 -0
  66. package/assets/skills/superflow-pipeline/references/reviewer-checklist.md +154 -0
  67. package/assets/skills/superflow-pipeline/references/sql-risk-review-checklist.md +323 -0
  68. package/assets/skills/superflow-pipeline/references/subagent-progress.md +90 -0
  69. package/assets/skills/superflow-pipeline/references/superpower-technical-design-template.md +125 -0
  70. package/assets/skills/superflow-pipeline/references/test-execution-template.md +220 -0
  71. package/assets/skills/superflow-pipeline/references/test-guide.md +30 -0
  72. package/assets/skills/superflow-pipeline/references/traceability-matrix.md +106 -0
  73. package/assets/skills/superflow-pipeline/references/validation-integrity.md +134 -0
  74. package/assets/skills/superflow-pipeline/scripts/superflow-archive.sh +178 -0
  75. package/assets/skills/superflow-pipeline/scripts/superflow-env.sh +118 -0
  76. package/assets/skills/superflow-pipeline/scripts/superflow-guard.sh +428 -0
  77. package/assets/skills/superflow-pipeline/scripts/superflow-handoff.sh +296 -0
  78. package/assets/skills/superflow-pipeline/scripts/superflow-state.sh +574 -0
  79. package/assets/skills/superflow-pipeline/scripts/superflow-status.sh +172 -0
  80. package/assets/skills/superflow-pipeline/scripts/superflow-yaml-validate.sh +138 -0
  81. package/assets/skills/superflow-table-impact-analysis/SKILL.md +77 -0
  82. package/assets/skills/superflow-tweak/SKILL.md +46 -0
  83. package/assets/skills/superflow-verify/SKILL.md +112 -0
  84. package/assets/skills-en/api-doc-changelog/SKILL.md +193 -0
  85. package/assets/skills-en/openspec-apply-change/SKILL.md +156 -0
  86. package/assets/skills-en/openspec-archive-change/SKILL.md +114 -0
  87. package/assets/skills-en/openspec-explore/SKILL.md +288 -0
  88. package/assets/skills-en/openspec-propose/SKILL.md +110 -0
  89. package/assets/skills-en/superflow-archive/SKILL.md +61 -0
  90. package/assets/skills-en/superflow-clarify/SKILL.md +146 -0
  91. package/assets/skills-en/superflow-clarify/agents/openai.yaml +4 -0
  92. package/assets/skills-en/superflow-design/SKILL.md +83 -0
  93. package/assets/skills-en/superflow-design/agents/openai.yaml +4 -0
  94. package/assets/skills-en/superflow-docs/SKILL.md +316 -0
  95. package/assets/skills-en/superflow-docs/agents/openai.yaml +4 -0
  96. package/assets/skills-en/superflow-hotfix/SKILL.md +48 -0
  97. package/assets/skills-en/superflow-implement/SKILL.md +461 -0
  98. package/assets/skills-en/superflow-implement/agents/openai.yaml +4 -0
  99. package/assets/skills-en/superflow-pipeline/SKILL.md +844 -0
  100. package/assets/skills-en/superflow-pipeline/agents/openai.yaml +4 -0
  101. package/assets/skills-en/superflow-pipeline/references/api-design-template.md +431 -0
  102. package/assets/skills-en/superflow-pipeline/references/architecture-design-template.md +119 -0
  103. package/assets/skills-en/superflow-pipeline/references/batch-prompt-template.md +536 -0
  104. package/assets/skills-en/superflow-pipeline/references/batch-split-guide.md +140 -0
  105. package/assets/skills-en/superflow-pipeline/references/decision-point.md +30 -0
  106. package/assets/skills-en/superflow-pipeline/references/dirty-worktree.md +35 -0
  107. package/assets/skills-en/superflow-pipeline/references/document-templates.md +123 -0
  108. package/assets/skills-en/superflow-pipeline/references/feature-gated-workflow.md +124 -0
  109. package/assets/skills-en/superflow-pipeline/references/implementation-prompt-template.md +1056 -0
  110. package/assets/skills-en/superflow-pipeline/references/mock-strategy-guide.md +86 -0
  111. package/assets/skills-en/superflow-pipeline/references/openspec-format.md +57 -0
  112. package/assets/skills-en/superflow-pipeline/references/orchestration.md +639 -0
  113. package/assets/skills-en/superflow-pipeline/references/p0-baseline-template.md +174 -0
  114. package/assets/skills-en/superflow-pipeline/references/project-config.md +40 -0
  115. package/assets/skills-en/superflow-pipeline/references/prompt-usage-template.md +152 -0
  116. package/assets/skills-en/superflow-pipeline/references/quality-gate.md +299 -0
  117. package/assets/skills-en/superflow-pipeline/references/quality-standards.md +190 -0
  118. package/assets/skills-en/superflow-pipeline/references/reviewer-checklist.md +154 -0
  119. package/assets/skills-en/superflow-pipeline/references/sql-risk-review-checklist.md +323 -0
  120. package/assets/skills-en/superflow-pipeline/references/subagent-progress.md +90 -0
  121. package/assets/skills-en/superflow-pipeline/references/superpower-technical-design-template.md +125 -0
  122. package/assets/skills-en/superflow-pipeline/references/test-execution-template.md +220 -0
  123. package/assets/skills-en/superflow-pipeline/references/test-guide.md +30 -0
  124. package/assets/skills-en/superflow-pipeline/references/traceability-matrix.md +106 -0
  125. package/assets/skills-en/superflow-pipeline/references/validation-integrity.md +134 -0
  126. package/assets/skills-en/superflow-pipeline/scripts/superflow-archive.sh +178 -0
  127. package/assets/skills-en/superflow-pipeline/scripts/superflow-env.sh +118 -0
  128. package/assets/skills-en/superflow-pipeline/scripts/superflow-guard.sh +428 -0
  129. package/assets/skills-en/superflow-pipeline/scripts/superflow-handoff.sh +296 -0
  130. package/assets/skills-en/superflow-pipeline/scripts/superflow-state.sh +574 -0
  131. package/assets/skills-en/superflow-pipeline/scripts/superflow-status.sh +172 -0
  132. package/assets/skills-en/superflow-pipeline/scripts/superflow-yaml-validate.sh +138 -0
  133. package/assets/skills-en/superflow-table-impact-analysis/SKILL.md +77 -0
  134. package/assets/skills-en/superflow-tweak/SKILL.md +46 -0
  135. package/assets/skills-en/superflow-verify/SKILL.md +112 -0
  136. package/dist/cli/index.js +186 -0
  137. package/dist/cli/index.js.map +1 -0
  138. package/dist/commands/archive.js +6 -0
  139. package/dist/commands/archive.js.map +1 -0
  140. package/dist/commands/clarify.js +6 -0
  141. package/dist/commands/clarify.js.map +1 -0
  142. package/dist/commands/design.js +6 -0
  143. package/dist/commands/design.js.map +1 -0
  144. package/dist/commands/docs.js +6 -0
  145. package/dist/commands/docs.js.map +1 -0
  146. package/dist/commands/doctor.js +473 -0
  147. package/dist/commands/doctor.js.map +1 -0
  148. package/dist/commands/implement.js +6 -0
  149. package/dist/commands/implement.js.map +1 -0
  150. package/dist/commands/init.js +471 -0
  151. package/dist/commands/init.js.map +1 -0
  152. package/dist/commands/pipeline.js +6 -0
  153. package/dist/commands/pipeline.js.map +1 -0
  154. package/dist/commands/scan.js +59 -0
  155. package/dist/commands/scan.js.map +1 -0
  156. package/dist/commands/status.js +173 -0
  157. package/dist/commands/status.js.map +1 -0
  158. package/dist/commands/uninstall.js +213 -0
  159. package/dist/commands/uninstall.js.map +1 -0
  160. package/dist/commands/update.js +187 -0
  161. package/dist/commands/update.js.map +1 -0
  162. package/dist/commands/verify.js +6 -0
  163. package/dist/commands/verify.js.map +1 -0
  164. package/dist/core/assets.js +27 -0
  165. package/dist/core/assets.js.map +1 -0
  166. package/dist/core/context.js +100 -0
  167. package/dist/core/context.js.map +1 -0
  168. package/dist/core/dependencies.js +146 -0
  169. package/dist/core/dependencies.js.map +1 -0
  170. package/dist/core/detect.js +71 -0
  171. package/dist/core/detect.js.map +1 -0
  172. package/dist/core/i18n.js +103 -0
  173. package/dist/core/i18n.js.map +1 -0
  174. package/dist/core/integrity.js +46 -0
  175. package/dist/core/integrity.js.map +1 -0
  176. package/dist/core/manifest.js +18 -0
  177. package/dist/core/manifest.js.map +1 -0
  178. package/dist/core/prompts.js +20 -0
  179. package/dist/core/prompts.js.map +1 -0
  180. package/dist/core/registry.js +134 -0
  181. package/dist/core/registry.js.map +1 -0
  182. package/dist/core/rules.js +17 -0
  183. package/dist/core/rules.js.map +1 -0
  184. package/dist/core/scripts.js +40 -0
  185. package/dist/core/scripts.js.map +1 -0
  186. package/dist/core/skill-check.js +31 -0
  187. package/dist/core/skill-check.js.map +1 -0
  188. package/dist/core/skills.js +56 -0
  189. package/dist/core/skills.js.map +1 -0
  190. package/dist/core/state.js +43 -0
  191. package/dist/core/state.js.map +1 -0
  192. package/dist/types.js +2 -0
  193. package/dist/types.js.map +1 -0
  194. package/dist/utils/path.js +11 -0
  195. package/dist/utils/path.js.map +1 -0
  196. package/dist/utils/shell.js +29 -0
  197. package/dist/utils/shell.js.map +1 -0
  198. package/package.json +60 -0
@@ -0,0 +1,950 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SDD SQL sync hook.
4
+
5
+ Warns when database-backed Java/XML changes are made without a version-level
6
+ summary SQL change. Blocks forbidden migration SQL style before commit.
7
+
8
+ Forbidden (B1-B8, blocks commit):
9
+ - B1 ALTER TABLE ADD COLUMN IF NOT EXISTS
10
+ - B2 CREATE [UNIQUE] INDEX IF NOT EXISTS
11
+ - B3 INFORMATION_SCHEMA 判断字段/索引
12
+ - B4 PREPARE / EXECUTE / DEALLOCATE PREPARE 动态 DDL
13
+ - B5 SET @变量 拼接或控制 DDL
14
+ - B6 ALTER TABLE ... ALGORITHM= / LOCK= 强制指定
15
+ - B7 FOREIGN KEY 外键约束(跨服务/多租户业务通常由代码层控制一致性)
16
+ - B8 NOT NULL 字段缺失 DEFAULT(老数据回填失败风险)
17
+
18
+ Warning (W1-W13, does not block, must be addressed in test-report.md):
19
+ - W1 回填类 UPDATE 使用相关子查询(应改 JOIN,O(k·N) -> O(N))
20
+ - W2 JSON_EXTRACT/JSON_UNQUOTE 后无 COALESCE/IFNULL 兜底
21
+ - W3 唯一键包含 DATETIME 列且未指定精度
22
+ - W4 INSERT IGNORE 在数据迁移中使用
23
+ - W7 SQL 文件头缺失(目标 MySQL 版本、关联批次、风险等级、评审人)
24
+
25
+ Header requirement: every sql/**/*.sql file should declare
26
+ `-- 目标 MySQL 版本`、`-- 关联批次`、`-- 风险等级`、`-- 评审人` in its
27
+ leading comment block. Missing header triggers W7 warning.
28
+
29
+ Exempt syntax:
30
+ - File-level: `-- allow-dynamic-ddl: <reason>` exempts all forbidden rules
31
+ - Rule-level: `-- allow-sql-risk-rule: <B#|W#>` exempts the matching rule
32
+ for the line on which the comment appears (or until next DDL boundary)
33
+
34
+ Subcommands:
35
+ - (default) Hook mode: invoked by PreToolUse/PostToolUse hooks
36
+ - --check-staged Run on `git diff --cached` SQL files; blocks on forbidden
37
+ - --risk-review Run on disk SQL files under cwd; reports warnings +
38
+ forbidden issues, exits 0 (non-blocking report)
39
+ - --check-all Scan sql/ under git root; CI-friendly exit codes 0/1/2/3
40
+ - --auto-fix Apply safe auto-fixes (B1/B2/B6/W7), print report.
41
+ Dry-run by default; exits 0.
42
+ - --auto-fix-write Same as --auto-fix but writes fixed content to disk
43
+ (with .sql.bak backup of original)
44
+
45
+ Auto-fixable rules (safe to apply without business context):
46
+ - B1: drop 'IF NOT EXISTS' from ALTER TABLE ADD COLUMN
47
+ - B2: drop 'IF NOT EXISTS' from CREATE [UNIQUE] INDEX
48
+ - B6: drop ALGORITHM= and LOCK= sub-clauses
49
+ - W7: prepend SQL file header (version/batch/risk/reviewer inferred from path)
50
+
51
+ Manual review rules (cannot auto-fix; --auto-fix outputs a fix template
52
+ per finding for the developer to apply):
53
+ - B3/B4/B5: dynamic DDL rewrite requires business decision
54
+ - B7: dropping FK requires code-layer replacement verification
55
+ - B8: NOT NULL DEFAULT requires business default value
56
+ - W1-W6, W8-W10: require SQL semantics or schema decision
57
+ """
58
+
59
+ import json
60
+ import os
61
+ import re
62
+ import subprocess
63
+ import sys
64
+ from pathlib import Path
65
+
66
+
67
+ DB_CODE_FILE = re.compile(r"\.(java|xml)$", re.I)
68
+ SQL_SUMMARY_FILE = re.compile(r"(^|/)sql/.*\.sql$", re.I)
69
+ NO_SQL_ACK_FILE = re.compile(
70
+ r"(^|/)(openspec/.*\.(md|yaml|yml)|ReleaseNotes\.md)$",
71
+ re.I,
72
+ )
73
+ NO_SQL_ACK = re.compile(
74
+ r"(无|无需|不涉及|没有|未涉及).{0,20}"
75
+ r"(SQL|sql|数据库|表结构|字段|索引|DDL|ddl).{0,20}"
76
+ r"(变更|修改|调整|迁移|脚本|改动|新增)?|"
77
+ r"(SQL|sql|数据库|表结构|字段|索引|DDL|ddl).{0,20}"
78
+ r"(无|无需|不涉及|没有|未涉及).{0,20}"
79
+ r"(变更|修改|调整|迁移|脚本|改动|新增)?",
80
+ re.I,
81
+ )
82
+ SQL_STYLE_EXEMPT = re.compile(r"--\s*allow-dynamic-ddl\s*:", re.I)
83
+ SQL_RISK_RULE_EXEMPT = re.compile(
84
+ r"--\s*allow-sql-risk-rule\s*:\s*([BW]\d+(?:\s*,\s*[BW]\d+)*)",
85
+ re.I,
86
+ )
87
+ SQL_COMMENT_BLOCK = re.compile(r"/\*.*?\*/", re.S)
88
+ SQL_FORBIDDEN_PATTERNS = (
89
+ (
90
+ re.compile(r"\binformation_schema\b", re.I),
91
+ "[B3] 不要用 information_schema 判断字段或索引是否存在",
92
+ ),
93
+ (
94
+ re.compile(r"\bPREPARE\b", re.I),
95
+ "[B4] 不要用 PREPARE 动态执行 DDL",
96
+ ),
97
+ (
98
+ re.compile(r"\bEXECUTE\b", re.I),
99
+ "[B4] 不要用 EXECUTE 动态执行 DDL",
100
+ ),
101
+ (
102
+ re.compile(r"\bDEALLOCATE\s+PREPARE\b", re.I),
103
+ "[B4] 不要用 DEALLOCATE PREPARE 动态执行 DDL",
104
+ ),
105
+ (
106
+ re.compile(r"\bSET\s+@\w+\b", re.I),
107
+ "[B5] 不要用 SET @变量 拼接或控制 DDL",
108
+ ),
109
+ (
110
+ re.compile(r"\bADD\s+COLUMN\s+IF\s+NOT\s+EXISTS\b", re.I),
111
+ "[B1] 不要用 ADD COLUMN IF NOT EXISTS 静默兼容字段冲突",
112
+ ),
113
+ (
114
+ re.compile(
115
+ r"\bCREATE\s+(UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b",
116
+ re.I,
117
+ ),
118
+ "[B2] 不要用 CREATE INDEX IF NOT EXISTS 静默兼容索引冲突",
119
+ ),
120
+ (
121
+ re.compile(r"\bALGORITHM\s*=", re.I),
122
+ "[B6] 不要在发布 SQL 中强制指定 ALGORITHM",
123
+ ),
124
+ (
125
+ re.compile(r"\bLOCK\s*=", re.I),
126
+ "[B6] 不要在发布 SQL 中强制指定 LOCK",
127
+ ),
128
+ (
129
+ re.compile(r"\bFOREIGN\s+KEY\s*\(", re.I),
130
+ "[B7] 不要使用数据库外键(FOREIGN KEY)做强制关联,"
131
+ "改由代码层 + 唯一键 + 状态机控制一致性",
132
+ ),
133
+ (
134
+ re.compile(
135
+ r"\bADD\s+COLUMN\s+\w+\s+\w+(?:\s*\([^)]*\))?\s+NOT\s+NULL\b"
136
+ r"(?!\s+DEFAULT)",
137
+ re.I,
138
+ ),
139
+ "[B8] ADD COLUMN 的 NOT NULL 字段缺少 DEFAULT,老数据回填时该字段"
140
+ "以 NULL 写入导致失败;必须有 DEFAULT 或显式 NULL 允许",
141
+ ),
142
+ )
143
+
144
+
145
+ SQL_WARNING_PATTERNS = (
146
+ (
147
+ re.compile(
148
+ r"\bUPDATE\b[^;]*?\bSET\b\s+\w+\s*=\s*\(\s*SELECT\b",
149
+ re.I | re.S,
150
+ ),
151
+ "[W1] 回填类 UPDATE 使用了相关子查询,性能为 O(k·N);"
152
+ "应改写为 JOIN,单表扫描 O(N)。同时检查 W1 评审勾选",
153
+ ),
154
+ (
155
+ re.compile(
156
+ r"\bJSON_(?:EXTRACT|UNQUOTE)\b[\s\S]{0,400}?\bAS\s+"
157
+ r"(?!COALESCE\b|IFNULL\b|UNSIGNED\b|CHAR\b|VARCHAR\b|"
158
+ r"DECIMAL\b|INT\b|BIGINT\b|DATETIME\b|DATE\b|TIME\b|JSON\b)",
159
+ re.I,
160
+ ),
161
+ "[W2] JSON_EXTRACT/UNQUOTE 后未发现 COALESCE/IFNULL 兜底,"
162
+ "NULL 会传播到业务字段,破坏 -1=不限额等业务语义"
163
+ "(当 AS 后面直接接业务数值/字符串类型时容易漏 COALESCE)",
164
+ ),
165
+ (
166
+ re.compile(
167
+ r"\bUNIQUE\s+(?:KEY\s+)?\w+\s*\([^)]*\bDATETIME\b[^)]*\)",
168
+ re.I,
169
+ ),
170
+ "[W3] 唯一键包含 DATETIME 列,秒级默认精度下毫秒级业务可能冲突;"
171
+ "建议改 DATETIME(3) 或加 segment_seq INT 等序号;改精度时也要改源表字段"
172
+ ";改精度时也要核对所有源表和消费表字段",
173
+ ),
174
+ (
175
+ re.compile(r"\bINSERT\s+IGNORE\b", re.I),
176
+ "[W4] 迁移类 INSERT IGNORE 静默吞错;应改用显式 WHERE NOT EXISTS 预检查"
177
+ "或先清空目标表,让错误显式抛出",
178
+ ),
179
+ (
180
+ # W11: 关系同步表(含物理 delete/insert 模式)—— 实战中容易被误判为
181
+ # 业务实体表,W6 hook 检测不到的源码审查场景
182
+ re.compile(
183
+ r"\bCREATE\s+TABLE\b\s+(?:IF\s+NOT\s+EXISTS\s+)?\w+\s*\("
184
+ r"[\s\S]{0,2000}?"
185
+ r"\b(PRIMARY\s+KEY|UNIQUE)",
186
+ re.I | re.S,
187
+ ),
188
+ None, # 占位:W11 实际靠人工源码审查(见 reference §6.1 SOP),hook 抓不到
189
+ ),
190
+ )
191
+
192
+
193
+ # W12: 检测 JSON 提取后是否在 CASE WHEN REGEXP 中已做数字校验
194
+ # CASE WHEN REGEXP 校验比单纯 COALESCE 更严,
195
+ # 能同时兜底 NULL 和 "abc" 等非数字字符串。
196
+ W12_CAST_REGEX_PATTERN = re.compile(
197
+ r"CASE\s+[\s\S]{0,200}?"
198
+ r"\bWHEN\b[\s\S]{0,200}?"
199
+ r"\bREGEXP\b[\s\S]{0,200}?"
200
+ r"\bTHEN\b[\s\S]{0,100}?\bCAST\b",
201
+ re.I | re.S,
202
+ )
203
+
204
+
205
+ # W13: 宽索引(>3 列)—— 实战中需要源码审查(Mapper XML 查询)才能判断是否有用
206
+ # hook 只能识别"宽索引是否存在",不能识别"是否有查询支撑"
207
+ W13_WIDE_INDEX_PATTERN = re.compile(
208
+ r"\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+\w+\s+ON\s+\w+\s*\(([^)]+)\)",
209
+ re.I,
210
+ )
211
+
212
+
213
+ SQL_HEADER_REQUIRED_KEYS = (
214
+ "目标 MySQL 版本",
215
+ "关联批次",
216
+ "风险等级",
217
+ "评审人",
218
+ )
219
+ SQL_HEADER_WINDOW_LINES = 30
220
+
221
+
222
+ _RULE_LABELS = {
223
+ "B1": "ALTER ADD COLUMN IF NOT EXISTS",
224
+ "B2": "CREATE INDEX IF NOT EXISTS",
225
+ "B3": "INFORMATION_SCHEMA 判断",
226
+ "B4": "PREPARE/EXECUTE/DEALLOCATE PREPARE 动态 DDL",
227
+ "B5": "SET @变量 拼接 DDL",
228
+ "B6": "ALTER TABLE ALGORITHM=/LOCK= 强制",
229
+ "B7": "FOREIGN KEY 外键约束",
230
+ "B8": "ADD COLUMN NOT NULL 缺 DEFAULT",
231
+ "W1": "回填 UPDATE 相关子查询",
232
+ "W2": "JSON 提取缺 COALESCE/IFNULL 兜底",
233
+ "W3": "唯一键含 DATETIME 列",
234
+ "W4": "INSERT IGNORE 迁移",
235
+ "W5": "宽索引 > 3 列",
236
+ "W6": "业务实体表缺 update_time/deleted",
237
+ "W7": "SQL 文件头缺失",
238
+ "W8": "JSON 字段无 CHECK 约束",
239
+ "W9": "大批量迁移未分批",
240
+ "W10": "test-report 缺 SQL 收口对账表",
241
+ "W11": "关系同步表误判(源码审查)",
242
+ "W12": "JSON 提取 NULL 兜底不充分(实战:优先 CASE WHEN REGEXP)",
243
+ "W13": "宽索引无查询支撑(源码审查)",
244
+ }
245
+ _B_RULE_ORDER = ("B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8")
246
+ _W_RULE_ORDER = (
247
+ "W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8", "W9", "W10",
248
+ "W11", "W12", "W13",
249
+ )
250
+
251
+
252
+ EXIT_CLEAN = 0
253
+ EXIT_WARN_ONLY = 1
254
+ EXIT_FORBIDDEN = 2
255
+ EXIT_ERROR = 3
256
+
257
+
258
+ _FIX_TEMPLATES = {
259
+ "B3": "修复模板:移除 information_schema 判断;评审人根据目标表实际结构显式"
260
+ "写 ALTER/CREATE INDEX。字段已存在则让 DDL 报错显式暴露,不要静默兼容。",
261
+ "B4": "修复模板:把 PREPARE/EXECUTE 块替换为显式 DDL,例如\n"
262
+ " ALTER TABLE <t> ADD COLUMN <col> <type> DEFAULT <v>;\n"
263
+ " 评审人确认目标表当前结构后写入。",
264
+ "B5": "修复模板:把 'SET @var := ...' 拼接 SQL 字符串的逻辑改为显式 DDL;"
265
+ "评审人重写。",
266
+ "B7": "修复模板:移除外键约束;由代码层(Service + 唯一键 + 状态机)控制"
267
+ "一致性。评审人确认代码层已有等效的引用完整性保护。",
268
+ "B8": "修复模板:NOT NULL 字段必须显式 DEFAULT 或允许 NULL;评审人根据"
269
+ "业务语义选默认值(0/''/CURRENT_TIMESTAMP/-1 等)。",
270
+ "W1": "修复模板:相关子查询改 JOIN:\n"
271
+ " 原: UPDATE t SET x = (SELECT s.x FROM s WHERE s.id = t.id)\n"
272
+ " 新: UPDATE t JOIN s ON s.id = t.id SET t.x = s.x",
273
+ "W2": "修复模板:JSON 提取后必须 COALESCE 兜底:\n"
274
+ " 原: CAST(JSON_UNQUOTE(JSON_EXTRACT(j, '$.k')) AS DECIMAL(12,4))\n"
275
+ " 新: COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(j, '$.k')) AS DECIMAL(12,4)),"
276
+ " <业务默认值>)",
277
+ "W3": "修复模板:唯一键含 DATETIME 时指定精度 (3) 或加 segment_seq INT 替代"
278
+ "时间字段;评审人根据业务唯一性保障策略调整。",
279
+ "W4": "修复模板:INSERT IGNORE 改为显式预检查:\n"
280
+ " 原: INSERT IGNORE INTO t SELECT ...\n"
281
+ " 新: INSERT INTO t SELECT ... WHERE NOT EXISTS\n"
282
+ " (SELECT 1 FROM t WHERE t.id = src.id)\n"
283
+ " 或先清空目标表:DELETE FROM t; INSERT INTO t SELECT ...;",
284
+ "W5": "修复模板:单条索引 > 3 列时按业务查询场景拆分;评审人评估。",
285
+ "W6": "修复模板:业务实体表必须加 update_time DATETIME ON UPDATE "
286
+ "CURRENT_TIMESTAMP 和 deleted TINYINT NOT NULL DEFAULT 0;评审人"
287
+ "判断表类型(业务实体/快照/关系/流水)。",
288
+ "W7": "修复模板:已由 --auto-fix 自动添加文件头;评审人补充 '评审人/'涉及表'"
289
+ "'变更摘要' 字段。",
290
+ "W8": "修复模板:JSON 字段加 CHECK 约束:\n"
291
+ " CONSTRAINT chk_<table>_<col>_json\n"
292
+ " CHECK (<col> IS NULL OR JSON_TYPE(<col>) = 'OBJECT')\n"
293
+ " 评审人选 JSON_TYPE(OBJECT/ARRAY)。",
294
+ "W9": "修复模板:大批量迁移必须分批:\n"
295
+ " INSERT INTO t SELECT ... FROM src LIMIT 0, 5000; -- 循环\n"
296
+ " 评审人评估表大小。",
297
+ "W10": "修复模板:test-report.md 末尾加 SQL 收口对账表:\n"
298
+ " | P编号 | 表 | 字段/索引/数据 | 源码引用 | 总SQL位置 | "
299
+ "开发库状态 | 测试库状态 | 处理结论 |\n"
300
+ " 评审人填写。",
301
+ }
302
+ DB_HINT = re.compile(
303
+ r"(@TableName|BaseMapper<|<result\s+column=|<id\s+column=|"
304
+ r"@TableField|Wrappers\.lambdaQuery|LambdaQueryWrapper|QueryWrapper|"
305
+ r"\b(SELECT|UPDATE|INSERT\s+INTO|DELETE\s+FROM)\b[\s\S]{0,120}\b\w+\b|"
306
+ r"\b[A-Za-z][A-Za-z0-9]*Mapper\.xml\b|"
307
+ r"\b[a-z][a-z0-9_]*(status|type|amount|balance|quota|limit|count|flag|id)\b)",
308
+ re.I | re.S,
309
+ )
310
+ MYBATIS_PLUS_ENTITY = re.compile(r"@TableName\s*\(|BaseMapper<", re.I)
311
+ CROSS_SCHEMA_RISK = re.compile(
312
+ r"(@TableName\s*\(|BaseMapper<|LambdaQueryWrapper|QueryWrapper|"
313
+ r"@TableField\s*\(|resultMap|<result\s+column=|<id\s+column=)",
314
+ re.I,
315
+ )
316
+
317
+
318
+ def run(cmd, cwd=None):
319
+ return subprocess.run(
320
+ cmd,
321
+ cwd=cwd,
322
+ text=True,
323
+ capture_output=True,
324
+ timeout=10,
325
+ )
326
+
327
+
328
+ def repo_root(start):
329
+ result = run(["git", "-C", str(start), "rev-parse", "--show-toplevel"])
330
+ if result.returncode != 0:
331
+ return None
332
+ return Path(result.stdout.strip())
333
+
334
+
335
+ def staged_files(root):
336
+ result = run(["git", "diff", "--cached", "--name-only"], cwd=root)
337
+ if result.returncode != 0:
338
+ return []
339
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
340
+
341
+
342
+ def staged_content(root, path):
343
+ result = run(["git", "show", f":{path}"], cwd=root)
344
+ if result.returncode == 0:
345
+ return result.stdout
346
+ disk_path = root / path
347
+ if disk_path.is_file():
348
+ return disk_path.read_text(encoding="utf-8", errors="ignore")
349
+ return ""
350
+
351
+
352
+ def sql_content_without_comments(content):
353
+ content = SQL_COMMENT_BLOCK.sub("", content or "")
354
+ lines = []
355
+ for line in content.splitlines():
356
+ idx_dash = line.find("--")
357
+ if idx_dash >= 0:
358
+ line = line[:idx_dash]
359
+ idx_hash = line.find("#")
360
+ if idx_hash >= 0:
361
+ line = line[:idx_hash]
362
+ lines.append(line.rstrip())
363
+ return "\n".join(lines)
364
+
365
+
366
+ def line_number(content, index):
367
+ return content.count("\n", 0, index) + 1
368
+
369
+
370
+ def _exempted_rules(content):
371
+ """Parse all `-- allow-sql-risk-rule: B3,B4` directives in content.
372
+
373
+ Returns a set of uppercased rule ids. Per-file exemption is the
374
+ implementation chosen here to keep the linter stateless; line-level
375
+ scope can be layered on later if needed.
376
+ """
377
+ rules = set()
378
+ for match in SQL_RISK_RULE_EXEMPT.finditer(content or ""):
379
+ for token in match.group(1).split(","):
380
+ token = token.strip().upper()
381
+ if token:
382
+ rules.add(token)
383
+ return rules
384
+
385
+
386
+ def _rule_id_from_reason(reason):
387
+ if not reason:
388
+ return ""
389
+ return reason.split("]", 1)[0].lstrip("[").strip().upper()
390
+
391
+
392
+ def _check_sql_header(content):
393
+ head = "\n".join((content or "").splitlines()[:SQL_HEADER_WINDOW_LINES])
394
+ return [k for k in SQL_HEADER_REQUIRED_KEYS if k not in head]
395
+
396
+
397
+ def lint_sql_style(path, content):
398
+ if not SQL_SUMMARY_FILE.search(path):
399
+ return []
400
+ if SQL_STYLE_EXEMPT.search(content or ""):
401
+ return []
402
+
403
+ check_content = sql_content_without_comments(content)
404
+ exempted = _exempted_rules(content)
405
+ issues = []
406
+ for pattern, reason in SQL_FORBIDDEN_PATTERNS:
407
+ if _rule_id_from_reason(reason) in exempted:
408
+ continue
409
+ for match in pattern.finditer(check_content):
410
+ issues.append(
411
+ f"{path}:{line_number(check_content, match.start())} {reason}"
412
+ )
413
+ return issues
414
+
415
+
416
+ def lint_sql_warnings(path, content):
417
+ if not SQL_SUMMARY_FILE.search(path):
418
+ return []
419
+ if SQL_STYLE_EXEMPT.search(content or ""):
420
+ return []
421
+
422
+ check_content = sql_content_without_comments(content)
423
+ exempted = _exempted_rules(content)
424
+ warnings = []
425
+ for pattern, reason in SQL_WARNING_PATTERNS:
426
+ if reason is None:
427
+ continue # 占位规则(hook 抓不到,靠源码审查)
428
+ if _rule_id_from_reason(reason) in exempted:
429
+ continue
430
+ for match in pattern.finditer(check_content):
431
+ # W2 精确度提升:如果整段 SQL 包含 CASE WHEN REGEXP 数字校验
432
+ # 模式,W2 视为已覆盖,不重复警告
433
+ if _rule_id_from_reason(reason) == "W2" and W12_CAST_REGEX_PATTERN.search(
434
+ check_content
435
+ ):
436
+ break
437
+ warnings.append(
438
+ f"{path}:{line_number(check_content, match.start())} {reason}"
439
+ )
440
+
441
+ # W11 占位提示:hook 抓不到,靠源码审查(仅在 SQL 提到 mapper.delete/
442
+ # deleted/物理删除模式时给出"请确认表类型"提示)
443
+ if re.search(
444
+ r"\bmapper\.delete\b|物理删除|关系同步表|业务实体表",
445
+ check_content,
446
+ re.I,
447
+ ):
448
+ warnings.append(
449
+ f"{path}:1 [W11] 提示:检测到物理 delete 或表类型讨论关键字,"
450
+ "请按 reference §6.1 源码审查 SOP 确认:业务 Service 是"
451
+ " mapper.delete()(物理)还是 deleted=1(软删除),"
452
+ "避免 W6/W11 误判"
453
+ )
454
+
455
+ # W13 宽索引提示:列出索引列数,提示需源码审查
456
+ for match in W13_WIDE_INDEX_PATTERN.finditer(check_content):
457
+ cols_text = match.group(1)
458
+ cols = [c.strip() for c in cols_text.split(",") if c.strip()]
459
+ if len(cols) > 3:
460
+ line_no = line_number(check_content, match.start())
461
+ warnings.append(
462
+ f"{path}:{line_no} [W13] 检测到 {len(cols)} 列宽索引("
463
+ f"{', '.join(cols)});按 reference §6.1 源码审查 SOP,"
464
+ "需审查 Mapper XML/Repository 真实查询和数据量,"
465
+ "确认索引是否真有加速效果"
466
+ )
467
+
468
+ missing = _check_sql_header(content)
469
+ if missing:
470
+ warnings.append(
471
+ f"{path}:1 [W7] SQL 文件头缺失必填键:{', '.join(missing)};"
472
+ "参考 sql-risk-review-checklist.md §4 模板"
473
+ )
474
+ return warnings
475
+
476
+
477
+ def is_db_related_file(path, content):
478
+ if not DB_CODE_FILE.search(path):
479
+ return False
480
+ normalized = path.replace("\\", "/")
481
+ if "/mapper/" in normalized.lower() or normalized.endswith("Mapper.xml"):
482
+ return True
483
+ if DB_HINT.search(content or ""):
484
+ return True
485
+ return False
486
+
487
+
488
+ def no_sql_ack_files(root, files):
489
+ if os.environ.get("SDD_SQL_NO_CHANGE") == "1":
490
+ return ["环境变量 SDD_SQL_NO_CHANGE=1"]
491
+
492
+ matches = []
493
+ for path in files:
494
+ if not NO_SQL_ACK_FILE.search(path):
495
+ continue
496
+ content = staged_content(root, path)
497
+ if NO_SQL_ACK.search(content or ""):
498
+ matches.append(path)
499
+ return matches
500
+
501
+
502
+ def warn(message, block=False):
503
+ prefix = "阻断" if block else "提醒"
504
+ print(f"[SDD SQL 收口{prefix}] {message}", file=sys.stderr)
505
+
506
+
507
+ def handle_commit(command, root):
508
+ if not re.search(r"\bgit\s+commit\b", command):
509
+ return 0
510
+
511
+ files = staged_files(root)
512
+ if not files:
513
+ return 0
514
+
515
+ sql_files = [path for path in files if SQL_SUMMARY_FILE.search(path)]
516
+ db_files = []
517
+ schema_risk_files = []
518
+ sql_style_issues = []
519
+ for path in files:
520
+ content = staged_content(root, path)
521
+ if SQL_SUMMARY_FILE.search(path):
522
+ sql_style_issues.extend(lint_sql_style(path, content))
523
+ continue
524
+ if is_db_related_file(path, content):
525
+ db_files.append(path)
526
+ if CROSS_SCHEMA_RISK.search(content or ""):
527
+ schema_risk_files.append(path)
528
+
529
+ if sql_style_issues:
530
+ warn(
531
+ "检测到已暂存的 SQL 使用过度兼容或动态 DDL 写法。\n"
532
+ "发布 SQL 应使用普通 ALTER TABLE / CREATE INDEX,让字段或索引"
533
+ "冲突在部署时直接暴露。\n"
534
+ "如确需例外,请在 SQL 文件中加入明确白名单注释:"
535
+ "-- allow-dynamic-ddl: 原因。\n"
536
+ "问题:\n - "
537
+ + "\n - ".join(sql_style_issues[:12]),
538
+ block=True,
539
+ )
540
+ return 2
541
+
542
+ if schema_risk_files:
543
+ warn(
544
+ "检测到已暂存的 MyBatis/MyBatis-Plus 实体、Mapper 或查询条件变更。\n"
545
+ "请确认已完成跨仓数据合同对账:表结构真源、全部消费仓、"
546
+ "实体/Mapper/SQL 字段、真实库 information_schema/SHOW CREATE。\n"
547
+ "重点检查 BaseMapper 是否会 SELECT 不存在列,旧字段是否已 "
548
+ "@TableField(exist = false) 或删除,查询条件是否仍依赖已迁移字段。\n"
549
+ "相关文件:\n - "
550
+ + "\n - ".join(schema_risk_files[:12]),
551
+ block=False,
552
+ )
553
+
554
+ if not db_files or sql_files:
555
+ return 0
556
+
557
+ ack_files = no_sql_ack_files(root, files)
558
+ if ack_files:
559
+ warn(
560
+ "检测到疑似涉库 Java/XML 变更,但本次提交已有“无 SQL 变更”说明,"
561
+ "不再要求创建 sql/ 下的空标记文件。\n"
562
+ "确认来源:\n - "
563
+ + "\n - ".join(ack_files[:12]),
564
+ block=False,
565
+ )
566
+ return 0
567
+
568
+ block = (root / ".sdd-enforced").exists()
569
+ message = (
570
+ "检测到已暂存的 Java/XML 变更疑似依赖数据库结构,但本次提交未暂存 "
571
+ "sql/ 下的版本总 SQL 文件。\n"
572
+ "请确认已完成三方对账:源码/Mapper、开发库、测试库现状+总SQL。\n"
573
+ "若本任务确认没有 SQL/数据库/表结构变更,请在 OpenSpec 或 "
574
+ "ReleaseNotes 中写明“无 SQL 变更”或“不涉及数据库变更”,"
575
+ "不要创建 noop.sql 空标记文件。\n"
576
+ "涉库文件:\n - "
577
+ + "\n - ".join(db_files[:12])
578
+ )
579
+ if len(db_files) > 12:
580
+ message += f"\n ... 共 {len(db_files)} 个文件"
581
+ warn(message, block=block)
582
+ return 2 if block else 0
583
+
584
+
585
+ def handle_edit(file_path, edit_content):
586
+ if not file_path:
587
+ return 0
588
+ if SQL_SUMMARY_FILE.search(file_path):
589
+ sql_style_issues = lint_sql_style(file_path, edit_content)
590
+ if sql_style_issues:
591
+ warn(
592
+ "正在写入的 SQL 使用过度兼容或动态 DDL 写法。\n"
593
+ "发布 SQL 应使用普通 ALTER TABLE / CREATE INDEX。\n"
594
+ "问题:\n - "
595
+ + "\n - ".join(sql_style_issues[:12]),
596
+ block=True,
597
+ )
598
+ return 2
599
+ return 0
600
+ if not is_db_related_file(file_path, edit_content):
601
+ return 0
602
+ warn(
603
+ "正在编辑疑似涉库 Java/XML 文件。若本次改动新增或修改表、字段、索引、"
604
+ "初始化数据依赖,任务完成前必须同步版本总 SQL 并输出 SQL 收口对账表。",
605
+ block=False,
606
+ )
607
+ if MYBATIS_PLUS_ENTITY.search(edit_content or ""):
608
+ warn(
609
+ "检测到 MyBatis-Plus @TableName/BaseMapper 相关编辑。"
610
+ "如果实体字段不在真实表结构中,BaseMapper 默认 SELECT 会运行时失败;"
611
+ "跨仓复制实体时必须逐仓对账,不存在列请删除或标注 "
612
+ "@TableField(exist = false)。",
613
+ block=False,
614
+ )
615
+ return 0
616
+
617
+
618
+ def handle_check_staged():
619
+ root = repo_root(Path.cwd())
620
+ if root is None:
621
+ return 0
622
+ files = staged_files(root)
623
+ sql_style_issues = []
624
+ sql_warning_issues = []
625
+ for path in files:
626
+ if SQL_SUMMARY_FILE.search(path):
627
+ content = staged_content(root, path)
628
+ sql_style_issues.extend(lint_sql_style(path, content))
629
+ sql_warning_issues.extend(lint_sql_warnings(path, content))
630
+
631
+ if sql_warning_issues:
632
+ warn(
633
+ "检测到已暂存的 SQL 命中警告项(W1-W13),提交流程不阻断,"
634
+ "但 test-report.md 必须记录每条处理结论。\n"
635
+ "参考 sql-risk-review-checklist.md 警告项说明。\n"
636
+ "问题:\n - "
637
+ + "\n - ".join(sql_warning_issues[:12])
638
+ + (f"\n ... 共 {len(sql_warning_issues)} 条" if len(sql_warning_issues) > 12 else ""),
639
+ block=False,
640
+ )
641
+
642
+ if not sql_style_issues:
643
+ return 0
644
+
645
+ warn(
646
+ "检测到已暂存的 SQL 使用禁用项(B1-B8)。\n"
647
+ "发布 SQL 应使用普通 ALTER TABLE / CREATE INDEX,让字段或索引"
648
+ "冲突在部署时直接暴露。\n"
649
+ "如确需例外,请在 SQL 文件中加入明确白名单注释:\n"
650
+ " -- allow-sql-risk-rule: B3,B4 # 仅豁免指定规则\n"
651
+ " -- allow-dynamic-ddl: <原因> # 整文件豁免(慎用)\n"
652
+ "问题:\n - "
653
+ + "\n - ".join(sql_style_issues[:12]),
654
+ block=True,
655
+ )
656
+ return 2
657
+
658
+
659
+ def _rule_id_from_issue(issue):
660
+ if "[" in issue and "]" in issue:
661
+ lb = issue.index("[")
662
+ rb = issue.index("]", lb)
663
+ return issue[lb + 1:rb].strip().upper()
664
+ return "未分类"
665
+
666
+
667
+ def _format_risk_report(sql_files, all_issues, all_warnings, title):
668
+ lines = [f"[{title}] 扫描文件: {len(sql_files)}"]
669
+ if sql_files:
670
+ lines.append(" " + "\n ".join(sql_files))
671
+ lines.append("")
672
+
673
+ by_issue = {}
674
+ for issue in all_issues:
675
+ by_issue.setdefault(_rule_id_from_issue(issue), []).append(issue)
676
+ by_warning = {}
677
+ for warning in all_warnings:
678
+ by_warning.setdefault(_rule_id_from_issue(warning), []).append(warning)
679
+
680
+ lines.append("【禁用项 B1-B8】(命中会阻断 commit)")
681
+ for rule_id in _B_RULE_ORDER:
682
+ hits = by_issue.get(rule_id, [])
683
+ if not hits:
684
+ continue
685
+ label = _RULE_LABELS.get(rule_id, rule_id)
686
+ lines.append(f" {rule_id} {label}(命中 {len(hits)})")
687
+ for h in hits:
688
+ lines.append(f" - {h}")
689
+ if not by_issue:
690
+ lines.append(" 无命中")
691
+
692
+ lines.append("")
693
+ lines.append("【警告项 W1-W13】(不阻断,test-report 须记录处理结论)")
694
+ for rule_id in _W_RULE_ORDER:
695
+ hits = by_warning.get(rule_id, [])
696
+ if not hits:
697
+ continue
698
+ label = _RULE_LABELS.get(rule_id, rule_id)
699
+ lines.append(f" {rule_id} {label}(命中 {len(hits)})")
700
+ for h in hits:
701
+ lines.append(f" - {h}")
702
+ if not by_warning:
703
+ lines.append(" 无命中")
704
+ return "\n".join(lines) + "\n"
705
+
706
+
707
+ def _scan_sql_files(root, paths):
708
+ sql_files = []
709
+ for p in paths:
710
+ if p.is_file() and SQL_SUMMARY_FILE.search(str(p)):
711
+ try:
712
+ sql_files.append(str(p.relative_to(root)))
713
+ except ValueError:
714
+ sql_files.append(str(p))
715
+ return sorted(sql_files)
716
+
717
+
718
+ def _collect_risks(root, sql_files):
719
+ all_issues = []
720
+ all_warnings = []
721
+ for rel in sql_files:
722
+ full_path = root / rel
723
+ if not full_path.is_file():
724
+ continue
725
+ content = full_path.read_text(encoding="utf-8", errors="ignore")
726
+ all_issues.extend(lint_sql_style(rel, content))
727
+ all_warnings.extend(lint_sql_warnings(rel, content))
728
+ return all_issues, all_warnings
729
+
730
+
731
+ def _exit_code(all_issues, all_warnings):
732
+ if all_issues:
733
+ return EXIT_FORBIDDEN
734
+ if all_warnings:
735
+ return EXIT_WARN_ONLY
736
+ return EXIT_CLEAN
737
+
738
+
739
+ def handle_risk_review():
740
+ """Scan SQL files under cwd; non-blocking report (exit 0/1/2)."""
741
+ root = Path.cwd()
742
+ sql_files = _scan_sql_files(root, root.glob("sql/**/*.sql"))
743
+ if not sql_files:
744
+ print("[SDD SQL 风险评审] 未发现 sql/**/*.sql 文件")
745
+ return EXIT_CLEAN
746
+
747
+ all_issues, all_warnings = _collect_risks(root, sql_files)
748
+ print(_format_risk_report(
749
+ sql_files, all_issues, all_warnings, "SDD SQL 风险评审"
750
+ ))
751
+ return _exit_code(all_issues, all_warnings)
752
+
753
+
754
+ def handle_check_all():
755
+ """Scan SQL files under git root; CI-friendly exit codes (0/1/2/3)."""
756
+ root = repo_root(Path.cwd())
757
+ if root is None:
758
+ warn("当前目录不在 Git 仓库内,无法定位 sql/ 根目录", block=False)
759
+ return EXIT_ERROR
760
+ sql_files = _scan_sql_files(root, root.glob("sql/**/*.sql"))
761
+ if not sql_files:
762
+ print(f"[SDD SQL 全量扫描] git root={root} 未发现 sql/**/*.sql 文件")
763
+ return EXIT_CLEAN
764
+
765
+ all_issues, all_warnings = _collect_risks(root, sql_files)
766
+ print(_format_risk_report(
767
+ sql_files, all_issues, all_warnings, "SDD SQL 全量扫描"
768
+ ))
769
+ print(
770
+ f"[退出码] clean={EXIT_CLEAN} warn_only={EXIT_WARN_ONLY} "
771
+ f"forbidden={EXIT_FORBIDDEN};本次返回 "
772
+ f"{_exit_code(all_issues, all_warnings)}"
773
+ )
774
+ return _exit_code(all_issues, all_warnings)
775
+
776
+
777
+ def _generate_sql_header(path):
778
+ """Build a SQL header block inferred from filename."""
779
+ name = Path(path).name
780
+ m = re.match(r"^v(\d+\.\d+\.\d+)(?:\.([a-zA-Z0-9_-]+))?\.sql$", name)
781
+ if m:
782
+ version = m.group(1)
783
+ batch = m.group(2) or f"批次-{Path(path).stem}"
784
+ else:
785
+ version = "未知"
786
+ batch = f"批次-{Path(path).stem}"
787
+ return (
788
+ "-- ============================================================================\n"
789
+ f"-- 目标 MySQL 版本:5.7\n"
790
+ "-- 平台 SQL 解析校验:(待填,parser/version)\n"
791
+ f"-- 关联批次:{batch}\n"
792
+ "-- 风险等级:中\n"
793
+ "-- 评审人:(待填)\n"
794
+ "-- 评审 checklist:参考 ~/.codex/skills/superflow-pipeline/references/sql-risk-review-checklist.md\n"
795
+ "-- 涉及表:(待补充)\n"
796
+ "-- 变更摘要:(待补充)\n"
797
+ "-- ============================================================================\n"
798
+ )
799
+
800
+
801
+ def _auto_fix_sql(path, content):
802
+ """Apply safe auto-fixes; return (new_content, auto_fixes, manual_items).
803
+
804
+ Auto-fixable: B1 (drop IF NOT EXISTS from ADD COLUMN),
805
+ B2 (drop IF NOT EXISTS from CREATE INDEX),
806
+ B6 (drop ALGORITHM= / LOCK= sub-clauses),
807
+ W7 (prepend SQL file header).
808
+ Manual review: anything remaining, with fix templates.
809
+ """
810
+ fixed = content
811
+ auto = []
812
+
813
+ new, n = re.subn(
814
+ r"(\bADD\s+COLUMN\s+[^,\n]+?)\s+IF\s+NOT\s+EXISTS\b",
815
+ r"\1", fixed, flags=re.I,
816
+ )
817
+ if n:
818
+ auto.append(f"[B1] 移除 {n} 处 'IF NOT EXISTS' (ADD COLUMN)")
819
+ fixed = new
820
+
821
+ new, n = re.subn(
822
+ r"(\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+\w+\s+ON\s+[^,\n]+?)"
823
+ r"\s+IF\s+NOT\s+EXISTS\b",
824
+ r"\1", fixed, flags=re.I,
825
+ )
826
+ if n:
827
+ auto.append(f"[B2] 移除 {n} 处 'IF NOT EXISTS' (CREATE INDEX)")
828
+ fixed = new
829
+
830
+ new = re.sub(r",\s*ALGORITHM\s*=\s*\w+", "", fixed, flags=re.I)
831
+ new, n_alg = re.subn(r",\s*ALGORITHM\s*=\s*\w+", "", fixed, flags=re.I)
832
+ new, n_lock = re.subn(r",\s*LOCK\s*=\s*\w+", "", new, flags=re.I)
833
+ if n_alg or n_lock:
834
+ auto.append(
835
+ f"[B6] 移除 {n_alg} 处 ALGORITHM= 和 {n_lock} 处 LOCK= 子句"
836
+ )
837
+ fixed = new
838
+
839
+ if _check_sql_header(fixed):
840
+ header = _generate_sql_header(path)
841
+ fixed = header + fixed
842
+ auto.append("[W7] 添加 SQL 文件头(版本/批次已基于路径推断,"
843
+ "评审人/涉及表/变更摘要待人工补充)")
844
+
845
+ issues = lint_sql_style(path, fixed)
846
+ warnings = lint_sql_warnings(path, fixed)
847
+ manual = list(issues) + list(warnings)
848
+ return fixed, auto, manual
849
+
850
+
851
+ def _print_fix_template(rule_id):
852
+ template = _FIX_TEMPLATES.get(rule_id)
853
+ if not template:
854
+ return
855
+ for line in template.split("\n"):
856
+ print(f" {line}")
857
+
858
+
859
+ def handle_auto_fix(write):
860
+ """Scan SQL files, apply safe auto-fixes, output per-file report.
861
+
862
+ write=False (default): dry-run, print report only.
863
+ write=True: write fixed content to disk with .bak backup.
864
+ """
865
+ root = Path.cwd()
866
+ sql_files = _scan_sql_files(root, root.glob("sql/**/*.sql"))
867
+ if not sql_files:
868
+ print("[SDD SQL 自动修复] 未发现 sql/**/*.sql 文件")
869
+ return EXIT_CLEAN
870
+
871
+ mode = "WRITE" if write else "DRY-RUN"
872
+ print(f"[SDD SQL 自动修复] 扫描文件: {len(sql_files)} 模式: {mode}\n")
873
+
874
+ total_auto = 0
875
+ total_manual = 0
876
+ for rel in sql_files:
877
+ full_path = root / rel
878
+ content = full_path.read_text(encoding="utf-8", errors="ignore")
879
+ fixed, auto, manual = _auto_fix_sql(rel, content)
880
+
881
+ if not auto and not manual:
882
+ print(f" ✓ {rel} 无风险")
883
+ continue
884
+
885
+ if auto:
886
+ print(f"\n=== {rel} ===")
887
+ for a in auto:
888
+ print(f" ✅ [自动修] {a}")
889
+ total_auto += len(auto)
890
+ if write:
891
+ bak_path = full_path.with_suffix(full_path.suffix + ".bak")
892
+ bak_path.write_text(content, encoding="utf-8")
893
+ full_path.write_text(fixed, encoding="utf-8")
894
+ print(f" 📝 已写入 {rel}(备份: {bak_path.name})")
895
+ else:
896
+ print(f"\n=== {rel} ===")
897
+
898
+ for m in manual:
899
+ rule_id = _rule_id_from_issue(m)
900
+ print(f" ⚠️ [需开发者确认] {m}")
901
+ _print_fix_template(rule_id)
902
+ total_manual += 1
903
+
904
+ print(f"\n=== 总结 ===")
905
+ print(f"自动修复:{total_auto} 项")
906
+ print(f"需开发者确认:{total_manual} 项")
907
+ if not write and total_auto:
908
+ print(f"\n如确认修复,运行:python3 ~/.codex/hooks/"
909
+ f"superflow-sql-sync-hook.py --auto-fix-write")
910
+ return EXIT_CLEAN
911
+
912
+
913
+ def main():
914
+ if len(sys.argv) > 1:
915
+ if sys.argv[1] == "--check-staged":
916
+ return handle_check_staged()
917
+ if sys.argv[1] == "--risk-review":
918
+ return handle_risk_review()
919
+ if sys.argv[1] == "--check-all":
920
+ return handle_check_all()
921
+ if sys.argv[1] == "--auto-fix":
922
+ return handle_auto_fix(write=False)
923
+ if sys.argv[1] == "--auto-fix-write":
924
+ return handle_auto_fix(write=True)
925
+
926
+ try:
927
+ payload = json.loads(sys.stdin.read() or "{}")
928
+ except json.JSONDecodeError:
929
+ return 0
930
+
931
+ tool_input = payload.get("tool_input", {})
932
+ command = tool_input.get("command") or tool_input.get("cmd") or ""
933
+ file_path = tool_input.get("file_path") or ""
934
+
935
+ start = Path.cwd()
936
+ if file_path:
937
+ start = Path(file_path).expanduser().resolve().parent
938
+ root = repo_root(start)
939
+ if root is None:
940
+ return 0
941
+
942
+ if command:
943
+ return handle_commit(command, root)
944
+
945
+ edit_content = tool_input.get("new_string") or tool_input.get("content") or ""
946
+ return handle_edit(file_path, edit_content)
947
+
948
+
949
+ if __name__ == "__main__":
950
+ sys.exit(main())