ai-dev-flow 3.10.0__py3-none-any.whl
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.
- ai_dev_flow/__init__.py +5 -0
- ai_dev_flow/__main__.py +6 -0
- ai_dev_flow/checkpoint.py +65 -0
- ai_dev_flow/cli.py +598 -0
- ai_dev_flow/config.py +404 -0
- ai_dev_flow/data/pipeline.yaml +193 -0
- ai_dev_flow/data/profiles/java-spring.yml +140 -0
- ai_dev_flow/data/recipes/steps/00-init-project.yaml +168 -0
- ai_dev_flow/data/recipes/steps/01-refine-requirement.yaml +117 -0
- ai_dev_flow/data/recipes/steps/02-analysis.yaml +97 -0
- ai_dev_flow/data/recipes/steps/03-design.yaml +91 -0
- ai_dev_flow/data/recipes/steps/03.5-decompose.yaml +120 -0
- ai_dev_flow/data/recipes/steps/05-build.yaml +72 -0
- ai_dev_flow/data/recipes/steps/06-review.yaml +115 -0
- ai_dev_flow/data/recipes/steps/07-delivery.yaml +76 -0
- ai_dev_flow/data/recipes/steps/task-template.yaml +74 -0
- ai_dev_flow/error_handler.py +122 -0
- ai_dev_flow/executor.py +406 -0
- ai_dev_flow/git_ops.py +187 -0
- ai_dev_flow/knowledge_accumulator.py +121 -0
- ai_dev_flow/logger.py +84 -0
- ai_dev_flow/sandbox.py +291 -0
- ai_dev_flow/semantic_summarizer.py +351 -0
- ai_dev_flow/snapshot.py +210 -0
- ai_dev_flow/state.py +87 -0
- ai_dev_flow/task_context.py +134 -0
- ai_dev_flow/task_graph.py +841 -0
- ai_dev_flow/task_state.py +151 -0
- ai_dev_flow/watchdog.py +120 -0
- ai_dev_flow-3.10.0.dist-info/METADATA +171 -0
- ai_dev_flow-3.10.0.dist-info/RECORD +35 -0
- ai_dev_flow-3.10.0.dist-info/WHEEL +5 -0
- ai_dev_flow-3.10.0.dist-info/entry_points.txt +2 -0
- ai_dev_flow-3.10.0.dist-info/licenses/LICENSE +21 -0
- ai_dev_flow-3.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""语义摘要器 — 从文件中提取结构化关键信息,替代纯截断。
|
|
2
|
+
|
|
3
|
+
策略(零 AI 成本):
|
|
4
|
+
- 代码文件:提取类/函数签名、参数、返回类型、docstring、关键注释
|
|
5
|
+
- 文档文件:提取标题层级 + 首句摘要
|
|
6
|
+
- 配置文件:提取顶层结构
|
|
7
|
+
- 提取结果缓存到 .ai-dev/summaries/{hash}.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import hashlib
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("ai-dev-flow")
|
|
18
|
+
|
|
19
|
+
SUMMARY_DIR = "summaries"
|
|
20
|
+
MAX_SUMMARY_LINES = 120 # 摘要最大行数
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SemanticSummarizer:
|
|
24
|
+
"""文件语义结构提取器。不调用 AI,基于语言规则提取关键信息。"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, ai_dev_dir: Path):
|
|
27
|
+
self.cache_dir = ai_dev_dir / SUMMARY_DIR
|
|
28
|
+
self.cache_dir.mkdir(exist_ok=True)
|
|
29
|
+
|
|
30
|
+
def summarize(self, filepath: Path) -> str | None:
|
|
31
|
+
"""对文件做语义摘要。已在缓存中则直接返回。"""
|
|
32
|
+
if not filepath.exists():
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
content = filepath.read_text(encoding="utf-8", errors="replace")
|
|
37
|
+
except Exception:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
cache_key = hashlib.sha256(content.encode()).hexdigest()[:16]
|
|
41
|
+
cache_file = self.cache_dir / f"{cache_key}.md"
|
|
42
|
+
if cache_file.exists():
|
|
43
|
+
return cache_file.read_text(encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
ext = filepath.suffix.lower()
|
|
46
|
+
lines = content.splitlines()
|
|
47
|
+
size_kb = len(content.encode("utf-8")) // 1024
|
|
48
|
+
|
|
49
|
+
if ext in (".py", ".pyw"):
|
|
50
|
+
summary = self._summarize_python(lines, filepath, len(lines), size_kb)
|
|
51
|
+
elif ext in (".java", ".kt", ".scala"):
|
|
52
|
+
summary = self._summarize_java(lines, filepath, len(lines), size_kb)
|
|
53
|
+
elif ext in (".ts", ".tsx", ".js", ".jsx"):
|
|
54
|
+
summary = self._summarize_typescript(lines, filepath, len(lines), size_kb)
|
|
55
|
+
elif ext in (".md", ".mdx"):
|
|
56
|
+
summary = self._summarize_markdown(lines, filepath, len(lines), size_kb)
|
|
57
|
+
elif ext in (".yml", ".yaml"):
|
|
58
|
+
summary = self._summarize_yaml(lines, filepath, len(lines), size_kb)
|
|
59
|
+
elif ext in (".vue", ".svelte"):
|
|
60
|
+
summary = self._summarize_vue(lines, filepath, len(lines), size_kb)
|
|
61
|
+
elif ext in (".xml", ".html", ".htm"):
|
|
62
|
+
summary = self._summarize_xml(lines, filepath, len(lines), size_kb)
|
|
63
|
+
elif ext in (".sql"):
|
|
64
|
+
summary = self._summarize_sql(lines, filepath, len(lines), size_kb)
|
|
65
|
+
else:
|
|
66
|
+
summary = self._summarize_generic(lines, filepath, len(lines), size_kb)
|
|
67
|
+
|
|
68
|
+
cache_file.write_text(summary, encoding="utf-8")
|
|
69
|
+
return summary
|
|
70
|
+
|
|
71
|
+
# ---- Python ----
|
|
72
|
+
|
|
73
|
+
def _summarize_python(self, lines: list[str], filepath: Path,
|
|
74
|
+
total: int, size_kb: int) -> str:
|
|
75
|
+
parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
|
|
76
|
+
imports, classes, funcs, constants, comments = [], [], [], [], []
|
|
77
|
+
|
|
78
|
+
for i, line in enumerate(lines, 1):
|
|
79
|
+
stripped = line.strip()
|
|
80
|
+
if not stripped:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# Imports
|
|
84
|
+
if stripped.startswith(("import ", "from ")):
|
|
85
|
+
imports.append(stripped)
|
|
86
|
+
# Class defs
|
|
87
|
+
elif stripped.startswith("class "):
|
|
88
|
+
doc = self._peek_docstring(lines, i)
|
|
89
|
+
classes.append(f"- `{stripped}`{doc}")
|
|
90
|
+
# Function defs (not methods — those are indented)
|
|
91
|
+
elif stripped.startswith("def ") and not line.startswith((" ", "\t")):
|
|
92
|
+
doc = self._peek_docstring(lines, i)
|
|
93
|
+
funcs.append(f"- `{stripped}`{doc}")
|
|
94
|
+
# Decorators (capture before class/def)
|
|
95
|
+
elif stripped.startswith("@"):
|
|
96
|
+
if i < total and lines[i].strip().startswith(("class ", "def ")):
|
|
97
|
+
continue # will be captured with the class/def
|
|
98
|
+
# Module-level constants
|
|
99
|
+
elif re.match(r'^[A-Z_][A-Z0-9_]*\s*=', stripped):
|
|
100
|
+
constants.append(stripped.split("=")[0].strip())
|
|
101
|
+
# Key comments
|
|
102
|
+
elif re.search(r'(TODO|FIXME|HACK|XXX|NOTE|WARNING)', stripped, re.I):
|
|
103
|
+
comments.append(f" L{i}: {stripped[:100]}")
|
|
104
|
+
|
|
105
|
+
if imports:
|
|
106
|
+
parts.append(f"**Imports**: {', '.join(imports[:15])}")
|
|
107
|
+
if len(imports) > 15:
|
|
108
|
+
parts.append(f" ... and {len(imports) - 15} more")
|
|
109
|
+
if constants:
|
|
110
|
+
parts.append(f"**Constants**: {', '.join(constants[:20])}")
|
|
111
|
+
if classes:
|
|
112
|
+
parts.append(f"\n### Classes ({len(classes)})")
|
|
113
|
+
parts.extend(classes[:30])
|
|
114
|
+
if len(classes) > 30:
|
|
115
|
+
parts.append(f" ... and {len(classes) - 30} more")
|
|
116
|
+
if funcs:
|
|
117
|
+
parts.append(f"\n### Functions ({len(funcs)})")
|
|
118
|
+
parts.extend(funcs[:30])
|
|
119
|
+
if len(funcs) > 30:
|
|
120
|
+
parts.append(f" ... and {len(funcs) - 30} more")
|
|
121
|
+
if comments:
|
|
122
|
+
parts.append("\n### Key Comments")
|
|
123
|
+
parts.extend(comments[:15])
|
|
124
|
+
|
|
125
|
+
return "\n".join(parts)
|
|
126
|
+
|
|
127
|
+
# ---- Java/Kotlin ----
|
|
128
|
+
|
|
129
|
+
def _summarize_java(self, lines: list[str], filepath: Path,
|
|
130
|
+
total: int, size_kb: int) -> str:
|
|
131
|
+
parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
|
|
132
|
+
package, imports, classes, methods, fields, comments = "", [], [], [], [], []
|
|
133
|
+
|
|
134
|
+
for i, line in enumerate(lines, 1):
|
|
135
|
+
stripped = line.strip()
|
|
136
|
+
if not stripped:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if stripped.startswith("package "):
|
|
140
|
+
package = stripped
|
|
141
|
+
elif stripped.startswith("import "):
|
|
142
|
+
imports.append(stripped)
|
|
143
|
+
elif re.match(r'(public |private |protected )?(class |interface |@interface |enum )', stripped):
|
|
144
|
+
classes.append(f"- `{stripped}`")
|
|
145
|
+
elif re.match(r'(public |private |protected )?[\w<>\[\],\s]+\s+\w+\s*\(', stripped):
|
|
146
|
+
methods.append(f"- `{stripped[:120]}`")
|
|
147
|
+
elif re.match(r'(public |private |protected )?static\s+final\s+', stripped):
|
|
148
|
+
fields.append(stripped[:80])
|
|
149
|
+
elif re.search(r'(TODO|FIXME|HACK|XXX)', stripped, re.I):
|
|
150
|
+
comments.append(f" L{i}: {stripped[:100]}")
|
|
151
|
+
|
|
152
|
+
if package:
|
|
153
|
+
parts.append(f"**Package**: {package}")
|
|
154
|
+
if imports:
|
|
155
|
+
parts.append(f"**Imports**: {len(imports)} packages")
|
|
156
|
+
if classes:
|
|
157
|
+
parts.append(f"\n### Classes/Interfaces ({len(classes)})")
|
|
158
|
+
parts.extend(classes)
|
|
159
|
+
if fields:
|
|
160
|
+
parts.append("\n### Constants/Fields")
|
|
161
|
+
parts.extend(fields[:15])
|
|
162
|
+
if methods:
|
|
163
|
+
parts.append(f"\n### Methods ({len(methods)})")
|
|
164
|
+
parts.extend(methods[:40])
|
|
165
|
+
if len(methods) > 40:
|
|
166
|
+
parts.append(f" ... and {len(methods) - 40} more")
|
|
167
|
+
if comments:
|
|
168
|
+
parts.append("\n### Key Comments")
|
|
169
|
+
parts.extend(comments[:15])
|
|
170
|
+
|
|
171
|
+
return "\n".join(parts)
|
|
172
|
+
|
|
173
|
+
# ---- TypeScript/JavaScript ----
|
|
174
|
+
|
|
175
|
+
def _summarize_typescript(self, lines: list[str], filepath: Path,
|
|
176
|
+
total: int, size_kb: int) -> str:
|
|
177
|
+
parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
|
|
178
|
+
imports, exports, classes, funcs, interfaces, comments = [], [], [], [], [], []
|
|
179
|
+
|
|
180
|
+
for i, line in enumerate(lines, 1):
|
|
181
|
+
stripped = line.strip()
|
|
182
|
+
if not stripped:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if stripped.startswith("import "):
|
|
186
|
+
imports.append(stripped[:80])
|
|
187
|
+
elif stripped.startswith("export "):
|
|
188
|
+
exports.append(stripped[:100])
|
|
189
|
+
elif stripped.startswith("class "):
|
|
190
|
+
classes.append(f"- `{stripped[:120]}`")
|
|
191
|
+
elif re.match(r'(export\s+)?(interface|type)\s+', stripped):
|
|
192
|
+
interfaces.append(f"- `{stripped[:120]}`")
|
|
193
|
+
elif re.match(r'(export\s+)?(async\s+)?function\s+', stripped):
|
|
194
|
+
funcs.append(f"- `{stripped[:120]}`")
|
|
195
|
+
elif re.search(r'(TODO|FIXME|HACK|XXX)', stripped, re.I):
|
|
196
|
+
comments.append(f" L{i}: {stripped[:100]}")
|
|
197
|
+
|
|
198
|
+
if imports:
|
|
199
|
+
parts.append(f"**Imports**: {len(imports)} modules")
|
|
200
|
+
if exports:
|
|
201
|
+
parts.append(f"**Exports**: {len(exports)} items")
|
|
202
|
+
if interfaces:
|
|
203
|
+
parts.append(f"\n### Interfaces/Types ({len(interfaces)})")
|
|
204
|
+
parts.extend(interfaces[:20])
|
|
205
|
+
if classes:
|
|
206
|
+
parts.append(f"\n### Classes ({len(classes)})")
|
|
207
|
+
parts.extend(classes[:20])
|
|
208
|
+
if funcs:
|
|
209
|
+
parts.append(f"\n### Functions ({len(funcs)})")
|
|
210
|
+
parts.extend(funcs[:30])
|
|
211
|
+
if comments:
|
|
212
|
+
parts.append("\n### Key Comments")
|
|
213
|
+
parts.extend(comments[:15])
|
|
214
|
+
|
|
215
|
+
return "\n".join(parts)
|
|
216
|
+
|
|
217
|
+
# ---- Markdown ----
|
|
218
|
+
|
|
219
|
+
def _summarize_markdown(self, lines: list[str], filepath: Path,
|
|
220
|
+
total: int, size_kb: int) -> str:
|
|
221
|
+
parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
|
|
222
|
+
headers = []
|
|
223
|
+
for line in lines:
|
|
224
|
+
stripped = line.strip()
|
|
225
|
+
if stripped.startswith("#"):
|
|
226
|
+
headers.append(stripped)
|
|
227
|
+
if headers:
|
|
228
|
+
parts.append("### Document Structure")
|
|
229
|
+
parts.extend(headers[:40])
|
|
230
|
+
if len(headers) > 40:
|
|
231
|
+
parts.append(f" ... and {len(headers) - 40} more headings")
|
|
232
|
+
return "\n".join(parts)
|
|
233
|
+
|
|
234
|
+
# ---- YAML ----
|
|
235
|
+
|
|
236
|
+
def _summarize_yaml(self, lines: list[str], filepath: Path,
|
|
237
|
+
total: int, size_kb: int) -> str:
|
|
238
|
+
parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
|
|
239
|
+
top_keys = []
|
|
240
|
+
for line in lines:
|
|
241
|
+
stripped = line.strip()
|
|
242
|
+
if stripped and not stripped.startswith("#") and not line.startswith(" "):
|
|
243
|
+
top_keys.append(stripped[:100])
|
|
244
|
+
if top_keys:
|
|
245
|
+
parts.append("### Top-level Structure")
|
|
246
|
+
parts.extend(f"- {k}" for k in top_keys[:30])
|
|
247
|
+
return "\n".join(parts)
|
|
248
|
+
|
|
249
|
+
# ---- Vue/Svelte ----
|
|
250
|
+
|
|
251
|
+
def _summarize_vue(self, lines: list[str], filepath: Path,
|
|
252
|
+
total: int, size_kb: int) -> str:
|
|
253
|
+
parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
|
|
254
|
+
sections = []
|
|
255
|
+
in_script, in_style, in_template = False, False, False
|
|
256
|
+
script_lines, style_lines, template_lines = 0, 0, 0
|
|
257
|
+
|
|
258
|
+
for line in lines:
|
|
259
|
+
stripped = line.strip()
|
|
260
|
+
if "<script" in stripped:
|
|
261
|
+
in_script = True
|
|
262
|
+
elif "</script>" in stripped:
|
|
263
|
+
in_script = False
|
|
264
|
+
sections.append(f"- `<script>` ({script_lines} lines)")
|
|
265
|
+
elif "<style" in stripped:
|
|
266
|
+
in_style = True
|
|
267
|
+
elif "</style>" in stripped:
|
|
268
|
+
in_style = False
|
|
269
|
+
sections.append(f"- `<style>` ({style_lines} lines)")
|
|
270
|
+
elif "<template" in stripped:
|
|
271
|
+
in_template = True
|
|
272
|
+
elif "</template>" in stripped:
|
|
273
|
+
in_template = False
|
|
274
|
+
sections.append(f"- `<template>` ({template_lines} lines)")
|
|
275
|
+
elif in_script:
|
|
276
|
+
script_lines += 1
|
|
277
|
+
elif in_style:
|
|
278
|
+
style_lines += 1
|
|
279
|
+
elif in_template:
|
|
280
|
+
template_lines += 1
|
|
281
|
+
|
|
282
|
+
if sections:
|
|
283
|
+
parts.append("### Component Structure")
|
|
284
|
+
parts.extend(sections)
|
|
285
|
+
return "\n".join(parts)
|
|
286
|
+
|
|
287
|
+
# ---- XML/HTML ----
|
|
288
|
+
|
|
289
|
+
def _summarize_xml(self, lines: list[str], filepath: Path,
|
|
290
|
+
total: int, size_kb: int) -> str:
|
|
291
|
+
parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
|
|
292
|
+
tags = set()
|
|
293
|
+
for line in lines:
|
|
294
|
+
for m in re.finditer(r'<(\w+)', line):
|
|
295
|
+
tags.add(m.group(1))
|
|
296
|
+
if tags:
|
|
297
|
+
parts.append(f"**Elements**: {', '.join(sorted(tags)[:30])}")
|
|
298
|
+
return "\n".join(parts)
|
|
299
|
+
|
|
300
|
+
# ---- SQL ----
|
|
301
|
+
|
|
302
|
+
def _summarize_sql(self, lines: list[str], filepath: Path,
|
|
303
|
+
total: int, size_kb: int) -> str:
|
|
304
|
+
parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
|
|
305
|
+
statements = []
|
|
306
|
+
for line in lines:
|
|
307
|
+
stripped = line.strip().upper()
|
|
308
|
+
if re.match(r'(CREATE|ALTER|DROP|INSERT|UPDATE|DELETE|SELECT|GRANT|REVOKE)\b', stripped):
|
|
309
|
+
statements.append(stripped[:120])
|
|
310
|
+
if statements:
|
|
311
|
+
parts.append("### SQL Statements")
|
|
312
|
+
parts.extend(f"- {s}" for s in statements[:20])
|
|
313
|
+
if len(statements) > 20:
|
|
314
|
+
parts.append(f" ... and {len(statements) - 20} more")
|
|
315
|
+
return "\n".join(parts)
|
|
316
|
+
|
|
317
|
+
# ---- Generic fallback ----
|
|
318
|
+
|
|
319
|
+
def _summarize_generic(self, lines: list[str], filepath: Path,
|
|
320
|
+
total: int, size_kb: int) -> str:
|
|
321
|
+
parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
|
|
322
|
+
parts.append(f"**Type**: {filepath.suffix or 'text'}")
|
|
323
|
+
# Extract lines that look like declarations
|
|
324
|
+
decls = []
|
|
325
|
+
for line in lines[:200]:
|
|
326
|
+
stripped = line.strip()
|
|
327
|
+
if stripped and not stripped.startswith(("#", "//", "/*", "*")):
|
|
328
|
+
if re.match(r'^[\w]+\s+[\w]+\s*[=(]', stripped):
|
|
329
|
+
decls.append(stripped[:120])
|
|
330
|
+
if decls:
|
|
331
|
+
parts.append("### Possible Declarations")
|
|
332
|
+
parts.extend(f"- {d}" for d in decls[:30])
|
|
333
|
+
return "\n".join(parts)
|
|
334
|
+
|
|
335
|
+
# ---- Helpers ----
|
|
336
|
+
|
|
337
|
+
def _peek_docstring(self, lines: list[str], idx: int) -> str:
|
|
338
|
+
"""提取紧随定义行的 docstring 首行。"""
|
|
339
|
+
if idx >= len(lines):
|
|
340
|
+
return ""
|
|
341
|
+
next_line = lines[idx].strip()
|
|
342
|
+
# 单行 docstring: """text""" 或 '''text'''
|
|
343
|
+
m = re.match(r'["\']{3}(.+?)["\']{3}', next_line)
|
|
344
|
+
if m:
|
|
345
|
+
return f' — "{m.group(1)[:80]}"'
|
|
346
|
+
# 多行 docstring 开始
|
|
347
|
+
if next_line.startswith(('"""', "'''")):
|
|
348
|
+
text = next_line.lstrip('"').lstrip("'").strip()
|
|
349
|
+
if text:
|
|
350
|
+
return f' — "{text[:80]}"'
|
|
351
|
+
return ""
|
ai_dev_flow/snapshot.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""项目快照 — 文件树 hash + 变更检测,避免每次全量扫描项目。
|
|
2
|
+
|
|
3
|
+
增量策略:
|
|
4
|
+
- 首次运行:全量扫描,存 mtime+size 到 snapshot.json
|
|
5
|
+
- 后续运行:对比 mtime/size,仅对变化文件重读内容
|
|
6
|
+
- 内容缓存:未变化文件复用上次读取的内容
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import hashlib
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("ai-dev-flow")
|
|
18
|
+
|
|
19
|
+
SNAPSHOT_FILE = "snapshot.json"
|
|
20
|
+
|
|
21
|
+
# 扫描时跳过的目录
|
|
22
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
23
|
+
"target", "build", "dist", ".idea", ".vscode", ".ai-dev"}
|
|
24
|
+
|
|
25
|
+
# 二进制文件扩展名(不做内容缓存)
|
|
26
|
+
BINARY_EXTS = {".jar", ".war", ".class", ".exe", ".dll", ".so", ".dylib",
|
|
27
|
+
".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".zip", ".gz"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class FileEntry:
|
|
32
|
+
path: str # 相对于 project_root 的路径
|
|
33
|
+
size: int
|
|
34
|
+
mtime: float
|
|
35
|
+
hash: str = "" # 仅变更文件计算
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class SnapshotDiff:
|
|
40
|
+
added: list[str]
|
|
41
|
+
removed: list[str]
|
|
42
|
+
modified: list[str]
|
|
43
|
+
unchanged: list[str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SnapshotManager:
|
|
47
|
+
"""管理项目文件树快照,支持增量变更检测。"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, ai_dev_dir: Path, project_root: Path):
|
|
50
|
+
self.snapshot_path = ai_dev_dir / SNAPSHOT_FILE
|
|
51
|
+
self.project_root = project_root
|
|
52
|
+
self._content_cache: dict[str, str] = {}
|
|
53
|
+
|
|
54
|
+
# ---- 快照构建 ----
|
|
55
|
+
|
|
56
|
+
def build_snapshot(self) -> dict[str, FileEntry]:
|
|
57
|
+
"""全量扫描项目,构建文件树快照。"""
|
|
58
|
+
entries: dict[str, FileEntry] = {}
|
|
59
|
+
self._walk(self.project_root, self.project_root, entries)
|
|
60
|
+
logger.info(f"Snapshot built: {len(entries)} files indexed")
|
|
61
|
+
return entries
|
|
62
|
+
|
|
63
|
+
def _walk(self, base: Path, current: Path, entries: dict[str, FileEntry]) -> None:
|
|
64
|
+
try:
|
|
65
|
+
for child in sorted(current.iterdir()):
|
|
66
|
+
if child.name.startswith(".") or child.name in SKIP_DIRS:
|
|
67
|
+
continue
|
|
68
|
+
if child.is_dir():
|
|
69
|
+
self._walk(base, child, entries)
|
|
70
|
+
elif child.is_file():
|
|
71
|
+
try:
|
|
72
|
+
stat = child.stat()
|
|
73
|
+
rel = str(child.relative_to(base)).replace("\\", "/")
|
|
74
|
+
entries[rel] = FileEntry(
|
|
75
|
+
path=rel,
|
|
76
|
+
size=stat.st_size,
|
|
77
|
+
mtime=stat.st_mtime,
|
|
78
|
+
)
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
except PermissionError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
# ---- 持久化 ----
|
|
85
|
+
|
|
86
|
+
def save(self, entries: dict[str, FileEntry]) -> None:
|
|
87
|
+
data = {
|
|
88
|
+
"project_root": str(self.project_root),
|
|
89
|
+
"files": {
|
|
90
|
+
path: {"size": e.size, "mtime": e.mtime, "hash": e.hash}
|
|
91
|
+
for path, e in entries.items()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
self.snapshot_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
95
|
+
|
|
96
|
+
def load(self) -> dict[str, FileEntry] | None:
|
|
97
|
+
if not self.snapshot_path.exists():
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
data = json.loads(self.snapshot_path.read_text(encoding="utf-8"))
|
|
101
|
+
entries = {}
|
|
102
|
+
for path, info in data.get("files", {}).items():
|
|
103
|
+
entries[path] = FileEntry(
|
|
104
|
+
path=path,
|
|
105
|
+
size=info["size"],
|
|
106
|
+
mtime=info["mtime"],
|
|
107
|
+
hash=info.get("hash", ""),
|
|
108
|
+
)
|
|
109
|
+
return entries
|
|
110
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
111
|
+
logger.warning(f"Snapshot corrupted: {e}")
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
# ---- 变更检测 ----
|
|
115
|
+
|
|
116
|
+
def diff(self, before: dict[str, FileEntry],
|
|
117
|
+
after: dict[str, FileEntry]) -> SnapshotDiff:
|
|
118
|
+
"""对比新旧快照,返回变更分类。"""
|
|
119
|
+
added = [p for p in after if p not in before]
|
|
120
|
+
removed = [p for p in before if p not in after]
|
|
121
|
+
modified = []
|
|
122
|
+
unchanged = []
|
|
123
|
+
|
|
124
|
+
for p in before:
|
|
125
|
+
if p in after:
|
|
126
|
+
bf = before[p]
|
|
127
|
+
af = after[p]
|
|
128
|
+
if bf.mtime != af.mtime or bf.size != af.size:
|
|
129
|
+
modified.append(p)
|
|
130
|
+
else:
|
|
131
|
+
unchanged.append(p)
|
|
132
|
+
|
|
133
|
+
return SnapshotDiff(
|
|
134
|
+
added=added, removed=removed, modified=modified, unchanged=unchanged
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def get_changed_files(self) -> SnapshotDiff:
|
|
138
|
+
"""主 API:对比当前文件系统与上次快照,返回变更文件列表。"""
|
|
139
|
+
old = self.load()
|
|
140
|
+
new = self.build_snapshot()
|
|
141
|
+
if old is None:
|
|
142
|
+
# 首次运行:全部文件视为 added
|
|
143
|
+
return SnapshotDiff(
|
|
144
|
+
added=list(new.keys()),
|
|
145
|
+
removed=[],
|
|
146
|
+
modified=[],
|
|
147
|
+
unchanged=[],
|
|
148
|
+
)
|
|
149
|
+
diff = self.diff(old, new)
|
|
150
|
+
# 对 modified 文件计算内容 hash 以确认真正变更
|
|
151
|
+
real_modified = []
|
|
152
|
+
for p in diff.modified:
|
|
153
|
+
if self._hash_changed(p, old[p], new[p]):
|
|
154
|
+
real_modified.append(p)
|
|
155
|
+
else:
|
|
156
|
+
diff.unchanged.append(p)
|
|
157
|
+
diff.modified = real_modified
|
|
158
|
+
return diff
|
|
159
|
+
|
|
160
|
+
def _hash_changed(self, path: str, old_entry: FileEntry,
|
|
161
|
+
new_entry: FileEntry) -> bool:
|
|
162
|
+
"""快速判断文件内容是否真正变更(mtime 可能被 git checkout 刷新)。"""
|
|
163
|
+
# 快速路径:size 不同则必然变更
|
|
164
|
+
if old_entry.size != new_entry.size:
|
|
165
|
+
return True
|
|
166
|
+
# 对 100KB 以内的文本文件做 hash 对比
|
|
167
|
+
if new_entry.size > 100 * 1024:
|
|
168
|
+
return True # 大文件保守视为变更
|
|
169
|
+
ext = Path(path).suffix.lower()
|
|
170
|
+
if ext in BINARY_EXTS:
|
|
171
|
+
return True # 二进制文件保守视为变更
|
|
172
|
+
try:
|
|
173
|
+
fp = self.project_root / path
|
|
174
|
+
content = fp.read_bytes()
|
|
175
|
+
new_hash = hashlib.sha256(content).hexdigest()
|
|
176
|
+
if old_entry.hash:
|
|
177
|
+
return old_entry.hash != new_hash
|
|
178
|
+
# 旧快照无 hash(首次迁移),计算旧 hash
|
|
179
|
+
hashlib.sha256(content).hexdigest()
|
|
180
|
+
new_entry.hash = new_hash
|
|
181
|
+
return True # 无旧 hash 时保守视为变更
|
|
182
|
+
except Exception:
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
# ---- 内容缓存 ----
|
|
186
|
+
|
|
187
|
+
def read_cached(self, path: str, cache: dict[str, str]) -> str | None:
|
|
188
|
+
"""读取文件内容,优先使用缓存。"""
|
|
189
|
+
if path in cache:
|
|
190
|
+
return cache[path]
|
|
191
|
+
fp = self.project_root / path
|
|
192
|
+
if not fp.exists():
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
content = fp.read_text(encoding="utf-8", errors="replace")
|
|
196
|
+
cache[path] = content
|
|
197
|
+
return content
|
|
198
|
+
except Exception:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def update_snapshot(self) -> None:
|
|
202
|
+
"""更新快照为当前文件系统状态。在任务完成后调用。"""
|
|
203
|
+
entries = self.build_snapshot()
|
|
204
|
+
# 保留已有 hash 值避免重复计算
|
|
205
|
+
old = self.load()
|
|
206
|
+
if old:
|
|
207
|
+
for path, entry in entries.items():
|
|
208
|
+
if path in old and old[path].mtime == entry.mtime and old[path].size == entry.size:
|
|
209
|
+
entry.hash = old[path].hash
|
|
210
|
+
self.save(entries)
|
ai_dev_flow/state.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""状态文件管理 - .pipeline_stage / .pipeline_note 读写。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("ai-dev-flow")
|
|
9
|
+
|
|
10
|
+
STAGE_FILE = ".pipeline_stage"
|
|
11
|
+
NOTE_FILE = ".pipeline_note"
|
|
12
|
+
TASK_STATE_FILE = "task_state.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_stage(ai_dev_dir: Path) -> str | None:
|
|
16
|
+
"""读取当前进度,无文件时返回 None。"""
|
|
17
|
+
sf = ai_dev_dir / STAGE_FILE
|
|
18
|
+
if not sf.exists():
|
|
19
|
+
return None
|
|
20
|
+
stage = sf.read_text(encoding="utf-8").strip()
|
|
21
|
+
logger.debug(f"read stage: {stage}")
|
|
22
|
+
return stage
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_stage(ai_dev_dir: Path, stage: str) -> None:
|
|
26
|
+
"""写入当前进度。"""
|
|
27
|
+
sf = ai_dev_dir / STAGE_FILE
|
|
28
|
+
sf.write_text(stage, encoding="utf-8")
|
|
29
|
+
logger.debug(f"write stage: {stage}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def clear_stage(ai_dev_dir: Path) -> None:
|
|
33
|
+
"""删除状态文件(管线全部完成后)。"""
|
|
34
|
+
sf = ai_dev_dir / STAGE_FILE
|
|
35
|
+
if sf.exists():
|
|
36
|
+
sf.unlink()
|
|
37
|
+
logger.debug("stage file removed")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_note(ai_dev_dir: Path) -> str | None:
|
|
41
|
+
"""读取上次运行留下的笔记。"""
|
|
42
|
+
nf = ai_dev_dir / NOTE_FILE
|
|
43
|
+
if not nf.exists():
|
|
44
|
+
return None
|
|
45
|
+
return nf.read_text(encoding="utf-8").strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def write_note(ai_dev_dir: Path, note: str) -> None:
|
|
49
|
+
"""写入笔记。"""
|
|
50
|
+
nf = ai_dev_dir / NOTE_FILE
|
|
51
|
+
nf.write_text(note, encoding="utf-8")
|
|
52
|
+
logger.info("note saved")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def clear_note(ai_dev_dir: Path) -> None:
|
|
56
|
+
"""删除笔记。"""
|
|
57
|
+
nf = ai_dev_dir / NOTE_FILE
|
|
58
|
+
if nf.exists():
|
|
59
|
+
nf.unlink()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_task_state(ai_dev_dir: Path) -> dict | None:
|
|
63
|
+
"""读取任务执行状态。返回 None 表示无状态文件。"""
|
|
64
|
+
import json
|
|
65
|
+
sf = ai_dev_dir / TASK_STATE_FILE
|
|
66
|
+
if not sf.exists():
|
|
67
|
+
return None
|
|
68
|
+
return json.loads(sf.read_text(encoding="utf-8"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def write_task_state(ai_dev_dir: Path, state: dict) -> None:
|
|
72
|
+
"""原子写入任务执行状态(写临时文件后重命名)。"""
|
|
73
|
+
import json
|
|
74
|
+
import tempfile
|
|
75
|
+
sf = ai_dev_dir / TASK_STATE_FILE
|
|
76
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
77
|
+
mode="w", encoding="utf-8", delete=False,
|
|
78
|
+
dir=ai_dev_dir, prefix=".task_state_tmp"
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
json.dump(state, tmp, indent=2, ensure_ascii=False)
|
|
82
|
+
tmp.flush()
|
|
83
|
+
tmp.close()
|
|
84
|
+
Path(tmp.name).replace(sf)
|
|
85
|
+
except Exception:
|
|
86
|
+
Path(tmp.name).unlink(missing_ok=True)
|
|
87
|
+
raise
|