@jahanxu/code-flow 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.
- package/package.json +13 -0
- package/src/adapters/claude/settings.local.json +26 -0
- package/src/adapters/claude/skills/cf-init.md +13 -0
- package/src/adapters/claude/skills/cf-inject.md +12 -0
- package/src/adapters/claude/skills/cf-learn.md +11 -0
- package/src/adapters/claude/skills/cf-scan.md +12 -0
- package/src/adapters/claude/skills/cf-stats.md +11 -0
- package/src/adapters/claude/skills/cf-validate.md +12 -0
- package/src/adapters/codex/AGENTS.md +3 -0
- package/src/adapters/cursor/cursorrules +1 -0
- package/src/cli.js +105 -0
- package/src/core/code-flow/config.yml +97 -0
- package/src/core/code-flow/scripts/cf_core.py +129 -0
- package/src/core/code-flow/scripts/cf_init.py +829 -0
- package/src/core/code-flow/scripts/cf_inject.py +150 -0
- package/src/core/code-flow/scripts/cf_inject_hook.py +107 -0
- package/src/core/code-flow/scripts/cf_learn.py +202 -0
- package/src/core/code-flow/scripts/cf_scan.py +157 -0
- package/src/core/code-flow/scripts/cf_session_hook.py +16 -0
- package/src/core/code-flow/scripts/cf_stats.py +108 -0
- package/src/core/code-flow/scripts/cf_validate.py +340 -0
- package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -0
- package/src/core/code-flow/specs/backend/database.md +13 -0
- package/src/core/code-flow/specs/backend/directory-structure.md +13 -0
- package/src/core/code-flow/specs/backend/logging.md +13 -0
- package/src/core/code-flow/specs/backend/platform-rules.md +13 -0
- package/src/core/code-flow/specs/frontend/component-specs.md +14 -0
- package/src/core/code-flow/specs/frontend/directory-structure.md +14 -0
- package/src/core/code-flow/specs/frontend/quality-standards.md +15 -0
- package/src/core/code-flow/validation.yml +30 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import fnmatch
|
|
6
|
+
|
|
7
|
+
from cf_core import (
|
|
8
|
+
assemble_context,
|
|
9
|
+
load_config,
|
|
10
|
+
load_inject_state,
|
|
11
|
+
match_domains,
|
|
12
|
+
read_specs,
|
|
13
|
+
save_inject_state,
|
|
14
|
+
select_specs,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def match_details(rel_path: str, mapping: dict) -> dict:
|
|
19
|
+
details = {}
|
|
20
|
+
for domain, cfg in (mapping or {}).items():
|
|
21
|
+
patterns = cfg.get("patterns") or []
|
|
22
|
+
for pattern in patterns:
|
|
23
|
+
if fnmatch.fnmatch(rel_path, pattern):
|
|
24
|
+
details[domain] = pattern
|
|
25
|
+
break
|
|
26
|
+
return details
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> None:
|
|
30
|
+
project_root = os.getcwd()
|
|
31
|
+
config = load_config(project_root)
|
|
32
|
+
if not config:
|
|
33
|
+
print(json.dumps({"error": "config_missing"}, ensure_ascii=False))
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
mapping = config.get("path_mapping") or {}
|
|
37
|
+
available_domains = sorted(mapping.keys())
|
|
38
|
+
args = sys.argv[1:]
|
|
39
|
+
list_specs = "--list-specs" in args
|
|
40
|
+
list_domain = ""
|
|
41
|
+
if list_specs:
|
|
42
|
+
for arg in args:
|
|
43
|
+
if arg.startswith("--domain="):
|
|
44
|
+
list_domain = arg.split("=", 1)[1]
|
|
45
|
+
if list_domain and list_domain in mapping:
|
|
46
|
+
specs = (mapping.get(list_domain) or {}).get("specs") or []
|
|
47
|
+
print(json.dumps({"domain": list_domain, "specs": specs}, ensure_ascii=False))
|
|
48
|
+
return
|
|
49
|
+
print(json.dumps({"error": "domain_not_found", "available_domains": available_domains}, ensure_ascii=False))
|
|
50
|
+
return
|
|
51
|
+
if not args:
|
|
52
|
+
state_path = os.path.join(project_root, ".code-flow", ".inject-state")
|
|
53
|
+
recent_target = ""
|
|
54
|
+
try:
|
|
55
|
+
with open(state_path, "r", encoding="utf-8") as file:
|
|
56
|
+
data = json.load(file)
|
|
57
|
+
recent_target = data.get("last_file", "")
|
|
58
|
+
except Exception:
|
|
59
|
+
recent_target = ""
|
|
60
|
+
|
|
61
|
+
if not recent_target:
|
|
62
|
+
print(
|
|
63
|
+
json.dumps(
|
|
64
|
+
{
|
|
65
|
+
"error": "missing_target",
|
|
66
|
+
"available_domains": available_domains,
|
|
67
|
+
"usage": "cf-inject <domain|file_path>",
|
|
68
|
+
},
|
|
69
|
+
ensure_ascii=False,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return
|
|
73
|
+
target = recent_target
|
|
74
|
+
else:
|
|
75
|
+
target = args[0]
|
|
76
|
+
match_info = {}
|
|
77
|
+
if target in mapping:
|
|
78
|
+
domains = [target]
|
|
79
|
+
else:
|
|
80
|
+
abs_path = target
|
|
81
|
+
if not os.path.isabs(abs_path):
|
|
82
|
+
abs_path = os.path.join(project_root, target)
|
|
83
|
+
rel_path = os.path.relpath(abs_path, project_root)
|
|
84
|
+
domains = match_domains(rel_path, mapping)
|
|
85
|
+
match_info = match_details(rel_path, mapping)
|
|
86
|
+
|
|
87
|
+
if not domains:
|
|
88
|
+
print(
|
|
89
|
+
json.dumps(
|
|
90
|
+
{
|
|
91
|
+
"error": "domain_not_found",
|
|
92
|
+
"target": target,
|
|
93
|
+
"available_domains": available_domains,
|
|
94
|
+
},
|
|
95
|
+
ensure_ascii=False,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
specs = []
|
|
101
|
+
priorities = {}
|
|
102
|
+
for domain in domains:
|
|
103
|
+
domain_cfg = mapping.get(domain) or {}
|
|
104
|
+
priorities.update(domain_cfg.get("spec_priority") or {})
|
|
105
|
+
specs.extend(read_specs(project_root, domain, domain_cfg))
|
|
106
|
+
|
|
107
|
+
if not specs:
|
|
108
|
+
print(json.dumps({"error": "specs_empty", "domains": domains}, ensure_ascii=False))
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
budget_cfg = config.get("budget") or {}
|
|
112
|
+
budget = budget_cfg.get("l1_max", 1700)
|
|
113
|
+
try:
|
|
114
|
+
budget = int(budget)
|
|
115
|
+
except Exception:
|
|
116
|
+
budget = 1700
|
|
117
|
+
|
|
118
|
+
selected = select_specs(specs, budget, priorities)
|
|
119
|
+
if not selected:
|
|
120
|
+
print(json.dumps({"error": "budget_exceeded", "budget": budget}, ensure_ascii=False))
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
included_domains = {spec.get("domain") for spec in selected if spec.get("domain")}
|
|
124
|
+
state = load_inject_state(project_root)
|
|
125
|
+
injected_domains = state.get("injected_domains") or []
|
|
126
|
+
if not isinstance(injected_domains, list):
|
|
127
|
+
injected_domains = []
|
|
128
|
+
injected_domains = {d for d in injected_domains if isinstance(d, str)}
|
|
129
|
+
state_payload = {"injected_domains": sorted(injected_domains | included_domains)}
|
|
130
|
+
state_payload["last_file"] = target
|
|
131
|
+
save_inject_state(project_root, state_payload)
|
|
132
|
+
|
|
133
|
+
total_tokens = sum(spec.get("tokens", 0) for spec in selected)
|
|
134
|
+
output = {
|
|
135
|
+
"domains": sorted(included_domains),
|
|
136
|
+
"selected_specs": [
|
|
137
|
+
{"path": spec["path"], "tokens": spec["tokens"]} for spec in selected
|
|
138
|
+
],
|
|
139
|
+
"total_tokens": total_tokens,
|
|
140
|
+
"budget": budget,
|
|
141
|
+
"context": assemble_context(selected, "## Active Specs"),
|
|
142
|
+
}
|
|
143
|
+
if os.environ.get("CF_DEBUG") == "1":
|
|
144
|
+
output["match_info"] = match_info
|
|
145
|
+
output["target"] = target
|
|
146
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
main()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from cf_core import (
|
|
7
|
+
assemble_context,
|
|
8
|
+
is_code_file,
|
|
9
|
+
load_config,
|
|
10
|
+
load_inject_state,
|
|
11
|
+
match_domains,
|
|
12
|
+
read_specs,
|
|
13
|
+
save_inject_state,
|
|
14
|
+
select_specs,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
try:
|
|
20
|
+
raw = sys.stdin.read()
|
|
21
|
+
if not raw.strip():
|
|
22
|
+
return
|
|
23
|
+
data = json.loads(raw)
|
|
24
|
+
tool_name = data.get("tool_name", "")
|
|
25
|
+
tool_input = data.get("tool_input") or {}
|
|
26
|
+
file_path = tool_input.get("file_path", "")
|
|
27
|
+
if tool_name not in {"Edit", "Write", "MultiEdit"}:
|
|
28
|
+
return
|
|
29
|
+
if not isinstance(file_path, str) or not file_path:
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
project_root = os.getcwd()
|
|
33
|
+
abs_path = file_path
|
|
34
|
+
if not os.path.isabs(abs_path):
|
|
35
|
+
abs_path = os.path.join(project_root, file_path)
|
|
36
|
+
rel_path = os.path.relpath(abs_path, project_root)
|
|
37
|
+
|
|
38
|
+
config = load_config(project_root)
|
|
39
|
+
if not config:
|
|
40
|
+
return
|
|
41
|
+
inject_config = config.get("inject") or {}
|
|
42
|
+
if inject_config.get("auto") is False:
|
|
43
|
+
return
|
|
44
|
+
if not is_code_file(rel_path, inject_config):
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
mapping = config.get("path_mapping") or {}
|
|
48
|
+
domains = match_domains(rel_path, mapping)
|
|
49
|
+
if not domains:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
state = load_inject_state(project_root)
|
|
53
|
+
injected_domains = state.get("injected_domains") or []
|
|
54
|
+
if not isinstance(injected_domains, list):
|
|
55
|
+
injected_domains = []
|
|
56
|
+
injected_domains = {d for d in injected_domains if isinstance(d, str)}
|
|
57
|
+
new_domains = [domain for domain in domains if domain not in injected_domains]
|
|
58
|
+
if not new_domains:
|
|
59
|
+
state_payload = {"injected_domains": sorted(injected_domains)}
|
|
60
|
+
state_payload["last_file"] = abs_path
|
|
61
|
+
save_inject_state(project_root, state_payload)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
specs = []
|
|
65
|
+
priorities = {}
|
|
66
|
+
for domain in new_domains:
|
|
67
|
+
domain_cfg = mapping.get(domain) or {}
|
|
68
|
+
priorities.update(domain_cfg.get("spec_priority") or {})
|
|
69
|
+
specs.extend(read_specs(project_root, domain, domain_cfg))
|
|
70
|
+
|
|
71
|
+
if not specs:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
budget_cfg = config.get("budget") or {}
|
|
75
|
+
budget = budget_cfg.get("l1_max", 1700)
|
|
76
|
+
try:
|
|
77
|
+
budget = int(budget)
|
|
78
|
+
except Exception:
|
|
79
|
+
budget = 1700
|
|
80
|
+
|
|
81
|
+
selected = select_specs(specs, budget, priorities)
|
|
82
|
+
if not selected:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
included_domains = {spec.get("domain") for spec in selected if spec.get("domain")}
|
|
86
|
+
state_payload = {"injected_domains": sorted(injected_domains | included_domains)}
|
|
87
|
+
state_payload["last_file"] = abs_path
|
|
88
|
+
save_inject_state(project_root, state_payload)
|
|
89
|
+
|
|
90
|
+
payload = {
|
|
91
|
+
"hookSpecificOutput": {
|
|
92
|
+
"hookEventName": "PreToolUse",
|
|
93
|
+
"additionalContext": assemble_context(selected, "## Active Specs (auto-injected)"),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if os.environ.get("CF_DEBUG") == "1":
|
|
97
|
+
payload["debug"] = {
|
|
98
|
+
"target": abs_path,
|
|
99
|
+
"domains": sorted(new_domains),
|
|
100
|
+
}
|
|
101
|
+
sys.stdout.write(json.dumps(payload))
|
|
102
|
+
except Exception:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_config(project_root: str) -> dict:
|
|
10
|
+
config_path = os.path.join(project_root, ".code-flow", "config.yml")
|
|
11
|
+
if not os.path.exists(config_path):
|
|
12
|
+
return {}
|
|
13
|
+
try:
|
|
14
|
+
import yaml
|
|
15
|
+
except Exception:
|
|
16
|
+
return {}
|
|
17
|
+
try:
|
|
18
|
+
with open(config_path, "r", encoding="utf-8") as file:
|
|
19
|
+
data = yaml.safe_load(file)
|
|
20
|
+
return data or {}
|
|
21
|
+
except Exception:
|
|
22
|
+
return {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_text(path: str) -> str:
|
|
26
|
+
try:
|
|
27
|
+
with open(path, "r", encoding="utf-8") as file:
|
|
28
|
+
return file.read()
|
|
29
|
+
except Exception:
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_text(path: str, content: str) -> bool:
|
|
34
|
+
try:
|
|
35
|
+
with open(path, "w", encoding="utf-8") as file:
|
|
36
|
+
file.write(content)
|
|
37
|
+
return True
|
|
38
|
+
except Exception:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def estimate_tokens(text: str) -> int:
|
|
43
|
+
return len(text) // 4
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_target_spec(domain: str, config: dict, file_arg: str) -> tuple:
|
|
47
|
+
mapping = config.get("path_mapping") or {}
|
|
48
|
+
domain_cfg = mapping.get(domain) or {}
|
|
49
|
+
specs = domain_cfg.get("specs") or []
|
|
50
|
+
if not specs:
|
|
51
|
+
return "", specs
|
|
52
|
+
if file_arg:
|
|
53
|
+
if file_arg in specs:
|
|
54
|
+
return file_arg, specs
|
|
55
|
+
return "", specs
|
|
56
|
+
if len(specs) == 1:
|
|
57
|
+
return specs[0], specs
|
|
58
|
+
return "", specs
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def insert_learning(content: str, entry: str) -> str:
|
|
62
|
+
text = content.rstrip()
|
|
63
|
+
if not text:
|
|
64
|
+
return f"## Learnings\n{entry}\n"
|
|
65
|
+
|
|
66
|
+
lines = text.splitlines()
|
|
67
|
+
learn_index = None
|
|
68
|
+
for index, line in enumerate(lines):
|
|
69
|
+
if line.strip() == "## Learnings":
|
|
70
|
+
learn_index = index
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
if learn_index is None:
|
|
74
|
+
return f"{text}\n\n## Learnings\n{entry}\n"
|
|
75
|
+
|
|
76
|
+
insert_at = len(lines)
|
|
77
|
+
for idx in range(learn_index + 1, len(lines)):
|
|
78
|
+
if lines[idx].startswith("## "):
|
|
79
|
+
insert_at = idx
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
lines.insert(insert_at, entry)
|
|
83
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> None:
|
|
87
|
+
parser = argparse.ArgumentParser()
|
|
88
|
+
parser.add_argument("--scope", required=True, choices=["global", "frontend", "backend"])
|
|
89
|
+
parser.add_argument("--content", required=True)
|
|
90
|
+
parser.add_argument("--file", default="")
|
|
91
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
92
|
+
args = parser.parse_args()
|
|
93
|
+
|
|
94
|
+
project_root = os.getcwd()
|
|
95
|
+
config = load_config(project_root)
|
|
96
|
+
|
|
97
|
+
date_str = datetime.date.today().isoformat()
|
|
98
|
+
entry = f"- [{date_str}] {args.content.strip()}"
|
|
99
|
+
|
|
100
|
+
if args.scope == "global":
|
|
101
|
+
target_path = os.path.join(project_root, "CLAUDE.md")
|
|
102
|
+
content = read_text(target_path)
|
|
103
|
+
updated = insert_learning(content, entry)
|
|
104
|
+
if args.dry_run:
|
|
105
|
+
tokens = estimate_tokens(updated)
|
|
106
|
+
print(
|
|
107
|
+
json.dumps(
|
|
108
|
+
{
|
|
109
|
+
"status": "dry_run",
|
|
110
|
+
"target": "CLAUDE.md",
|
|
111
|
+
"entry": entry,
|
|
112
|
+
"tokens": tokens,
|
|
113
|
+
"warning": "L0 超出预算" if tokens > 800 else "",
|
|
114
|
+
},
|
|
115
|
+
ensure_ascii=False,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
return
|
|
119
|
+
if not write_text(target_path, updated):
|
|
120
|
+
print(json.dumps({"error": "write_failed", "target": "CLAUDE.md"}, ensure_ascii=False))
|
|
121
|
+
return
|
|
122
|
+
tokens = estimate_tokens(updated)
|
|
123
|
+
print(
|
|
124
|
+
json.dumps(
|
|
125
|
+
{
|
|
126
|
+
"status": "ok",
|
|
127
|
+
"target": "CLAUDE.md",
|
|
128
|
+
"entry": entry,
|
|
129
|
+
"tokens": tokens,
|
|
130
|
+
"warning": "L0 超出预算" if tokens > 800 else "",
|
|
131
|
+
},
|
|
132
|
+
ensure_ascii=False,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
if not config:
|
|
138
|
+
print(json.dumps({"error": "config_missing"}, ensure_ascii=False))
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
spec_rel, specs = find_target_spec(args.scope, config, args.file)
|
|
142
|
+
if not spec_rel:
|
|
143
|
+
print(
|
|
144
|
+
json.dumps(
|
|
145
|
+
{
|
|
146
|
+
"error": "spec_not_selected",
|
|
147
|
+
"available_specs": specs,
|
|
148
|
+
},
|
|
149
|
+
ensure_ascii=False,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
target_path = os.path.join(project_root, ".code-flow", "specs", spec_rel)
|
|
155
|
+
content = read_text(target_path)
|
|
156
|
+
if not content:
|
|
157
|
+
print(
|
|
158
|
+
json.dumps(
|
|
159
|
+
{"error": "spec_missing_or_empty", "target": spec_rel},
|
|
160
|
+
ensure_ascii=False,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
updated = insert_learning(content, entry)
|
|
166
|
+
tokens = estimate_tokens(updated)
|
|
167
|
+
warning = ""
|
|
168
|
+
if tokens > 500:
|
|
169
|
+
warning = "单文件超过 500 tokens,建议精简"
|
|
170
|
+
if args.dry_run:
|
|
171
|
+
print(
|
|
172
|
+
json.dumps(
|
|
173
|
+
{
|
|
174
|
+
"status": "dry_run",
|
|
175
|
+
"target": spec_rel,
|
|
176
|
+
"entry": entry,
|
|
177
|
+
"tokens": tokens,
|
|
178
|
+
"warning": warning,
|
|
179
|
+
},
|
|
180
|
+
ensure_ascii=False,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
if not write_text(target_path, updated):
|
|
185
|
+
print(json.dumps({"error": "write_failed", "target": spec_rel}, ensure_ascii=False))
|
|
186
|
+
return
|
|
187
|
+
print(
|
|
188
|
+
json.dumps(
|
|
189
|
+
{
|
|
190
|
+
"status": "ok",
|
|
191
|
+
"target": spec_rel,
|
|
192
|
+
"entry": entry,
|
|
193
|
+
"tokens": tokens,
|
|
194
|
+
"warning": warning,
|
|
195
|
+
},
|
|
196
|
+
ensure_ascii=False,
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
main()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from cf_core import estimate_tokens, load_config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PATH_PATTERN = re.compile(r"(?:[\w./-]+/)+[\w.-]+\.[A-Za-z0-9]+")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_text(path: str) -> str:
|
|
14
|
+
try:
|
|
15
|
+
with open(path, "r", encoding="utf-8") as file:
|
|
16
|
+
return file.read().strip()
|
|
17
|
+
except Exception:
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_line(line: str) -> str:
|
|
22
|
+
return " ".join(line.strip().split())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_redundant_lines(specs: list) -> dict:
|
|
26
|
+
line_map = {}
|
|
27
|
+
for spec in specs:
|
|
28
|
+
for raw in spec["content"].splitlines():
|
|
29
|
+
line = normalize_line(raw)
|
|
30
|
+
if not line:
|
|
31
|
+
continue
|
|
32
|
+
if line.startswith("#") or line == "---":
|
|
33
|
+
continue
|
|
34
|
+
if len(line) < 6:
|
|
35
|
+
continue
|
|
36
|
+
line_map.setdefault(line, set()).add(spec["path"])
|
|
37
|
+
redundant = {line: paths for line, paths in line_map.items() if len(paths) >= 3}
|
|
38
|
+
return redundant
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_missing_paths(text: str, project_root: str) -> list:
|
|
42
|
+
missing = []
|
|
43
|
+
for token in set(PATH_PATTERN.findall(text)):
|
|
44
|
+
if token.startswith("http://") or token.startswith("https://"):
|
|
45
|
+
continue
|
|
46
|
+
abs_path = token if os.path.isabs(token) else os.path.join(project_root, token)
|
|
47
|
+
if not os.path.exists(abs_path):
|
|
48
|
+
missing.append(token)
|
|
49
|
+
return sorted(missing)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main() -> None:
|
|
53
|
+
project_root = os.getcwd()
|
|
54
|
+
files = []
|
|
55
|
+
total_tokens = 0
|
|
56
|
+
json_output = "--json" in sys.argv
|
|
57
|
+
only_issues = "--only-issues" in sys.argv
|
|
58
|
+
limit = None
|
|
59
|
+
for arg in sys.argv[1:]:
|
|
60
|
+
if arg.startswith("--limit="):
|
|
61
|
+
try:
|
|
62
|
+
limit = int(arg.split("=", 1)[1])
|
|
63
|
+
except Exception:
|
|
64
|
+
limit = None
|
|
65
|
+
|
|
66
|
+
claude_path = os.path.join(project_root, "CLAUDE.md")
|
|
67
|
+
if os.path.exists(claude_path):
|
|
68
|
+
content = read_text(claude_path)
|
|
69
|
+
tokens = estimate_tokens(content)
|
|
70
|
+
total_tokens += tokens
|
|
71
|
+
files.append({"path": "CLAUDE.md", "tokens": tokens, "issues": []})
|
|
72
|
+
|
|
73
|
+
specs_root = os.path.join(project_root, ".code-flow", "specs")
|
|
74
|
+
specs = []
|
|
75
|
+
if os.path.isdir(specs_root):
|
|
76
|
+
for root, _, filenames in os.walk(specs_root):
|
|
77
|
+
for name in filenames:
|
|
78
|
+
if not name.endswith(".md"):
|
|
79
|
+
continue
|
|
80
|
+
full_path = os.path.join(root, name)
|
|
81
|
+
content = read_text(full_path)
|
|
82
|
+
if not content:
|
|
83
|
+
continue
|
|
84
|
+
tokens = estimate_tokens(content)
|
|
85
|
+
total_tokens += tokens
|
|
86
|
+
rel = os.path.relpath(full_path, specs_root)
|
|
87
|
+
rel_path = os.path.join("specs", rel).replace(os.sep, "/")
|
|
88
|
+
spec_entry = {
|
|
89
|
+
"path": rel_path,
|
|
90
|
+
"tokens": tokens,
|
|
91
|
+
"issues": [],
|
|
92
|
+
"content": content,
|
|
93
|
+
}
|
|
94
|
+
specs.append(spec_entry)
|
|
95
|
+
|
|
96
|
+
redundant_map = find_redundant_lines(specs)
|
|
97
|
+
|
|
98
|
+
for spec in specs:
|
|
99
|
+
issues = []
|
|
100
|
+
if spec["tokens"] > 500:
|
|
101
|
+
issues.append(f"冗长: {spec['tokens']} tokens")
|
|
102
|
+
|
|
103
|
+
missing_paths = find_missing_paths(spec["content"], project_root)
|
|
104
|
+
for path in missing_paths[:3]:
|
|
105
|
+
issues.append(f"过时: 路径不存在 {path}")
|
|
106
|
+
|
|
107
|
+
redundant_lines = [line for line, paths in redundant_map.items() if spec["path"] in paths]
|
|
108
|
+
for line in redundant_lines[:3]:
|
|
109
|
+
issues.append(f"冗余: '{line}' 出现于 {len(redundant_map[line])} 个文件")
|
|
110
|
+
|
|
111
|
+
files.append({"path": spec["path"], "tokens": spec["tokens"], "issues": issues})
|
|
112
|
+
|
|
113
|
+
for entry in files:
|
|
114
|
+
if total_tokens <= 0:
|
|
115
|
+
entry["percent"] = "0%"
|
|
116
|
+
else:
|
|
117
|
+
entry["percent"] = f"{round(entry['tokens'] * 100 / total_tokens)}%"
|
|
118
|
+
|
|
119
|
+
config = load_config(project_root)
|
|
120
|
+
budget = (config.get("budget") or {}).get("total", 2500)
|
|
121
|
+
try:
|
|
122
|
+
budget = int(budget)
|
|
123
|
+
except Exception:
|
|
124
|
+
budget = 2500
|
|
125
|
+
|
|
126
|
+
output = {
|
|
127
|
+
"files": files,
|
|
128
|
+
"total_tokens": total_tokens,
|
|
129
|
+
"budget": budget,
|
|
130
|
+
"warnings": [],
|
|
131
|
+
}
|
|
132
|
+
if limit is not None:
|
|
133
|
+
files = files[:limit]
|
|
134
|
+
|
|
135
|
+
if only_issues:
|
|
136
|
+
files = [entry for entry in files if entry.get("issues")]
|
|
137
|
+
|
|
138
|
+
if json_output:
|
|
139
|
+
output = {
|
|
140
|
+
"files": files,
|
|
141
|
+
"total_tokens": total_tokens,
|
|
142
|
+
"budget": budget,
|
|
143
|
+
"warnings": [],
|
|
144
|
+
}
|
|
145
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
print("FILE | TOKENS | PERCENT | ISSUES")
|
|
149
|
+
for entry in files:
|
|
150
|
+
issues = entry.get("issues") or []
|
|
151
|
+
issue_text = " / ".join(issues) if issues else "-"
|
|
152
|
+
print(f"{entry['path']} | {entry['tokens']} | {entry['percent']} | {issue_text}")
|
|
153
|
+
print("TOTAL:", f"{total_tokens} / {budget}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
main()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def main() -> None:
|
|
6
|
+
try:
|
|
7
|
+
project_root = os.getcwd()
|
|
8
|
+
state_path = os.path.join(project_root, ".code-flow", ".inject-state")
|
|
9
|
+
if os.path.exists(state_path):
|
|
10
|
+
os.remove(state_path)
|
|
11
|
+
except Exception:
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
main()
|