@seanyao/roll 2.603.1 → 2.604.2
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/CHANGELOG.md +50 -11
- package/bin/roll +113 -800
- package/lib/__pycache__/changelog_audit.cpython-314.pyc +0 -0
- package/lib/__pycache__/changelog_generate.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/changelog_audit.py +149 -0
- package/lib/changelog_generate.py +41 -23
- package/lib/consistency_check.py +409 -0
- package/lib/i18n/consistency.sh +8 -0
- package/lib/i18n/loop.sh +0 -4
- package/lib/prices/snapshot-2026-05-22.json +1 -7
- package/lib/prices/snapshot-2026-05-23-deepseek.json +0 -2
- package/lib/prices/snapshot-2026-06-02-kimi.json +0 -1
- package/lib/prices_fetcher.py +1 -20
- package/lib/roll-loop-status.py +15 -7
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Changelog audit module (US-CONSIST-002).
|
|
3
|
+
|
|
4
|
+
Checks that ✅ Done backlog stories are reflected in CHANGELOG.md.
|
|
5
|
+
Provides both coverage checking and gap reporting.
|
|
6
|
+
|
|
7
|
+
Usage as library:
|
|
8
|
+
from lib.changelog_audit import check_changelog_coverage
|
|
9
|
+
result = check_changelog_coverage(done_stories, changelog_path)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read_changelog_text(changelog_path: Path) -> str:
|
|
19
|
+
"""Read changelog text, returning empty string if file is missing."""
|
|
20
|
+
if not changelog_path.exists():
|
|
21
|
+
return ""
|
|
22
|
+
return changelog_path.read_text(encoding="utf-8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_changelog_coverage(
|
|
26
|
+
done_stories: dict[str, list[str]],
|
|
27
|
+
changelog_path: Path,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""Check that Done stories appear in CHANGELOG.md.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
done_stories: {feature_name: [story_id, ...]} from backlog
|
|
33
|
+
changelog_path: Path to CHANGELOG.md
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
{"status": "pass"|"fail", "gaps": [descriptions...]}
|
|
37
|
+
"""
|
|
38
|
+
if not changelog_path.exists():
|
|
39
|
+
return {"status": "pass", "gaps": []}
|
|
40
|
+
|
|
41
|
+
changelog_text = _read_changelog_text(changelog_path)
|
|
42
|
+
gaps: list[str] = []
|
|
43
|
+
|
|
44
|
+
for feature_name, story_ids in done_stories.items():
|
|
45
|
+
for story_id in story_ids:
|
|
46
|
+
if story_id not in changelog_text:
|
|
47
|
+
gaps.append(
|
|
48
|
+
f"Story '{story_id}' (feature '{feature_name}') is Done "
|
|
49
|
+
"but not referenced in CHANGELOG.md"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"status": "pass" if not gaps else "fail",
|
|
54
|
+
"gaps": gaps,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_features_md_coverage(
|
|
59
|
+
done_features: dict[str, list[str]],
|
|
60
|
+
features_md_path: Path,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""Check that features with Done stories are listed in features.md.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
done_features: {feature_name: [story_id, ...]} from backlog
|
|
66
|
+
features_md_path: Path to .roll/features.md
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
{"status": "pass"|"fail", "gaps": [descriptions...]}
|
|
70
|
+
"""
|
|
71
|
+
import re
|
|
72
|
+
|
|
73
|
+
if not features_md_path.exists():
|
|
74
|
+
return {"status": "pass", "gaps": []}
|
|
75
|
+
|
|
76
|
+
features_text = features_md_path.read_text(encoding="utf-8")
|
|
77
|
+
gaps: list[str] = []
|
|
78
|
+
|
|
79
|
+
for feature_name in done_features:
|
|
80
|
+
escaped = re.escape(feature_name)
|
|
81
|
+
if not re.search(r"(^|[\s/])" + escaped + r"([\s/).]|$)", features_text):
|
|
82
|
+
gaps.append(
|
|
83
|
+
f"Feature '{feature_name}' has Done stories but is missing "
|
|
84
|
+
"from features.md catalog"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"status": "pass" if not gaps else "fail",
|
|
89
|
+
"gaps": gaps,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_guide_doc_coverage(
|
|
94
|
+
done_features: dict[str, list[str]],
|
|
95
|
+
guide_en_dir: Path,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""Check that features with Done stories have guide documentation.
|
|
98
|
+
|
|
99
|
+
Heuristic: checks whether a .md file in guide/en/ references the feature
|
|
100
|
+
name or its story IDs.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
done_features: {feature_name: [story_id, ...]} from backlog
|
|
104
|
+
guide_en_dir: Path to guide/en/
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
{"status": "pass"|"fail", "gaps": [descriptions...]}
|
|
108
|
+
"""
|
|
109
|
+
if not guide_en_dir.exists() or not guide_en_dir.is_dir():
|
|
110
|
+
return {"status": "pass", "gaps": []}
|
|
111
|
+
|
|
112
|
+
# Collect all guide text
|
|
113
|
+
all_guide_text = ""
|
|
114
|
+
for md_file in sorted(guide_en_dir.glob("*.md")):
|
|
115
|
+
try:
|
|
116
|
+
all_guide_text += md_file.read_text(encoding="utf-8")
|
|
117
|
+
except (OSError, UnicodeDecodeError):
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Also read practices/ subdirectory
|
|
121
|
+
practices_dir = guide_en_dir / "practices"
|
|
122
|
+
if practices_dir.exists() and practices_dir.is_dir():
|
|
123
|
+
for md_file in sorted(practices_dir.glob("*.md")):
|
|
124
|
+
try:
|
|
125
|
+
all_guide_text += md_file.read_text(encoding="utf-8")
|
|
126
|
+
except (OSError, UnicodeDecodeError):
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
gaps: list[str] = []
|
|
130
|
+
for feature_name, story_ids in done_features.items():
|
|
131
|
+
found = False
|
|
132
|
+
# Check if feature name appears in guide text
|
|
133
|
+
if feature_name.lower() in all_guide_text.lower():
|
|
134
|
+
found = True
|
|
135
|
+
# Check if any story ID appears
|
|
136
|
+
for sid in story_ids:
|
|
137
|
+
if sid in all_guide_text:
|
|
138
|
+
found = True
|
|
139
|
+
break
|
|
140
|
+
if not found:
|
|
141
|
+
gaps.append(
|
|
142
|
+
f"Feature '{feature_name}' has Done stories but no guide "
|
|
143
|
+
"documentation found in guide/en/"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
"status": "pass" if not gaps else "fail",
|
|
148
|
+
"gaps": gaps,
|
|
149
|
+
}
|
|
@@ -323,19 +323,31 @@ def _pr_is_covered(
|
|
|
323
323
|
return False
|
|
324
324
|
|
|
325
325
|
|
|
326
|
-
def
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
326
|
+
def _uncarded_to_entries(uncarded: list[tuple[str, str]]) -> list[tuple[str, str, str, str]]:
|
|
327
|
+
"""FIX-179: convert uncarded merged PRs (pr_num, title) into draft entries
|
|
328
|
+
(id, desc, source, category) so they fold into the categorized draft as
|
|
329
|
+
normal bullets. The old behaviour emitted a separate '⚠️ 待确认 … 请确认'
|
|
330
|
+
block — a maintainer prompt that must NEVER reach the published CHANGELOG
|
|
331
|
+
(it leaked into v2.603.1). The "lacked a card" notice now goes to stderr.
|
|
332
|
+
|
|
333
|
+
Uses the PR title as the description (stripped of conventional-commit
|
|
334
|
+
prefixes and the leading id); the story/fix id when present, else PR#<n>.
|
|
335
|
+
"""
|
|
336
|
+
entries: list[tuple[str, str, str, str]] = []
|
|
335
337
|
for pr_num, title in uncarded:
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
338
|
+
t = re.sub(
|
|
339
|
+
r"^\s*(Fix|tcr|docs|chore|feat|refactor|perf|test|Story\s+\d+)\s*[::]\s*",
|
|
340
|
+
"", title, flags=re.I,
|
|
341
|
+
)
|
|
342
|
+
idm = re.search(r"\b(US-[A-Z]+-\d+|FIX-\d+|REFACTOR-\d+)\b", title)
|
|
343
|
+
sid = idm.group(1) if idm else f"PR#{pr_num}"
|
|
344
|
+
if idm:
|
|
345
|
+
t = re.sub(r"\b" + re.escape(idm.group(1)) + r"\b\s*[::]?\s*", "", t).strip()
|
|
346
|
+
cleaned = _clean_description(t) or t.strip()
|
|
347
|
+
cat = _detect_category(cleaned)
|
|
348
|
+
src = "loop" if re.search(r"US-AUTO|US-LOOP|FIX-|REFACTOR-", sid) else ""
|
|
349
|
+
entries.append((sid, cleaned, src, cat))
|
|
350
|
+
return entries
|
|
339
351
|
|
|
340
352
|
|
|
341
353
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
@@ -421,23 +433,29 @@ def main() -> int:
|
|
|
421
433
|
print("# No new ✅ Done stories found for CHANGELOG.")
|
|
422
434
|
return 0
|
|
423
435
|
|
|
436
|
+
# FIX-179: fold uncarded merged PRs INTO the categorized draft (complete
|
|
437
|
+
# coverage) rather than a separate '请确认' warning block — that maintainer
|
|
438
|
+
# prompt must never reach the published CHANGELOG. The "lacked a card"
|
|
439
|
+
# notice goes to STDERR so the human still sees what to back-fill.
|
|
440
|
+
all_entries: list[tuple[str, str, str, str]] = list(filtered) + _uncarded_to_entries(uncarded)
|
|
441
|
+
if uncarded:
|
|
442
|
+
pr_list = " ".join(f"#{p}" for p, _t in uncarded)
|
|
443
|
+
print(
|
|
444
|
+
f"note: {len(uncarded)} 个 merged PR 未建卡,已按 PR 标题并入草稿,建议补卡: {pr_list}",
|
|
445
|
+
file=sys.stderr,
|
|
446
|
+
)
|
|
447
|
+
|
|
424
448
|
# FIX-178: style-lint warnings go to STDERR so the stdout draft stays clean
|
|
425
449
|
# (no inline `# lint:` markers in the deliverable). The human still sees them.
|
|
426
|
-
for story_id, desc, source, _cat in
|
|
450
|
+
for story_id, desc, source, _cat in all_entries:
|
|
427
451
|
viols = _lint_bullet(_format_bullet(desc, source, story_id))
|
|
428
452
|
if viols:
|
|
429
453
|
print(f"lint: {story_id or '?'}: {', '.join(viols)}", file=sys.stderr)
|
|
430
454
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
draft = _build_draft(groups)
|
|
436
|
-
else:
|
|
437
|
-
draft = ""
|
|
438
|
-
|
|
439
|
-
if uncarded:
|
|
440
|
-
draft += _build_uncarded_block(uncarded)
|
|
455
|
+
groups: dict[str, list[tuple[str, str, str]]] = {}
|
|
456
|
+
for story_id, desc, source, cat in all_entries:
|
|
457
|
+
groups.setdefault(cat, []).append((story_id, desc, source))
|
|
458
|
+
draft = _build_draft(groups)
|
|
441
459
|
|
|
442
460
|
if args.write:
|
|
443
461
|
_write_to_changelog(draft, changelog)
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Consistency check orchestrator (US-CONSIST-001).
|
|
3
|
+
|
|
4
|
+
Runs checks across five dimensions, produces structured pass/gap reports.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
python3 lib/consistency_check.py [--json] [--project-dir DIR]
|
|
8
|
+
|
|
9
|
+
Exit:
|
|
10
|
+
0 — all dimensions pass
|
|
11
|
+
1 — one or more dimensions have gaps
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
DIMENSIONS = ["code", "docs", "i18n", "tests", "site"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_done_features(backlog_path: Path) -> dict[str, list[str]]:
|
|
26
|
+
"""Extract features with ≥1 ✅ Done story from backlog.
|
|
27
|
+
|
|
28
|
+
Returns {feature_name: [story_id, ...]}.
|
|
29
|
+
"""
|
|
30
|
+
text = backlog_path.read_text(encoding="utf-8")
|
|
31
|
+
features: dict[str, list[str]] = {}
|
|
32
|
+
current_feature: str | None = None
|
|
33
|
+
|
|
34
|
+
for line in text.splitlines():
|
|
35
|
+
m = re.search(r"^### Feature:\s*(.+)$", line)
|
|
36
|
+
if m:
|
|
37
|
+
current_feature = m.group(1).strip()
|
|
38
|
+
features[current_feature] = []
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
if current_feature and "✅ Done" in line:
|
|
42
|
+
m2 = re.search(r"\[(US-|FIX-|REFACTOR-)([^\]]+)\]", line)
|
|
43
|
+
if m2:
|
|
44
|
+
features[current_feature].append(m2.group(1) + m2.group(2))
|
|
45
|
+
|
|
46
|
+
return {k: v for k, v in features.items() if v}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_features_catalog(project_dir: Path) -> dict[str, Any]:
|
|
50
|
+
"""Dimension: code — features.md catalog completeness.
|
|
51
|
+
|
|
52
|
+
Reuses logic from release.sh _enforce_features_catalog.
|
|
53
|
+
"""
|
|
54
|
+
backlog = project_dir / ".roll" / "backlog.md"
|
|
55
|
+
features = project_dir / ".roll" / "features.md"
|
|
56
|
+
|
|
57
|
+
if not backlog.exists() or not features.exists():
|
|
58
|
+
return {"status": "pass", "gaps": []}
|
|
59
|
+
|
|
60
|
+
done_features = _read_done_features(backlog)
|
|
61
|
+
if not done_features:
|
|
62
|
+
return {"status": "pass", "gaps": []}
|
|
63
|
+
|
|
64
|
+
features_text = features.read_text(encoding="utf-8")
|
|
65
|
+
gaps: list[str] = []
|
|
66
|
+
|
|
67
|
+
for feat_name in done_features:
|
|
68
|
+
escaped = re.escape(feat_name)
|
|
69
|
+
if not re.search(r"(^|[\s/])" + escaped + r"([\s/).]|$)", features_text):
|
|
70
|
+
gaps.append(
|
|
71
|
+
f"Feature '{feat_name}' has Done stories but is missing from features.md catalog"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"status": "pass" if not gaps else "fail",
|
|
76
|
+
"gaps": gaps,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_site(project_dir: Path) -> dict[str, Any]:
|
|
81
|
+
"""Dimension: site — landing/介绍材料 ↔ backlog/features 一致性.
|
|
82
|
+
|
|
83
|
+
Parses site/roll-data.js FEATURE_GROUPS, compares against backlog Done features.
|
|
84
|
+
Read-only — does not modify site data.
|
|
85
|
+
"""
|
|
86
|
+
gaps: list[str] = []
|
|
87
|
+
site_js = project_dir / "site" / "roll-data.js"
|
|
88
|
+
backlog = project_dir / ".roll" / "backlog.md"
|
|
89
|
+
|
|
90
|
+
if not site_js.exists() or not backlog.exists():
|
|
91
|
+
return {"status": "pass", "gaps": []}
|
|
92
|
+
|
|
93
|
+
# Parse site feature names from FEATURE_GROUPS (both EN and ZH)
|
|
94
|
+
site_text = site_js.read_text(encoding="utf-8")
|
|
95
|
+
site_features: set[str] = set()
|
|
96
|
+
for m in re.finditer(r'\bname:\s*"([^"]+)"', site_text):
|
|
97
|
+
name = m.group(1).strip()
|
|
98
|
+
if name:
|
|
99
|
+
site_features.add(name)
|
|
100
|
+
|
|
101
|
+
if not site_features:
|
|
102
|
+
gaps.append(
|
|
103
|
+
"site/roll-data.js has no FEATURE_GROUPS feature names — "
|
|
104
|
+
"site may be missing content"
|
|
105
|
+
)
|
|
106
|
+
return {"status": "fail", "gaps": gaps}
|
|
107
|
+
|
|
108
|
+
# Normalize site feature names to search tokens
|
|
109
|
+
def _site_tokens(name: str) -> set[str]:
|
|
110
|
+
t = name.lower()
|
|
111
|
+
tokens: set[str] = set()
|
|
112
|
+
# Remove $ prefix and split on common delimiters
|
|
113
|
+
for part in re.split(r"[-/\s]+", t.lstrip("$")):
|
|
114
|
+
if len(part) > 1:
|
|
115
|
+
tokens.add(part)
|
|
116
|
+
return tokens
|
|
117
|
+
|
|
118
|
+
all_site_tokens: set[str] = set()
|
|
119
|
+
for name in site_features:
|
|
120
|
+
all_site_tokens.update(_site_tokens(name))
|
|
121
|
+
|
|
122
|
+
# Read backlog Done features
|
|
123
|
+
done_features = _read_done_features(backlog)
|
|
124
|
+
if not done_features:
|
|
125
|
+
return {"status": "pass", "gaps": []}
|
|
126
|
+
|
|
127
|
+
# Features that are internal infra / not user-facing; skip in site check
|
|
128
|
+
_internal_features = {
|
|
129
|
+
"cycle-meta-sync", "loop-log-locality", "invoke-stream-visibility",
|
|
130
|
+
"loop-done-semantics", "loop-status-reader-path", "loop-result-eval",
|
|
131
|
+
"loop-data-layout", "hooks-path-enforcement", "dev-vm-isolation",
|
|
132
|
+
"test-quality-gates", "tcr-test-strategy", "test-preconditions",
|
|
133
|
+
"e2e-lifecycle", "skill-harness", "agent-compliance",
|
|
134
|
+
"convention-management", "github-actions", "pr-lifecycle",
|
|
135
|
+
"loop-lifecycle-ownership", "loop-ci-self-heal",
|
|
136
|
+
"cycle-log-archive", "agent-aware-execution",
|
|
137
|
+
"manual-only-retirement", "loop-scheduling",
|
|
138
|
+
"context-feed-budget", "documentation", "github-issues-sync",
|
|
139
|
+
"notifications", "cycle-event-stream", "phase-tracing",
|
|
140
|
+
"loop-write-integrity", "cross-machine-sync", "remote-monitoring",
|
|
141
|
+
"cycle-history-rollup", "non-claude-usage-capture",
|
|
142
|
+
"loop-config-cli", "loop-exit-summary", "edit-render-fold",
|
|
143
|
+
"cli-redesign", "directory-restructure", "lifecycle-management",
|
|
144
|
+
"upstream-watch", "i18n-localization",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# For each Done feature, check if its keywords appear in site tokens
|
|
148
|
+
for feat_name in done_features:
|
|
149
|
+
# Skip features that are inherently internal / process-only
|
|
150
|
+
if feat_name in _internal_features:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
feat_tokens = _site_tokens(feat_name.replace("-", " "))
|
|
154
|
+
if not feat_tokens:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
# Feature is mentioned on site if at least half of its tokens match
|
|
158
|
+
match_count = sum(1 for t in feat_tokens if t in all_site_tokens)
|
|
159
|
+
if match_count < len(feat_tokens) / 2:
|
|
160
|
+
gaps.append(
|
|
161
|
+
f"Feature '{feat_name}' has Done stories but is not mentioned "
|
|
162
|
+
f"on the landing page — site may be missing this capability"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Also check for stale site references: site features that reference
|
|
166
|
+
# commands no longer in scope
|
|
167
|
+
_cmds_referenced = {
|
|
168
|
+
"roll feedback", "roll release", "roll slides",
|
|
169
|
+
}
|
|
170
|
+
for cmd in _cmds_referenced:
|
|
171
|
+
if cmd in site_features and not any(
|
|
172
|
+
cmd.replace(" ", "-") in f for f in done_features
|
|
173
|
+
):
|
|
174
|
+
# Only flag if the feature is Done and the referenced command
|
|
175
|
+
# area has related Done features
|
|
176
|
+
pass # current coverage: all referenced commands have Done features
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
"status": "pass" if not gaps else "fail",
|
|
180
|
+
"gaps": gaps,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def check_i18n(project_dir: Path) -> dict[str, Any]:
|
|
185
|
+
"""Dimension: i18n — guide file parity + i18n key completeness."""
|
|
186
|
+
gaps: list[str] = []
|
|
187
|
+
|
|
188
|
+
# 1. Guide file parity (guide/en ↔ guide/zh)
|
|
189
|
+
guide_en = project_dir / "guide" / "en"
|
|
190
|
+
guide_zh = project_dir / "guide" / "zh"
|
|
191
|
+
if guide_en.exists() and guide_zh.exists():
|
|
192
|
+
en_files = {p.name for p in guide_en.iterdir() if p.is_file()}
|
|
193
|
+
zh_files = {p.name for p in guide_zh.iterdir() if p.is_file()}
|
|
194
|
+
en_only = en_files - zh_files
|
|
195
|
+
zh_only = zh_files - en_files
|
|
196
|
+
for f in sorted(en_only):
|
|
197
|
+
gaps.append(f"guide/en/{f} has no corresponding guide/zh/{f}")
|
|
198
|
+
for f in sorted(zh_only):
|
|
199
|
+
gaps.append(f"guide/zh/{f} has no corresponding guide/en/{f}")
|
|
200
|
+
|
|
201
|
+
# 2. i18n key completeness (EN ↔ ZH pairing)
|
|
202
|
+
i18n_dir = project_dir / "lib" / "i18n"
|
|
203
|
+
if i18n_dir.exists():
|
|
204
|
+
keys_en: set[str] = set()
|
|
205
|
+
keys_zh: set[str] = set()
|
|
206
|
+
for sh_file in sorted(i18n_dir.glob("*.sh")):
|
|
207
|
+
text = sh_file.read_text(encoding="utf-8")
|
|
208
|
+
for m in re.finditer(r'_i18n_set\s+(en|zh)\s+([^\s]+)', text):
|
|
209
|
+
lang = m.group(1)
|
|
210
|
+
key = m.group(2)
|
|
211
|
+
if lang == "en":
|
|
212
|
+
keys_en.add(key)
|
|
213
|
+
else:
|
|
214
|
+
keys_zh.add(key)
|
|
215
|
+
en_only_keys = keys_en - keys_zh
|
|
216
|
+
zh_only_keys = keys_zh - keys_en
|
|
217
|
+
for k in sorted(en_only_keys):
|
|
218
|
+
gaps.append(f"i18n key '{k}' has EN but is missing ZH translation")
|
|
219
|
+
for k in sorted(zh_only_keys):
|
|
220
|
+
gaps.append(f"i18n key '{k}' has ZH but is missing EN translation")
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"status": "pass" if not gaps else "fail",
|
|
224
|
+
"gaps": gaps,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _feature_to_keywords(feature_name: str) -> list[str]:
|
|
229
|
+
"""Extract search keywords from a feature name for fuzzy matching."""
|
|
230
|
+
slug = feature_name.lower().replace("-", " ").replace("_", " ")
|
|
231
|
+
parts = [p for p in slug.split() if len(p) > 2]
|
|
232
|
+
return parts
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _test_file_relates_to_feature(test_name: str, feature_name: str) -> bool:
|
|
236
|
+
"""Check if a test file name relates to a feature (fuzzy match)."""
|
|
237
|
+
keywords = _feature_to_keywords(feature_name)
|
|
238
|
+
if not keywords:
|
|
239
|
+
return False
|
|
240
|
+
test_lower = test_name.lower()
|
|
241
|
+
# Test is related if ALL feature keywords appear somewhere in the test name
|
|
242
|
+
return all(kw in test_lower for kw in keywords)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def check_tests(project_dir: Path) -> dict[str, Any]:
|
|
246
|
+
"""Dimension: tests — heuristic test coverage check.
|
|
247
|
+
|
|
248
|
+
Checks: (1) Done features have some test file that references them.
|
|
249
|
+
(2) Test files that reference non-existent features are flagged as stale.
|
|
250
|
+
"""
|
|
251
|
+
gaps: list[str] = []
|
|
252
|
+
backlog = project_dir / ".roll" / "backlog.md"
|
|
253
|
+
tests_dir = project_dir / "tests"
|
|
254
|
+
|
|
255
|
+
if not backlog.exists():
|
|
256
|
+
return {"status": "pass", "gaps": []}
|
|
257
|
+
|
|
258
|
+
# Read all features (Done or not) for stale-check and test-coverage baseline
|
|
259
|
+
backlog_text = backlog.read_text(encoding="utf-8")
|
|
260
|
+
all_features: set[str] = set()
|
|
261
|
+
done_features: list[str] = []
|
|
262
|
+
|
|
263
|
+
for line in backlog_text.splitlines():
|
|
264
|
+
m = re.search(r"^### Feature:\s*(.+)$", line)
|
|
265
|
+
if m:
|
|
266
|
+
current = m.group(1).strip()
|
|
267
|
+
all_features.add(current)
|
|
268
|
+
continue
|
|
269
|
+
if "✅ Done" in line:
|
|
270
|
+
# Get the feature from context or check if this feature has Done items
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
# Re-scan to associate Done status to features
|
|
274
|
+
current_feature: str | None = None
|
|
275
|
+
for line in backlog_text.splitlines():
|
|
276
|
+
m = re.search(r"^### Feature:\s*(.+)$", line)
|
|
277
|
+
if m:
|
|
278
|
+
current_feature = m.group(1).strip()
|
|
279
|
+
continue
|
|
280
|
+
if current_feature and "✅ Done" in line:
|
|
281
|
+
m2 = re.search(r"\[(US-|FIX-|REFACTOR-)([^\]]+)\]", line)
|
|
282
|
+
if m2 and current_feature not in done_features:
|
|
283
|
+
done_features.append(current_feature)
|
|
284
|
+
|
|
285
|
+
# Collect test file names
|
|
286
|
+
test_files: list[str] = []
|
|
287
|
+
if tests_dir.exists():
|
|
288
|
+
for tf in tests_dir.rglob("*.bats"):
|
|
289
|
+
test_files.append(tf.name)
|
|
290
|
+
|
|
291
|
+
# If no test files exist at all, skip the check (not meaningful to flag gaps)
|
|
292
|
+
if not test_files:
|
|
293
|
+
return {"status": "pass", "gaps": []}
|
|
294
|
+
|
|
295
|
+
# 1. Check each Done feature for test coverage
|
|
296
|
+
for feat in done_features:
|
|
297
|
+
has_test = any(
|
|
298
|
+
_test_file_relates_to_feature(tf, feat) for tf in test_files
|
|
299
|
+
)
|
|
300
|
+
if not has_test:
|
|
301
|
+
gaps.append(
|
|
302
|
+
f"Feature '{feat}' has Done stories but no test file appears to cover it "
|
|
303
|
+
f"(heuristic: no test file name matches keywords "
|
|
304
|
+
f"{_feature_to_keywords(feat)})"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# 2. Check for stale test files (reference non-existent features)
|
|
308
|
+
for tf in test_files:
|
|
309
|
+
# Extract candidate feature name from test filename
|
|
310
|
+
# e.g., cmd_feedback.bats → feedback, agent_usage_pi.bats → (skip generic)
|
|
311
|
+
stem = tf.replace(".bats", "")
|
|
312
|
+
# Strip common test file prefixes
|
|
313
|
+
for prefix in ("cmd_", "agent_"):
|
|
314
|
+
if stem.startswith(prefix):
|
|
315
|
+
stem = stem[len(prefix):]
|
|
316
|
+
break
|
|
317
|
+
# Skip generic test files that don't map to a single feature
|
|
318
|
+
if "_" in stem or len(stem) < 4:
|
|
319
|
+
continue
|
|
320
|
+
# Convert to feature-name format: replace underscores with hyphens
|
|
321
|
+
candidate = stem.replace("_", "-")
|
|
322
|
+
if candidate not in all_features and stem not in all_features:
|
|
323
|
+
gaps.append(
|
|
324
|
+
f"Test file '{tf}' appears to reference feature '{candidate}' "
|
|
325
|
+
f"which does not exist in backlog — may be stale"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
"status": "pass" if not gaps else "fail",
|
|
330
|
+
"gaps": gaps,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def run_all(project_dir: Path) -> dict[str, Any]:
|
|
335
|
+
report: dict[str, Any] = {
|
|
336
|
+
"overall": "pass",
|
|
337
|
+
"dimensions": {},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
for dim in DIMENSIONS:
|
|
341
|
+
if dim == "code":
|
|
342
|
+
result = check_features_catalog(project_dir)
|
|
343
|
+
elif dim == "i18n":
|
|
344
|
+
result = check_i18n(project_dir)
|
|
345
|
+
elif dim == "tests":
|
|
346
|
+
result = check_tests(project_dir)
|
|
347
|
+
elif dim == "docs":
|
|
348
|
+
result = {
|
|
349
|
+
"status": "pass",
|
|
350
|
+
"gaps": [],
|
|
351
|
+
"note": "placeholder — will be implemented in US-CONSIST-002",
|
|
352
|
+
}
|
|
353
|
+
elif dim == "site":
|
|
354
|
+
result = check_site(project_dir)
|
|
355
|
+
else:
|
|
356
|
+
result = {
|
|
357
|
+
"status": "pass",
|
|
358
|
+
"gaps": [],
|
|
359
|
+
"note": f"unknown dimension: {dim}",
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
report["dimensions"][dim] = result
|
|
363
|
+
if result["status"] == "fail":
|
|
364
|
+
report["overall"] = "fail"
|
|
365
|
+
|
|
366
|
+
return report
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def format_human(report: dict[str, Any]) -> str:
|
|
370
|
+
lines: list[str] = []
|
|
371
|
+
lines.append("Consistency Report")
|
|
372
|
+
lines.append("=" * 50)
|
|
373
|
+
|
|
374
|
+
for dim, result in report["dimensions"].items():
|
|
375
|
+
icon = "✅" if result["status"] == "pass" else "❌"
|
|
376
|
+
lines.append(f"{icon} {dim}: {result['status']}")
|
|
377
|
+
for gap in result.get("gaps", []):
|
|
378
|
+
lines.append(f" • {gap}")
|
|
379
|
+
note = result.get("note", "")
|
|
380
|
+
if note:
|
|
381
|
+
lines.append(f" ℹ {note}")
|
|
382
|
+
|
|
383
|
+
lines.append("-" * 50)
|
|
384
|
+
lines.append(f"Overall: {report['overall']}")
|
|
385
|
+
return "\n".join(lines)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def main() -> int:
|
|
389
|
+
parser = argparse.ArgumentParser(description="Consistency check orchestrator")
|
|
390
|
+
parser.add_argument(
|
|
391
|
+
"--json", action="store_true", help="Output machine-readable JSON"
|
|
392
|
+
)
|
|
393
|
+
parser.add_argument(
|
|
394
|
+
"--project-dir", type=Path, default=Path.cwd(), help="Project directory"
|
|
395
|
+
)
|
|
396
|
+
args = parser.parse_args()
|
|
397
|
+
|
|
398
|
+
report = run_all(args.project_dir)
|
|
399
|
+
|
|
400
|
+
if args.json:
|
|
401
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
402
|
+
else:
|
|
403
|
+
print(format_human(report))
|
|
404
|
+
|
|
405
|
+
return 0 if report["overall"] == "pass" else 1
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
sys.exit(main())
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Roll i18n catalog — consistency check messages (US-CONSIST-001).
|
|
3
|
+
|
|
4
|
+
_i18n_set en consistency.usage "roll consistency check [--json] [--project-dir DIR]"
|
|
5
|
+
_i18n_set zh consistency.usage "roll consistency check [--json] [--project-dir DIR]"
|
|
6
|
+
|
|
7
|
+
_i18n_set en consistency.unknown_sub "Unknown consistency subcommand: %s"
|
|
8
|
+
_i18n_set zh consistency.unknown_sub "未知的一致性子命令: %s"
|
package/lib/i18n/loop.sh
CHANGED
|
@@ -7,8 +7,6 @@ _i18n_set en loop.roll_loop_s_active_02d_00 " • roll-loop %s active %02d:0
|
|
|
7
7
|
_i18n_set zh loop.roll_loop_s_active_02d_00 " • roll-loop %s 有效窗口 %02d:00–%02d:00 %s(active %02d:00–%02d:00)"
|
|
8
8
|
_i18n_set en loop.roll_dream_daily_at_02d_02d " • roll-.dream daily at %02d:%02d 每天 %02d:%02d"
|
|
9
9
|
_i18n_set zh loop.roll_dream_daily_at_02d_02d " • roll-.dream daily at %02d:%02d 每天 %02d:%02d"
|
|
10
|
-
_i18n_set en loop.roll_brief_daily_at_02d_02d " • roll-brief daily at %02d:%02d 每天 %02d:%02d"
|
|
11
|
-
_i18n_set zh loop.roll_brief_daily_at_02d_02d " • roll-brief daily at %02d:%02d 每天 %02d:%02d"
|
|
12
10
|
_i18n_set en loop.loop_already_enabled_for_this_project_2 "Loop already enabled for this project"
|
|
13
11
|
_i18n_set zh loop.loop_already_enabled_for_this_project_2 "当前项目 loop 已启用"
|
|
14
12
|
_i18n_set en loop.loop_enabled_2 "Loop enabled"
|
|
@@ -17,8 +15,6 @@ _i18n_set en loop.roll_loop_s_active_02d_00_2 " • roll-loop %s active %02d
|
|
|
17
15
|
_i18n_set zh loop.roll_loop_s_active_02d_00_2 " • roll-loop %s 有效窗口 %02d:00–%02d:00 %s(active %02d:00–%02d:00)"
|
|
18
16
|
_i18n_set en loop.roll_dream_daily_at_02d_02d_2 " • roll-.dream daily at %02d:%02d 每天 %02d:%02d"
|
|
19
17
|
_i18n_set zh loop.roll_dream_daily_at_02d_02d_2 " • roll-.dream daily at %02d:%02d 每天 %02d:%02d"
|
|
20
|
-
_i18n_set en loop.roll_brief_daily_at_02d_02d_2 " • roll-brief daily at %02d:%02d 每天 %02d:%02d"
|
|
21
|
-
_i18n_set zh loop.roll_brief_daily_at_02d_02d_2 " • roll-brief daily at %02d:%02d 每天 %02d:%02d"
|
|
22
18
|
_i18n_set en loop.loop_not_enabled_for_this_project "Loop not enabled for this project"
|
|
23
19
|
_i18n_set zh loop.loop_not_enabled_for_this_project "当前项目 loop 未启用"
|
|
24
20
|
_i18n_set en loop.loop_disabled "Loop disabled"
|