@michaelxwb/code-flow 0.5.1

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 (99) hide show
  1. package/README.md +157 -0
  2. package/package.json +16 -0
  3. package/src/adapters/claude/CLAUDE.md +51 -0
  4. package/src/adapters/claude/commands/cf-init.md +262 -0
  5. package/src/adapters/claude/commands/cf-inject.md +55 -0
  6. package/src/adapters/claude/commands/cf-learn.md +291 -0
  7. package/src/adapters/claude/commands/cf-stats.md +86 -0
  8. package/src/adapters/claude/commands/cf-task/align.md +283 -0
  9. package/src/adapters/claude/commands/cf-task/archive.md +131 -0
  10. package/src/adapters/claude/commands/cf-task/block.md +30 -0
  11. package/src/adapters/claude/commands/cf-task/graph.md +73 -0
  12. package/src/adapters/claude/commands/cf-task/note.md +239 -0
  13. package/src/adapters/claude/commands/cf-task/plan.md +270 -0
  14. package/src/adapters/claude/commands/cf-task/prd.md +138 -0
  15. package/src/adapters/claude/commands/cf-task/start.md +173 -0
  16. package/src/adapters/claude/commands/cf-task/status.md +59 -0
  17. package/src/adapters/claude/commands/cf-validate.md +73 -0
  18. package/src/adapters/claude/settings.local.json +60 -0
  19. package/src/adapters/codex/AGENTS.md +51 -0
  20. package/src/adapters/codex/config.toml +3 -0
  21. package/src/adapters/codex/hooks.json +48 -0
  22. package/src/adapters/codex/skills/cf-init/SKILL.md +271 -0
  23. package/src/adapters/codex/skills/cf-inject/SKILL.md +56 -0
  24. package/src/adapters/codex/skills/cf-learn/SKILL.md +296 -0
  25. package/src/adapters/codex/skills/cf-stats/SKILL.md +87 -0
  26. package/src/adapters/codex/skills/cf-task-align/SKILL.md +284 -0
  27. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +132 -0
  28. package/src/adapters/codex/skills/cf-task-block/SKILL.md +31 -0
  29. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +74 -0
  30. package/src/adapters/codex/skills/cf-task-note/SKILL.md +236 -0
  31. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +271 -0
  32. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +139 -0
  33. package/src/adapters/codex/skills/cf-task-start/SKILL.md +174 -0
  34. package/src/adapters/codex/skills/cf-task-status/SKILL.md +60 -0
  35. package/src/adapters/codex/skills/cf-validate/SKILL.md +74 -0
  36. package/src/adapters/costrict/CLAUDE.md +51 -0
  37. package/src/adapters/costrict/commands/cf-init.md +262 -0
  38. package/src/adapters/costrict/commands/cf-inject.md +55 -0
  39. package/src/adapters/costrict/commands/cf-learn.md +291 -0
  40. package/src/adapters/costrict/commands/cf-stats.md +86 -0
  41. package/src/adapters/costrict/commands/cf-task/align.md +283 -0
  42. package/src/adapters/costrict/commands/cf-task/archive.md +131 -0
  43. package/src/adapters/costrict/commands/cf-task/block.md +30 -0
  44. package/src/adapters/costrict/commands/cf-task/graph.md +73 -0
  45. package/src/adapters/costrict/commands/cf-task/note.md +239 -0
  46. package/src/adapters/costrict/commands/cf-task/plan.md +270 -0
  47. package/src/adapters/costrict/commands/cf-task/prd.md +138 -0
  48. package/src/adapters/costrict/commands/cf-task/start.md +173 -0
  49. package/src/adapters/costrict/commands/cf-task/status.md +59 -0
  50. package/src/adapters/costrict/commands/cf-validate.md +73 -0
  51. package/src/adapters/costrict/settings.local.json +60 -0
  52. package/src/adapters/opencode/AGENTS.md +51 -0
  53. package/src/adapters/opencode/commands/cf-init.md +275 -0
  54. package/src/adapters/opencode/commands/cf-inject.md +59 -0
  55. package/src/adapters/opencode/commands/cf-learn.md +295 -0
  56. package/src/adapters/opencode/commands/cf-stats.md +90 -0
  57. package/src/adapters/opencode/commands/cf-task/align.md +287 -0
  58. package/src/adapters/opencode/commands/cf-task/archive.md +135 -0
  59. package/src/adapters/opencode/commands/cf-task/block.md +34 -0
  60. package/src/adapters/opencode/commands/cf-task/graph.md +77 -0
  61. package/src/adapters/opencode/commands/cf-task/note.md +243 -0
  62. package/src/adapters/opencode/commands/cf-task/plan.md +274 -0
  63. package/src/adapters/opencode/commands/cf-task/prd.md +142 -0
  64. package/src/adapters/opencode/commands/cf-task/start.md +177 -0
  65. package/src/adapters/opencode/commands/cf-task/status.md +63 -0
  66. package/src/adapters/opencode/commands/cf-validate.md +77 -0
  67. package/src/adapters/opencode/opencode.json +4 -0
  68. package/src/adapters/opencode/plugins/code-flow/index.js +132 -0
  69. package/src/adapters/opencode/plugins/code-flow/package.json +6 -0
  70. package/src/cli.js +810 -0
  71. package/src/core/code-flow/config.yml +159 -0
  72. package/src/core/code-flow/scripts/cf_checks.py +306 -0
  73. package/src/core/code-flow/scripts/cf_core.py +954 -0
  74. package/src/core/code-flow/scripts/cf_feedback.py +56 -0
  75. package/src/core/code-flow/scripts/cf_inject_hook.py +178 -0
  76. package/src/core/code-flow/scripts/cf_log.py +146 -0
  77. package/src/core/code-flow/scripts/cf_post_hook.py +196 -0
  78. package/src/core/code-flow/scripts/cf_scan.py +293 -0
  79. package/src/core/code-flow/scripts/cf_session_hook.py +42 -0
  80. package/src/core/code-flow/scripts/cf_stats.py +439 -0
  81. package/src/core/code-flow/scripts/cf_stop_hook.py +191 -0
  82. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +402 -0
  83. package/src/core/code-flow/specs/backend/_map.md +56 -0
  84. package/src/core/code-flow/specs/backend/code-quality-performance.md +47 -0
  85. package/src/core/code-flow/specs/backend/database.md +39 -0
  86. package/src/core/code-flow/specs/backend/directory-structure.md +42 -0
  87. package/src/core/code-flow/specs/backend/logging.md +38 -0
  88. package/src/core/code-flow/specs/backend/platform-rules.md +43 -0
  89. package/src/core/code-flow/specs/frontend/_map.md +52 -0
  90. package/src/core/code-flow/specs/frontend/component-specs.md +56 -0
  91. package/src/core/code-flow/specs/frontend/directory-structure.md +41 -0
  92. package/src/core/code-flow/specs/frontend/quality-standards.md +46 -0
  93. package/src/core/code-flow/specs/shared/_map.md +57 -0
  94. package/src/core/code-flow/specs/shared/design/design-frontend.md +247 -0
  95. package/src/core/code-flow/specs/shared/design/design-full.md +494 -0
  96. package/src/core/code-flow/specs/shared/design/design-lite.md +293 -0
  97. package/src/core/code-flow/specs/shared/prd-template.md +187 -0
  98. package/src/core/code-flow/tasks/.gitkeep +0 -0
  99. package/src/core/code-flow/validation.yml +48 -0
@@ -0,0 +1,159 @@
1
+ version: 1
2
+
3
+ budget:
4
+ total: 2500
5
+ l0_max: 800
6
+ l1_max: 1700
7
+ map_max: 400
8
+ # catalog 模式下 spec 目录的 token 上限(超出按 Tier1 优先截断并告警)
9
+ catalog_max: 200
10
+
11
+ inject:
12
+ auto: true
13
+ # 注入模式:
14
+ # catalog — UserPromptSubmit 仅注入极小 spec 目录(每 spec 一行适用场景),
15
+ # 由模型按语义自行 Read 相关 spec 全文;prompt 含明确文件路径且
16
+ # 命中 domain 时仍直接注入完整约束。PreToolUse 不受影响(编辑时
17
+ # 始终强制注入完整约束兜底)。
18
+ # full — 旧行为:UserPromptSubmit 按 tag 词表匹配注入完整 spec。
19
+ # 仅 literal "catalog" 启用目录模式;缺失/其他值一律 full(升级用户行为不变)。
20
+ mode: catalog
21
+
22
+ # 质量闭环(v0.5):合规反馈 + 学习采集。
23
+ # enabled 仅 literal true 启用(缺失即关闭,升级用户行为不变);
24
+ # 子开关缺省跟随 enabled,仅 literal false 单独关闭。
25
+ # 数据仅存本地 .code-flow/(.session-log.jsonl / .check-state.json)。
26
+ quality_loop:
27
+ enabled: true
28
+ post_check: true # PostToolUse 违规反馈(只提示不阻断)
29
+ stop_check: true # Stop 收尾按 validation.yml 校验
30
+ correction_capture: true # 纠正句式采集(喂 cf-learn 候选)
31
+ # 注入时对 spec 内容做保守无损压缩(去行尾空白、折叠多空行、剥 HTML 注释、去重 bullet)
32
+ # 缺省视为 true;仅显式 false 关闭
33
+ compress: true
34
+ # UserPromptSubmit 去重窗口:同一 spec 在最近 N 个 prompt 内不重复注入。
35
+ # 0 或负数 关闭去重(每次都注入,等同旧行为);缺省 5。
36
+ # 窗口"限速"而非"永久跳过",超过 N 个 prompt 后会再次注入,保证 auto-compaction
37
+ # 丢掉早期上下文后仍能恢复 spec。
38
+ dedup_window: 5
39
+ code_extensions:
40
+ - ".py"
41
+ - ".ts"
42
+ - ".tsx"
43
+ - ".js"
44
+ - ".jsx"
45
+ - ".go"
46
+ - ".java"
47
+ - ".rs"
48
+ - ".rb"
49
+ - ".vue"
50
+ - ".svelte"
51
+ skip_extensions:
52
+ - ".md"
53
+ - ".txt"
54
+ - ".json"
55
+ - ".yml"
56
+ - ".yaml"
57
+ - ".toml"
58
+ - ".lock"
59
+ - ".csv"
60
+ - ".xml"
61
+ - ".svg"
62
+ - ".png"
63
+ - ".jpg"
64
+ - ".jpeg"
65
+ - ".gif"
66
+ - ".ico"
67
+ - ".pdf"
68
+ - ".zip"
69
+ - ".gz"
70
+ - ".tar"
71
+ skip_paths:
72
+ - "docs/**"
73
+ - "*.config.*"
74
+ - ".code-flow/**"
75
+ - ".claude/**"
76
+ - ".costrict/**"
77
+ - "node_modules/**"
78
+ - "dist/**"
79
+ - "build/**"
80
+ - "out/**"
81
+ - "coverage/**"
82
+ - ".next/**"
83
+ - ".cache/**"
84
+ - ".venv/**"
85
+ - "venv/**"
86
+ - "__pycache__/**"
87
+ - ".git/**"
88
+
89
+ path_mapping:
90
+ shared:
91
+ # 共享模板供 cf-task:align / cf-task:prd 命令显式 Read 使用,
92
+ # 不是约束规范,禁止通配符自动注入。
93
+ # 仅 _map.md 保持 "*" 作为 Tier 0 导航。
94
+ specs:
95
+ - path: "shared/_map.md"
96
+ tags: ["*"]
97
+ tier: 0
98
+ - path: "shared/prd-template.md"
99
+ tags: [] # 永不自动注入,仅 cf-task:prd 命令显式 Read
100
+ tier: 1
101
+ - path: "shared/design/design-lite.md"
102
+ tags: [] # 永不自动注入,仅 cf-task:align 命令显式 Read
103
+ tier: 1
104
+ - path: "shared/design/design-full.md"
105
+ tags: [] # 永不自动注入,仅 cf-task:align 命令显式 Read
106
+ tier: 1
107
+ - path: "shared/design/design-frontend.md"
108
+ tags: [] # 永不自动注入,仅 cf-task:align 命令显式 Read(前端设计模板)
109
+ tier: 1
110
+ frontend:
111
+ patterns:
112
+ - "src/components/**"
113
+ - "src/pages/**"
114
+ - "src/hooks/**"
115
+ - "src/styles/**"
116
+ - "**/*.tsx"
117
+ - "**/*.jsx"
118
+ - "**/*.css"
119
+ - "**/*.scss"
120
+ specs:
121
+ - path: "frontend/_map.md"
122
+ tags: ["*"]
123
+ tier: 0
124
+ - path: "frontend/directory-structure.md"
125
+ tags: ["directory", "structure", "folder", "route", "page", "layout"]
126
+ tier: 1
127
+ - path: "frontend/quality-standards.md"
128
+ tags: ["quality", "type", "lint", "error", "test", "state", "strict"]
129
+ tier: 1
130
+ - path: "frontend/component-specs.md"
131
+ tags: ["component", "prop", "hook", "render", "ui", "style"]
132
+ tier: 1
133
+ backend:
134
+ patterns:
135
+ - "services/**"
136
+ - "api/**"
137
+ - "models/**"
138
+ - "*.py"
139
+ - "**/*.py"
140
+ - "**/*.go"
141
+ specs:
142
+ - path: "backend/_map.md"
143
+ tags: ["*"]
144
+ tier: 0
145
+ - path: "backend/directory-structure.md"
146
+ tags: ["directory", "structure", "folder", "module", "layout"]
147
+ tier: 1
148
+ - path: "backend/logging.md"
149
+ tags: ["log", "logging", "debug", "trace", "monitor", "observe"]
150
+ tier: 1
151
+ - path: "backend/database.md"
152
+ tags: ["database", "db", "sql", "orm", "model", "migration", "query", "table", "schema"]
153
+ tier: 1
154
+ - path: "backend/platform-rules.md"
155
+ tags: ["api", "deploy", "config", "version", "compatibility", "release", "flag"]
156
+ tier: 1
157
+ - path: "backend/code-quality-performance.md"
158
+ tags: ["quality", "performance", "error", "exception", "test", "timeout", "retry", "cache"]
159
+ tier: 1
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env python3
2
+ """Spec frontmatter checks: parse, cache and execute (quality loop FEAT-01).
3
+
4
+ Contract (spec-quality-loop.design.md §2.3.2 / §3.4 / §3.5):
5
+ - Checks live in spec frontmatter `checks:` so rule text and machine check
6
+ stay in the same file (ADR: frontmatter over checks.yml).
7
+ - Invalid entries are skipped and surfaced as parse errors (E-01) — they must
8
+ never break parsing of sibling checks or spec injection.
9
+ - Execution guards: per-check timeout (E-02), oversized content skipped
10
+ (B-01), disabled checks filtered (RULE-04 state), matched lines per check
11
+ capped so feedback stays small (RISK-05).
12
+ - yaml import is lazy: specs without `checks:` never pay for it on the
13
+ PostToolUse hot path (NFR-PERF-01).
14
+ """
15
+ import fnmatch
16
+ import os
17
+ import re
18
+ import sys
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from concurrent.futures import TimeoutError as FutureTimeout
21
+
22
+ from cf_core import _FRONTMATTER_RE, normalize_path
23
+
24
+ CHECK_TIMEOUT_SECONDS = 2.0
25
+ MAX_CONTENT_BYTES = 256 * 1024
26
+ MESSAGE_MAX_LEN = 200
27
+ MAX_LINES_PER_CHECK = 3
28
+
29
+ _ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
30
+ IMPLEMENTED_TYPES = ("regex",)
31
+ RESERVED_TYPES = ("ast", "cmd") # interface reserved, implementation deferred
32
+ _SEVERITIES = ("warn", "info") # no "error": feedback only, never blocking
33
+
34
+ _checks_cache: dict = {}
35
+
36
+ STATE_NAME = ".check-state.json"
37
+ FP_DISABLE_COUNT = 3
38
+ FP_DISABLE_RATE = 0.10
39
+
40
+
41
+ def _log(msg: str) -> None:
42
+ sys.stderr.write(f"[cf-checks] {msg}\n")
43
+
44
+
45
+ def state_path(project_root: str) -> str:
46
+ return os.path.join(project_root, ".code-flow", STATE_NAME)
47
+
48
+
49
+ def load_check_state(project_root: str) -> dict:
50
+ """Read check-state; corrupt/missing files rebuild as empty (tolerant)."""
51
+ import json
52
+ try:
53
+ with open(state_path(project_root), "r", encoding="utf-8") as f:
54
+ data = json.load(f)
55
+ return data if isinstance(data, dict) else {}
56
+ except Exception:
57
+ return {}
58
+
59
+
60
+ def save_check_state(project_root: str, state: dict) -> bool:
61
+ import json
62
+ try:
63
+ path = state_path(project_root)
64
+ os.makedirs(os.path.dirname(path), exist_ok=True)
65
+ with open(path, "w", encoding="utf-8") as f:
66
+ json.dump(state, f, ensure_ascii=False)
67
+ return True
68
+ except Exception as exc:
69
+ _log(f"save_check_state failed: {exc}")
70
+ return False
71
+
72
+
73
+ def record_hits(project_root: str, check_ids: list) -> dict:
74
+ """Increment hit_count for triggered checks (误报率分母). Returns state."""
75
+ state = load_check_state(project_root)
76
+ for cid in check_ids:
77
+ entry = state.setdefault(cid, {})
78
+ entry["hit_count"] = int(entry.get("hit_count", 0)) + 1
79
+ save_check_state(project_root, state)
80
+ return state
81
+
82
+
83
+ def record_false_positive(project_root: str, check_id: str) -> dict:
84
+ """Mark one false positive; auto-disable past RULE-04 thresholds.
85
+
86
+ Returns {"check_id", "fp_count", "hit_count", "disabled"}.
87
+ """
88
+ state = load_check_state(project_root)
89
+ entry = state.setdefault(check_id, {})
90
+ entry["fp_count"] = int(entry.get("fp_count", 0)) + 1
91
+ fp = entry["fp_count"]
92
+ hits = int(entry.get("hit_count", 0))
93
+ if not entry.get("disabled") and (
94
+ fp >= FP_DISABLE_COUNT or (hits > 0 and fp / hits > FP_DISABLE_RATE)
95
+ ):
96
+ entry["disabled"] = True
97
+ entry["disabled_reason"] = (
98
+ f"auto: fp_count={fp}" if fp >= FP_DISABLE_COUNT
99
+ else f"auto: fp_rate={fp}/{hits}"
100
+ )
101
+ save_check_state(project_root, state)
102
+ return {
103
+ "check_id": check_id,
104
+ "fp_count": fp,
105
+ "hit_count": hits,
106
+ "disabled": bool(entry.get("disabled")),
107
+ }
108
+
109
+
110
+ def _validate_entry(entry: object, index: int, seen_ids: set) -> tuple:
111
+ """Validate one raw checks entry → (check_dict | None, error | None)."""
112
+ if not isinstance(entry, dict):
113
+ return None, f"checks[{index}]: 不是对象,已跳过"
114
+ cid = entry.get("id")
115
+ if not isinstance(cid, str) or not _ID_RE.match(cid):
116
+ return None, f"checks[{index}]: id 缺失或非 kebab-case,已跳过"
117
+ if cid in seen_ids:
118
+ return None, f"checks[{index}]: id '{cid}' 重复,已跳过"
119
+ ctype = entry.get("type")
120
+ if ctype in RESERVED_TYPES:
121
+ return None, f"{cid}: type '{ctype}' 暂未实现(接口预留),已跳过"
122
+ if ctype not in IMPLEMENTED_TYPES:
123
+ return None, f"{cid}: 未知 type '{ctype}',已跳过"
124
+ pattern = entry.get("pattern")
125
+ if not isinstance(pattern, str) or not pattern:
126
+ return None, f"{cid}: pattern 缺失,已跳过"
127
+ try:
128
+ compiled = re.compile(pattern)
129
+ except re.error as exc:
130
+ return None, f"{cid}: pattern 非法正则({exc}),已跳过"
131
+
132
+ message = entry.get("message")
133
+ if not isinstance(message, str) or not message.strip():
134
+ return None, f"{cid}: message 缺失,已跳过"
135
+ error = None
136
+ if len(message) > MESSAGE_MAX_LEN:
137
+ error = f"{cid}: message 超过 {MESSAGE_MAX_LEN} 字符,运行时截断"
138
+ message = message[:MESSAGE_MAX_LEN]
139
+
140
+ severity = entry.get("severity", "warn")
141
+ if severity not in _SEVERITIES:
142
+ error = error or f"{cid}: severity '{severity}' 非法,按 warn 处理"
143
+ severity = "warn"
144
+
145
+ # default "*": fnmatch's "*" crosses "/" so it matches any depth;
146
+ # "**/*" under fnmatch would demand at least one directory level and
147
+ # silently skip repo-root files (same pitfall fixed in config patterns)
148
+ files = entry.get("files")
149
+ if not isinstance(files, str) or not files:
150
+ files = "*"
151
+
152
+ return {
153
+ "id": cid,
154
+ "type": ctype,
155
+ "pattern": pattern,
156
+ "regex": compiled,
157
+ "files": files,
158
+ "message": message,
159
+ "severity": severity,
160
+ }, error
161
+
162
+
163
+ def parse_spec_checks(content: str) -> tuple:
164
+ """Parse frontmatter checks → (checks, errors). Both lists, never raises."""
165
+ if not isinstance(content, str):
166
+ return [], []
167
+ match = _FRONTMATTER_RE.match(content)
168
+ if not match or "checks:" not in match.group(1):
169
+ return [], []
170
+ try:
171
+ import yaml
172
+ meta = yaml.safe_load(match.group(1)) or {}
173
+ except Exception as exc:
174
+ return [], [f"frontmatter YAML 解析失败: {exc}"]
175
+ raw = meta.get("checks")
176
+ if raw is None:
177
+ return [], []
178
+ if not isinstance(raw, list):
179
+ return [], ["checks 必须是列表"]
180
+ checks: list = []
181
+ errors: list = []
182
+ seen_ids: set = set()
183
+ for index, entry in enumerate(raw):
184
+ check, error = _validate_entry(entry, index, seen_ids)
185
+ if error:
186
+ errors.append(error)
187
+ if check:
188
+ checks.append(check)
189
+ seen_ids.add(check["id"])
190
+ return checks, errors
191
+
192
+
193
+ def load_spec_checks(spec_abs_path: str) -> tuple:
194
+ """Parse a spec file's checks with mtime cache → (checks, errors)."""
195
+ try:
196
+ mtime = os.path.getmtime(spec_abs_path)
197
+ except OSError:
198
+ return [], []
199
+ cached = _checks_cache.get(spec_abs_path)
200
+ if cached and cached["mtime"] == mtime:
201
+ return cached["checks"], cached["errors"]
202
+ try:
203
+ with open(spec_abs_path, "r", encoding="utf-8") as f:
204
+ checks, errors = parse_spec_checks(f.read())
205
+ except Exception as exc:
206
+ _log(f"load_spec_checks failed {spec_abs_path}: {exc}")
207
+ return [], []
208
+ _checks_cache[spec_abs_path] = {
209
+ "mtime": mtime, "checks": checks, "errors": errors,
210
+ }
211
+ return checks, errors
212
+
213
+
214
+ # 纠正句式(FEAT-04 信号①):保守收录、宁漏勿误(E-06)。
215
+ # 否定排除:不要紧/不要紧张、对不对、不对外/不对称。
216
+ _CORRECTION_PATTERNS = [
217
+ r"不要(?!紧)",
218
+ r"(?<!对)不对(?![外称])",
219
+ r"不是这样",
220
+ r"我说过",
221
+ r"改回[去来]",
222
+ r"别再?用",
223
+ r"写错了",
224
+ r"\bdon'?t\s+use\b",
225
+ r"\bthat'?s\s+wrong\b",
226
+ r"\bI\s+said\b",
227
+ r"\brevert\s+(that|this|it)\b",
228
+ r"\bstop\s+using\b",
229
+ ]
230
+ _CORRECTION_RE = re.compile("|".join(_CORRECTION_PATTERNS), re.IGNORECASE)
231
+
232
+
233
+ def detect_correction(prompt: str) -> dict:
234
+ """Detect a correction phrase in a user prompt → {"phrase", "span"} | None.
235
+
236
+ Single combined regex, first hit wins; the wordlist is deliberately
237
+ conservative — a missed correction only delays learning, a false hit
238
+ pollutes the candidate stream.
239
+ """
240
+ if not isinstance(prompt, str) or not prompt:
241
+ return None
242
+ match = _CORRECTION_RE.search(prompt)
243
+ if not match:
244
+ return None
245
+ return {"phrase": match.group(0), "span": [match.start(), match.end()]}
246
+
247
+
248
+ def _match_lines(regex, content: str) -> list:
249
+ hits = []
250
+ for line_no, line in enumerate(content.splitlines(), 1):
251
+ if regex.search(line):
252
+ hits.append((line_no, line.strip()))
253
+ if len(hits) >= MAX_LINES_PER_CHECK:
254
+ break
255
+ return hits
256
+
257
+
258
+ def run_checks(
259
+ checks: list,
260
+ rel_path: str,
261
+ content: str,
262
+ state: dict = None,
263
+ timeout: float = CHECK_TIMEOUT_SECONDS,
264
+ ) -> tuple:
265
+ """Execute checks against one file's content → (violations, skipped).
266
+
267
+ violations: [{check_id, file, line_no, line, message, severity}]
268
+ skipped: [{check_id, reason}] — timeout / oversize, caller records
269
+ degrade events (E-02 / B-01).
270
+ Disabled checks (state[check_id]["disabled"]) are silently filtered.
271
+ """
272
+ violations: list = []
273
+ skipped: list = []
274
+ if not checks or not isinstance(content, str):
275
+ return violations, skipped
276
+ if len(content.encode("utf-8", errors="ignore")) > MAX_CONTENT_BYTES:
277
+ return violations, [{"check_id": "*", "reason": "content_too_large"}]
278
+
279
+ rel_path = normalize_path(rel_path)
280
+ state = state or {}
281
+ with ThreadPoolExecutor(max_workers=1) as pool:
282
+ for check in checks:
283
+ if state.get(check["id"], {}).get("disabled"):
284
+ continue
285
+ if not fnmatch.fnmatch(rel_path, check["files"]):
286
+ continue
287
+ future = pool.submit(_match_lines, check["regex"], content)
288
+ try:
289
+ hits = future.result(timeout=timeout)
290
+ except FutureTimeout:
291
+ skipped.append({"check_id": check["id"], "reason": "timeout"})
292
+ future.cancel()
293
+ continue
294
+ except Exception as exc:
295
+ skipped.append({"check_id": check["id"], "reason": f"error:{exc}"})
296
+ continue
297
+ for line_no, line in hits:
298
+ violations.append({
299
+ "check_id": check["id"],
300
+ "file": rel_path,
301
+ "line_no": line_no,
302
+ "line": line[:160],
303
+ "message": check["message"],
304
+ "severity": check["severity"],
305
+ })
306
+ return violations, skipped