bigpowers 2.58.1 → 2.58.3
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/.pi/package.json +1 -1
- package/CHANGELOG.md +14 -0
- package/CONVENTIONS.md +12 -0
- package/package.json +1 -1
- package/scripts/audit-compliance.sh +3 -50
- package/scripts/check-blind-spots.sh +9 -307
- package/scripts/generate-skill-index.sh +1 -0
- package/scripts/land-branch.sh +2 -2
- package/scripts/lib/blind-spots.py +234 -0
- package/scripts/lib/git-hours.sh +50 -0
- package/scripts/lib/trace-stories.py +362 -0
- package/scripts/lib/waiver-utils.sh +39 -0
- package/scripts/record-cycle-time.sh +10 -52
- package/scripts/trace-stories.sh +8 -552
- package/scripts/validate-okf.sh +204 -119
- package/scripts/wait-for-ci.sh +3 -3
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""story: e38s04 — TEA-inspired heuristic blind-spot detector (Python engine).
|
|
3
|
+
|
|
4
|
+
Reads execution-status.yaml + traceability-matrix.json, runs 6 structural
|
|
5
|
+
quality checks, and emits specs/blind-spots.json.
|
|
6
|
+
|
|
7
|
+
Usage: called by scripts/check-blind-spots.sh with positional args:
|
|
8
|
+
python3 scripts/lib/blind-spots.py <repo_root> <blind_spots_json>
|
|
9
|
+
<exec_status_yaml> <matrix_json> <verifications_dir> <epics_dir>
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json, os, re, sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
ROOT = Path(sys.argv[1])
|
|
17
|
+
BLIND_SPOTS_JSON = Path(sys.argv[2])
|
|
18
|
+
EXEC_STATUS = Path(sys.argv[3])
|
|
19
|
+
MATRIX_JSON = Path(sys.argv[4])
|
|
20
|
+
VERIFICATIONS_DIR = Path(sys.argv[5])
|
|
21
|
+
EPICS_DIR = Path(sys.argv[6])
|
|
22
|
+
|
|
23
|
+
findings: list[dict] = []
|
|
24
|
+
|
|
25
|
+
def parse_exec_status(path: Path) -> dict[str, str]:
|
|
26
|
+
status: dict[str, str] = {}
|
|
27
|
+
if not path.exists():
|
|
28
|
+
return status
|
|
29
|
+
text = path.read_text(encoding="utf-8")
|
|
30
|
+
lines = text.splitlines()
|
|
31
|
+
in_dev_status = False
|
|
32
|
+
for line in lines:
|
|
33
|
+
stripped = line.strip()
|
|
34
|
+
if stripped.startswith("development_status:"):
|
|
35
|
+
in_dev_status = True
|
|
36
|
+
continue
|
|
37
|
+
if not in_dev_status:
|
|
38
|
+
if stripped and not stripped.startswith(" ") and not stripped.startswith("#"):
|
|
39
|
+
if ":" in stripped:
|
|
40
|
+
in_dev_status = False
|
|
41
|
+
continue
|
|
42
|
+
if ":" in stripped and not stripped.startswith("#"):
|
|
43
|
+
m = re.match(r'\s+(\S+):\s*"([^"]*)"', stripped)
|
|
44
|
+
if m:
|
|
45
|
+
status[m.group(1)] = m.group(2)
|
|
46
|
+
return status
|
|
47
|
+
|
|
48
|
+
dev_status = parse_exec_status(EXEC_STATUS)
|
|
49
|
+
|
|
50
|
+
matrix_data: dict | None = None
|
|
51
|
+
if MATRIX_JSON.exists():
|
|
52
|
+
matrix_data = json.loads(MATRIX_JSON.read_text(encoding="utf-8"))
|
|
53
|
+
|
|
54
|
+
tagged_files: dict[str, list[str]] = {}
|
|
55
|
+
file_story_counts: dict[str, set[str]] = {}
|
|
56
|
+
if matrix_data:
|
|
57
|
+
for s in matrix_data.get("stories", []):
|
|
58
|
+
sid = s["id"]
|
|
59
|
+
for link in s.get("links", []):
|
|
60
|
+
f = link["file"]
|
|
61
|
+
tagged_files.setdefault(sid, []).append(f)
|
|
62
|
+
file_story_counts.setdefault(f, set()).add(sid)
|
|
63
|
+
|
|
64
|
+
CODE_EXTENSIONS = {".sh", ".py", ".js", ".ts", ".jsx", ".tsx", ".md", ".yaml", ".yml", ".cjs", ".mjs", ".json"}
|
|
65
|
+
SKIP_DIRS = {".git", "node_modules", ".cursor", ".gemini", ".pi", "specs", "dist", "build", "__pycache__", ".bigpowers"}
|
|
66
|
+
|
|
67
|
+
def is_code_file(p: Path) -> bool:
|
|
68
|
+
return p.suffix in CODE_EXTENSIONS and not any(s in p.parts for s in SKIP_DIRS)
|
|
69
|
+
|
|
70
|
+
all_code_files: list[str] = []
|
|
71
|
+
for root, dirs, files in os.walk(str(ROOT)):
|
|
72
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
|
73
|
+
rel_root = str(Path(root).relative_to(ROOT))
|
|
74
|
+
for fname in files:
|
|
75
|
+
fpath = Path(root) / fname
|
|
76
|
+
if is_code_file(fpath):
|
|
77
|
+
all_code_files.append(str(fpath.relative_to(ROOT)))
|
|
78
|
+
|
|
79
|
+
TEST_PATTERNS = [
|
|
80
|
+
re.compile(r"(^|/)test(s)?/", re.IGNORECASE),
|
|
81
|
+
re.compile(r"(^|/)__tests__/", re.IGNORECASE),
|
|
82
|
+
re.compile(r"\.test\.", re.IGNORECASE),
|
|
83
|
+
re.compile(r"\.spec\.", re.IGNORECASE),
|
|
84
|
+
re.compile(r"_test\.", re.IGNORECASE),
|
|
85
|
+
re.compile(r"test_", re.IGNORECASE),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
def has_test_files(code_files: list[str]) -> bool:
|
|
89
|
+
for cf in code_files:
|
|
90
|
+
cf_path = Path(cf)
|
|
91
|
+
cf_dir = cf_path.parent
|
|
92
|
+
cf_stem = cf_path.stem
|
|
93
|
+
for tf in all_code_files:
|
|
94
|
+
tp = Path(tf)
|
|
95
|
+
if tp.parent == cf_dir:
|
|
96
|
+
for pat in TEST_PATTERNS:
|
|
97
|
+
if pat.search(tp.name) and cf_stem in tp.name:
|
|
98
|
+
return True
|
|
99
|
+
for tdn in ["tests", "test", "__tests__"]:
|
|
100
|
+
test_dir = cf_dir / tdn
|
|
101
|
+
if str(test_dir) in tf:
|
|
102
|
+
if cf_stem in tp.name:
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
# Check A: verify-gap
|
|
107
|
+
for sid, sstatus in dev_status.items():
|
|
108
|
+
if sstatus == "done":
|
|
109
|
+
verify_file = VERIFICATIONS_DIR / f"{sid}-verify.yaml"
|
|
110
|
+
if not verify_file.exists():
|
|
111
|
+
findings.append({
|
|
112
|
+
"check": "verify-gap",
|
|
113
|
+
"story_id": sid,
|
|
114
|
+
"severity": "HIGH",
|
|
115
|
+
"description": f"Story {sid} is marked done but no verification evidence exists at specs/verifications/{sid}-verify.yaml",
|
|
116
|
+
"remediation": f"Run verify-work for {sid} or create the verification evidence file"
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
# Check B: test-gap
|
|
120
|
+
for sid, files in tagged_files.items():
|
|
121
|
+
if dev_status.get(sid) in ("done", "active"):
|
|
122
|
+
if not has_test_files(files):
|
|
123
|
+
code_only = [f for f in files if Path(f).suffix in {".sh", ".py", ".js", ".ts", ".jsx", ".tsx"} and "SKILL.md" not in f]
|
|
124
|
+
if code_only:
|
|
125
|
+
findings.append({
|
|
126
|
+
"check": "test-gap",
|
|
127
|
+
"story_id": sid,
|
|
128
|
+
"severity": "MEDIUM",
|
|
129
|
+
"description": f"Story {sid} has {len(code_only)} tagged code file(s) with no matching test files",
|
|
130
|
+
"remediation": f"Add test coverage for the tagged files: {', '.join(code_only[:3])}"
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
# Check C: epic-orphan
|
|
134
|
+
if EPICS_DIR.exists():
|
|
135
|
+
for epic_dir in sorted(EPICS_DIR.iterdir()):
|
|
136
|
+
if not epic_dir.is_dir() or epic_dir.name == "archive":
|
|
137
|
+
continue
|
|
138
|
+
epic_yaml = epic_dir / "epic.yaml"
|
|
139
|
+
if not epic_yaml.exists():
|
|
140
|
+
continue
|
|
141
|
+
for task_file in sorted(epic_dir.glob("e*[0-9]-tasks.yaml")):
|
|
142
|
+
sid_match = re.match(r"(e\d+s\d+)", task_file.name)
|
|
143
|
+
if not sid_match:
|
|
144
|
+
continue
|
|
145
|
+
sid = sid_match.group(1)
|
|
146
|
+
sstatus = dev_status.get(sid, "backlog")
|
|
147
|
+
if sstatus in ("done", "active") and sid not in tagged_files:
|
|
148
|
+
findings.append({
|
|
149
|
+
"check": "epic-orphan",
|
|
150
|
+
"story_id": sid,
|
|
151
|
+
"severity": "LOW",
|
|
152
|
+
"description": f"Story {sid} has capsule tasks but no story tags found in any code file",
|
|
153
|
+
"remediation": f"Add `// story: {sid}` tags to implementing files"
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
# Check D: stale-tag
|
|
157
|
+
stale_from_matrix: set[str] = set()
|
|
158
|
+
if matrix_data:
|
|
159
|
+
stale_from_matrix = set(matrix_data.get("summary", {}).get("stale_tags", []))
|
|
160
|
+
|
|
161
|
+
for sid in stale_from_matrix:
|
|
162
|
+
findings.append({
|
|
163
|
+
"check": "stale-tag",
|
|
164
|
+
"story_id": sid,
|
|
165
|
+
"severity": "LOW",
|
|
166
|
+
"description": f"Story {sid} is marked done but still has story tags in code",
|
|
167
|
+
"remediation": f"Remove `// story: {sid}` tags from files or confirm the story should remain done"
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
# Check E: double-tag
|
|
171
|
+
for fpath, sids in file_story_counts.items():
|
|
172
|
+
if len(sids) > 1:
|
|
173
|
+
findings.append({
|
|
174
|
+
"check": "double-tag",
|
|
175
|
+
"file": fpath,
|
|
176
|
+
"severity": "MEDIUM",
|
|
177
|
+
"description": f"File '{fpath}' is tagged with multiple stories: {', '.join(sorted(sids))}",
|
|
178
|
+
"remediation": "Review whether this file genuinely implements multiple stories. If it's a shared utility, this is acceptable."
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
# Check F: bootstrap-testless
|
|
182
|
+
for sid in sorted(dev_status.keys()):
|
|
183
|
+
if dev_status[sid] in ("active",):
|
|
184
|
+
if sid in tagged_files:
|
|
185
|
+
code_files = tagged_files[sid]
|
|
186
|
+
code_only = [f for f in code_files if Path(f).suffix in {".sh", ".py", ".js", ".ts", ".jsx", ".tsx"} and "SKILL.md" not in f]
|
|
187
|
+
if code_only and not has_test_files(code_files):
|
|
188
|
+
findings.append({
|
|
189
|
+
"check": "bootstrap-testless",
|
|
190
|
+
"story_id": sid,
|
|
191
|
+
"severity": "HIGH",
|
|
192
|
+
"description": f"Active story {sid} has {len(code_only)} tagged code file(s) but no test files detected",
|
|
193
|
+
"remediation": f"Add test files for: {', '.join(code_only[:3])}"
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
# Deduplicate
|
|
197
|
+
seen: set[tuple] = set()
|
|
198
|
+
deduped: list[dict] = []
|
|
199
|
+
for f in findings:
|
|
200
|
+
key = (f["check"], f.get("story_id", f.get("file", "")))
|
|
201
|
+
if key not in seen:
|
|
202
|
+
seen.add(key)
|
|
203
|
+
deduped.append(f)
|
|
204
|
+
findings = deduped
|
|
205
|
+
|
|
206
|
+
# Emit blind-spots.json
|
|
207
|
+
high_count = sum(1 for f in findings if f["severity"] == "HIGH")
|
|
208
|
+
medium_count = sum(1 for f in findings if f["severity"] == "MEDIUM")
|
|
209
|
+
low_count = sum(1 for f in findings if f["severity"] == "LOW")
|
|
210
|
+
|
|
211
|
+
blind_spots = {
|
|
212
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
213
|
+
"version": "1.0",
|
|
214
|
+
"summary": {
|
|
215
|
+
"total_findings": len(findings),
|
|
216
|
+
"high": high_count,
|
|
217
|
+
"medium": medium_count,
|
|
218
|
+
"low": low_count
|
|
219
|
+
},
|
|
220
|
+
"findings": findings
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
BLIND_SPOTS_JSON.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
BLIND_SPOTS_JSON.write_text(json.dumps(blind_spots, indent=2), encoding="utf-8")
|
|
225
|
+
|
|
226
|
+
print(f"check-blind-spots.sh: {len(findings)} findings ({high_count} HIGH, {medium_count} MEDIUM, {low_count} LOW)")
|
|
227
|
+
for f in findings:
|
|
228
|
+
print(f" [{f['severity']}] {f['check']}: {f['description'][:100]}")
|
|
229
|
+
|
|
230
|
+
if high_count > 0:
|
|
231
|
+
print(f"\n⚠️ {high_count} HIGH-severity finding(s) — review specs/blind-spots.json")
|
|
232
|
+
sys.exit(1)
|
|
233
|
+
|
|
234
|
+
sys.exit(0)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# story: e40s04
|
|
3
|
+
# git-hours.sh — deterministic, additive git-hours effort engine.
|
|
4
|
+
# Source this file in scripts that compute effort from commit history.
|
|
5
|
+
# Requires: IDLE_SECONDS, PAD_SECONDS, ROOT set before sourcing.
|
|
6
|
+
#
|
|
7
|
+
# Model (kimmobrunfeldt/git-hours):
|
|
8
|
+
# * commits sorted by author-date across the WHOLE range;
|
|
9
|
+
# * gap < IDLE -> real elapsed time, credited to later commit's story;
|
|
10
|
+
# * gap >= IDLE -> session boundary: credit PAD to later commit's story;
|
|
11
|
+
# * first commit never padded.
|
|
12
|
+
#
|
|
13
|
+
# partition() emits per-story TSV: story \t effort_hours \t commits \t first_epoch \t last_epoch
|
|
14
|
+
# whole_range_hours() emits a single float: whole-range effort (additivity oracle)
|
|
15
|
+
|
|
16
|
+
partition() {
|
|
17
|
+
local range="$1" key="$2"
|
|
18
|
+
git -C "$ROOT" log --no-merges "$range" \
|
|
19
|
+
--pretty=format:"%at%x1f%H%x1f%(trailers:key=${key},valueonly,separator=%x2C)" \
|
|
20
|
+
| sort -n \
|
|
21
|
+
| awk -F '\x1f' -v IDLE="$IDLE_SECONDS" -v PAD="$PAD_SECONDS" '
|
|
22
|
+
{
|
|
23
|
+
ts = $1 + 0; story = $3
|
|
24
|
+
gsub(/[[:space:]]/, "", story)
|
|
25
|
+
if (story == "") story = "unattributed"
|
|
26
|
+
count[story]++; seen[story] = 1
|
|
27
|
+
if (NR > 1) {
|
|
28
|
+
gap = ts - prev
|
|
29
|
+
if (gap < 0) gap = 0
|
|
30
|
+
if (gap < IDLE) eff[story] += gap
|
|
31
|
+
else eff[story] += PAD
|
|
32
|
+
}
|
|
33
|
+
if (!(story in first)) first[story] = ts
|
|
34
|
+
last[story] = ts; prev = ts
|
|
35
|
+
}
|
|
36
|
+
END {
|
|
37
|
+
for (s in seen)
|
|
38
|
+
printf "%s\t%.4f\t%d\t%d\t%d\n", s, eff[s] / 3600.0, count[s], first[s], last[s]
|
|
39
|
+
}' \
|
|
40
|
+
| sort
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
whole_range_hours() {
|
|
44
|
+
local range="$1"
|
|
45
|
+
git -C "$ROOT" log --no-merges "$range" --pretty=format:"%at" \
|
|
46
|
+
| sort -n \
|
|
47
|
+
| awk -v IDLE="$IDLE_SECONDS" -v PAD="$PAD_SECONDS" '
|
|
48
|
+
{ ts=$1+0; if(NR>1){gap=ts-prev; if(gap<0)gap=0; if(gap<IDLE)e+=gap; else e+=PAD} prev=ts }
|
|
49
|
+
END { printf "%.4f\n", e/3600.0 }'
|
|
50
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""story: e38s01 — deterministic spec-to-code coverage matrix builder (Python engine).
|
|
3
|
+
|
|
4
|
+
Parses release-plan.yaml + execution-status.yaml, greps codebase for story tags,
|
|
5
|
+
builds oracle-tiered coverage matrix, emits JSON + markdown + OKF wiki.
|
|
6
|
+
|
|
7
|
+
Usage: called by scripts/trace-stories.sh with positional args:
|
|
8
|
+
python3 scripts/lib/trace-stories.py <repo_root> <matrix_json> <trace_md>
|
|
9
|
+
<okf_dir> <strict> <mode>
|
|
10
|
+
|
|
11
|
+
Oracle tiers (TEA-inspired):
|
|
12
|
+
Tier 1: explicit story tag → confidence high
|
|
13
|
+
Tier 2: file-name heuristic match → confidence medium
|
|
14
|
+
Tier 3: epic capsule task reference → confidence low
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json, os, re, subprocess, sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
|
|
21
|
+
ROOT = Path(sys.argv[1])
|
|
22
|
+
MATRIX_JSON = Path(sys.argv[2])
|
|
23
|
+
TRACE_MD = Path(sys.argv[3])
|
|
24
|
+
OKF_DIR = Path(sys.argv[4])
|
|
25
|
+
STRICT = int(sys.argv[5])
|
|
26
|
+
MODE = sys.argv[6]
|
|
27
|
+
|
|
28
|
+
# -----------------------------------------------------------------------
|
|
29
|
+
# 1. Parse release-plan.yaml → story inventory
|
|
30
|
+
# -----------------------------------------------------------------------
|
|
31
|
+
def parse_simple_yaml(text: str) -> dict:
|
|
32
|
+
"""Parse flat and one-level-nested YAML including lists of objects."""
|
|
33
|
+
root: dict = {}
|
|
34
|
+
stack: list = [(0, root, None, None)]
|
|
35
|
+
skip_until_indent = None
|
|
36
|
+
for i, raw in enumerate(text.splitlines()):
|
|
37
|
+
stripped = raw.strip()
|
|
38
|
+
if not stripped or stripped.startswith("#"):
|
|
39
|
+
continue
|
|
40
|
+
indent = len(raw) - len(raw.lstrip())
|
|
41
|
+
if skip_until_indent is not None:
|
|
42
|
+
if indent > skip_until_indent:
|
|
43
|
+
continue
|
|
44
|
+
else:
|
|
45
|
+
skip_until_indent = None
|
|
46
|
+
while len(stack) > 1:
|
|
47
|
+
if indent < stack[-1][0]:
|
|
48
|
+
stack.pop()
|
|
49
|
+
elif indent == stack[-1][0]:
|
|
50
|
+
if isinstance(stack[-1][1], list) and stripped.startswith("- "):
|
|
51
|
+
break
|
|
52
|
+
elif isinstance(stack[-1][1], dict) and stripped.startswith("- "):
|
|
53
|
+
entry_container = stack[-1][1]
|
|
54
|
+
entry_parent = stack[-1][2]
|
|
55
|
+
entry_key = stack[-1][3]
|
|
56
|
+
if entry_parent is not None and entry_key is not None:
|
|
57
|
+
if not isinstance(entry_parent.get(entry_key), list):
|
|
58
|
+
entry_parent[entry_key] = []
|
|
59
|
+
stack[-1] = (stack[-1][0], entry_parent[entry_key], entry_parent, entry_key)
|
|
60
|
+
break
|
|
61
|
+
stack.pop()
|
|
62
|
+
else:
|
|
63
|
+
stack.pop()
|
|
64
|
+
else:
|
|
65
|
+
break
|
|
66
|
+
container = stack[-1][1]
|
|
67
|
+
parent_dict = stack[-1][2]
|
|
68
|
+
key_in_parent = stack[-1][3]
|
|
69
|
+
if stripped.startswith("- "):
|
|
70
|
+
inner = stripped[2:]
|
|
71
|
+
if isinstance(container, dict) and parent_dict is not None and key_in_parent is not None:
|
|
72
|
+
if not isinstance(parent_dict.get(key_in_parent), list):
|
|
73
|
+
orig_indent = stack[-1][0]
|
|
74
|
+
parent_dict[key_in_parent] = []
|
|
75
|
+
container = parent_dict[key_in_parent]
|
|
76
|
+
stack[-1] = (orig_indent, container, parent_dict, key_in_parent)
|
|
77
|
+
if ":" in inner:
|
|
78
|
+
k, _, v = inner.partition(":")
|
|
79
|
+
k = k.strip(); v = v.strip()
|
|
80
|
+
if v in ("|", ">", "|-", ">-", "|+", ">+"):
|
|
81
|
+
item = {k: v}; container.append(item)
|
|
82
|
+
skip_until_indent = indent; continue
|
|
83
|
+
item = {k: _yaml_scalar(v) if v else None}
|
|
84
|
+
container.append(item)
|
|
85
|
+
if v == "":
|
|
86
|
+
stack.append((indent, item, container, k))
|
|
87
|
+
else:
|
|
88
|
+
container.append(_yaml_scalar(inner))
|
|
89
|
+
continue
|
|
90
|
+
if ":" not in stripped:
|
|
91
|
+
continue
|
|
92
|
+
key, _, val = stripped.partition(":")
|
|
93
|
+
key = key.strip(); val = val.strip()
|
|
94
|
+
if val in ("|", ">", "|-", ">-", "|+", ">+"):
|
|
95
|
+
if isinstance(container, list) and container:
|
|
96
|
+
container[-1][key] = val
|
|
97
|
+
elif isinstance(container, dict):
|
|
98
|
+
container[key] = val
|
|
99
|
+
skip_until_indent = indent; continue
|
|
100
|
+
if isinstance(container, list) and container:
|
|
101
|
+
container[-1][key] = _yaml_scalar(val) if val else None
|
|
102
|
+
if val == "":
|
|
103
|
+
nxt = {}; container[-1][key] = nxt
|
|
104
|
+
stack.append((indent, nxt, container[-1], key))
|
|
105
|
+
elif isinstance(container, dict):
|
|
106
|
+
if val == "":
|
|
107
|
+
nxt = {}; container[key] = nxt
|
|
108
|
+
stack.append((indent, nxt, container, key))
|
|
109
|
+
else:
|
|
110
|
+
container[key] = _yaml_scalar(val)
|
|
111
|
+
return root
|
|
112
|
+
|
|
113
|
+
def _yaml_scalar(val: str):
|
|
114
|
+
val = val.strip('"').strip("'")
|
|
115
|
+
if val in ("true", "false"): return val == "true"
|
|
116
|
+
if val in ("null", "~", ""): return None
|
|
117
|
+
try: return int(val)
|
|
118
|
+
except ValueError:
|
|
119
|
+
try: return float(val)
|
|
120
|
+
except ValueError: return val
|
|
121
|
+
|
|
122
|
+
release_text = (ROOT / "specs" / "release-plan.yaml").read_text(encoding="utf-8")
|
|
123
|
+
release = parse_simple_yaml(release_text)
|
|
124
|
+
exec_text = (ROOT / "specs" / "execution-status.yaml").read_text(encoding="utf-8")
|
|
125
|
+
exec_status = parse_simple_yaml(exec_text)
|
|
126
|
+
dev_status = exec_status.get("development_status", {})
|
|
127
|
+
|
|
128
|
+
# -----------------------------------------------------------------------
|
|
129
|
+
# 2. Build story inventory
|
|
130
|
+
# -----------------------------------------------------------------------
|
|
131
|
+
stories: dict[str, dict] = {}
|
|
132
|
+
epics = release.get("epics", [])
|
|
133
|
+
if not isinstance(epics, list):
|
|
134
|
+
epics = []
|
|
135
|
+
for epic in epics:
|
|
136
|
+
if not isinstance(epic, dict):
|
|
137
|
+
continue
|
|
138
|
+
eid = epic.get("id", ""); ebcp = epic.get("bcps", 0)
|
|
139
|
+
etitle = epic.get("title", ""); ewsjf = epic.get("wsjf", 0)
|
|
140
|
+
capsule_dir = epic.get("capsule_dir", "")
|
|
141
|
+
if capsule_dir:
|
|
142
|
+
capsule_path = ROOT / "specs" / capsule_dir / "epic.yaml"
|
|
143
|
+
if capsule_path.exists():
|
|
144
|
+
cap = parse_simple_yaml(capsule_path.read_text(encoding="utf-8"))
|
|
145
|
+
for s in cap.get("stories", []) or []:
|
|
146
|
+
if isinstance(s, dict):
|
|
147
|
+
sid = s.get("id", "")
|
|
148
|
+
stories[sid] = {"id": sid, "title": s.get("title", ""),
|
|
149
|
+
"epic_id": eid, "epic_title": etitle,
|
|
150
|
+
"bcp": s.get("bcp", 0), "wsjf": float(ewsjf),
|
|
151
|
+
"description": s.get("description", "")}
|
|
152
|
+
file_key = epic.get("file", "")
|
|
153
|
+
if file_key and not capsule_dir:
|
|
154
|
+
legacy_path = ROOT / "specs" / file_key
|
|
155
|
+
if legacy_path.exists():
|
|
156
|
+
leg = parse_simple_yaml(legacy_path.read_text(encoding="utf-8"))
|
|
157
|
+
for s in leg.get("stories", []) or []:
|
|
158
|
+
if isinstance(s, dict):
|
|
159
|
+
sid = s.get("id", "")
|
|
160
|
+
stories[sid] = {"id": sid, "title": s.get("title", ""),
|
|
161
|
+
"epic_id": eid, "epic_title": etitle,
|
|
162
|
+
"bcp": s.get("bcp", 0), "wsjf": float(ewsjf),
|
|
163
|
+
"description": s.get("description", "")}
|
|
164
|
+
|
|
165
|
+
# -----------------------------------------------------------------------
|
|
166
|
+
# 3. Grep codebase for story tags
|
|
167
|
+
# -----------------------------------------------------------------------
|
|
168
|
+
result = subprocess.run(
|
|
169
|
+
["grep", "-rn", "--include=*.md", "--include=*.sh", "--include=*.py",
|
|
170
|
+
"--include=*.js", "--include=*.ts", "--include=*.yaml", "--include=*.yml",
|
|
171
|
+
"-E", r"story:\s*e\d{2}s\d{2}", str(ROOT)],
|
|
172
|
+
capture_output=True, text=True, cwd=str(ROOT))
|
|
173
|
+
|
|
174
|
+
tag_inventory: list[dict] = []
|
|
175
|
+
tagged_sids: set[str] = set()
|
|
176
|
+
tag_index: dict[str, list] = {}
|
|
177
|
+
for line in result.stdout.splitlines():
|
|
178
|
+
m = re.match(r"^(.+?):(\d+):(.*story:\s*(e\d{2}s\d{2}).*)$", line)
|
|
179
|
+
if m:
|
|
180
|
+
fpath = m.group(1); fline = int(m.group(2)); sid = m.group(4)
|
|
181
|
+
try: fpath = str(Path(fpath).relative_to(ROOT))
|
|
182
|
+
except ValueError: pass
|
|
183
|
+
tag_inventory.append({"file": fpath, "line": fline, "story_id": sid})
|
|
184
|
+
tagged_sids.add(sid)
|
|
185
|
+
tag_index.setdefault(sid, []).append({"file": fpath, "line": fline})
|
|
186
|
+
|
|
187
|
+
# -----------------------------------------------------------------------
|
|
188
|
+
# 4. Oracle tiers
|
|
189
|
+
# -----------------------------------------------------------------------
|
|
190
|
+
all_files = []
|
|
191
|
+
for root_dir, dirs, files in os.walk(str(ROOT)):
|
|
192
|
+
dirs[:] = [d for d in dirs if d not in (".git", "node_modules", ".cursor")]
|
|
193
|
+
for f in files:
|
|
194
|
+
all_files.append(str(Path(root_dir, f).relative_to(ROOT)))
|
|
195
|
+
|
|
196
|
+
def slugify(text: str) -> str:
|
|
197
|
+
text = re.sub(r"[^a-zA-Z0-9\s-]", "", text.lower())
|
|
198
|
+
return re.sub(r"\s+", "-", text.strip())
|
|
199
|
+
|
|
200
|
+
def heuristic_match(story_title: str, file_path: str) -> bool:
|
|
201
|
+
slug = slugify(story_title)
|
|
202
|
+
words = slug.split("-")
|
|
203
|
+
fname = Path(file_path).stem.lower()
|
|
204
|
+
sig_words = [w for w in words if len(w) > 2]
|
|
205
|
+
if len(sig_words) < 2: sig_words = words
|
|
206
|
+
matches = sum(1 for w in sig_words if w in fname)
|
|
207
|
+
return matches >= min(2, len(sig_words))
|
|
208
|
+
|
|
209
|
+
def find_task_references(story_id: str) -> list[dict]:
|
|
210
|
+
refs = []
|
|
211
|
+
epics_dir = ROOT / "specs" / "epics"
|
|
212
|
+
if not epics_dir.exists(): return refs
|
|
213
|
+
for task_yaml in epics_dir.rglob("*tasks.yaml"):
|
|
214
|
+
try:
|
|
215
|
+
content = task_yaml.read_text(encoding="utf-8")
|
|
216
|
+
if f"story_id: {story_id}" in content or story_id in content:
|
|
217
|
+
refs.append({"file": str(task_yaml.relative_to(ROOT)), "type": "task_yaml"})
|
|
218
|
+
except Exception: pass
|
|
219
|
+
return refs
|
|
220
|
+
|
|
221
|
+
matrix_stories = []
|
|
222
|
+
dark_stories = []
|
|
223
|
+
for sid, sinfo in sorted(stories.items()):
|
|
224
|
+
links = []
|
|
225
|
+
sid_status = dev_status.get(sid, "backlog")
|
|
226
|
+
if sid in tag_index:
|
|
227
|
+
for t in tag_index[sid]:
|
|
228
|
+
links.append({"file": t["file"], "line": t["line"], "confidence": "high", "method": "explicit_tag"})
|
|
229
|
+
for fpath in all_files:
|
|
230
|
+
if heuristic_match(sinfo["title"], fpath):
|
|
231
|
+
if not any(l["file"] == fpath for l in links):
|
|
232
|
+
links.append({"file": fpath, "line": 0, "confidence": "medium", "method": "file_heuristic"})
|
|
233
|
+
for tr in find_task_references(sid):
|
|
234
|
+
existing = {l["file"] for l in links}
|
|
235
|
+
if tr["file"] not in existing:
|
|
236
|
+
links.append({"file": tr["file"], "line": 0, "confidence": "low", "method": "task_reference"})
|
|
237
|
+
matrix_stories.append({
|
|
238
|
+
"id": sid, "title": sinfo["title"], "epic_id": sinfo["epic_id"],
|
|
239
|
+
"epic_title": sinfo["epic_title"], "bcp": sinfo["bcp"], "wsjf": sinfo["wsjf"],
|
|
240
|
+
"status": sid_status, "links": links, "link_count": len(links)
|
|
241
|
+
})
|
|
242
|
+
if len(links) == 0 and sid_status != "backlog":
|
|
243
|
+
dark_stories.append(sid)
|
|
244
|
+
|
|
245
|
+
orphan_tags = [sid for sid in sorted(tagged_sids) if sid not in stories]
|
|
246
|
+
stale_tags = [sid for sid in sorted(tagged_sids) if sid in stories and dev_status.get(sid) == "done"]
|
|
247
|
+
|
|
248
|
+
# -----------------------------------------------------------------------
|
|
249
|
+
# 5. Emit matrix JSON
|
|
250
|
+
# -----------------------------------------------------------------------
|
|
251
|
+
matrix = {
|
|
252
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
253
|
+
"matrix_version": "1.0",
|
|
254
|
+
"stories": matrix_stories,
|
|
255
|
+
"summary": {
|
|
256
|
+
"total_stories": len(stories),
|
|
257
|
+
"tagged_stories": len(tagged_sids & set(stories.keys())),
|
|
258
|
+
"dark_stories": dark_stories, "dark_count": len(dark_stories),
|
|
259
|
+
"orphan_tags": orphan_tags, "orphan_count": len(orphan_tags),
|
|
260
|
+
"stale_tags": stale_tags, "stale_count": len(stale_tags),
|
|
261
|
+
"oracle_stats": {
|
|
262
|
+
"high": sum(1 for s in matrix_stories for l in s["links"] if l["confidence"] == "high"),
|
|
263
|
+
"medium": sum(1 for s in matrix_stories for l in s["links"] if l["confidence"] == "medium"),
|
|
264
|
+
"low": sum(1 for s in matrix_stories for l in s["links"] if l["confidence"] == "low")
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
MATRIX_JSON.parent.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
MATRIX_JSON.write_text(json.dumps(matrix, indent=2), encoding="utf-8")
|
|
270
|
+
|
|
271
|
+
# -----------------------------------------------------------------------
|
|
272
|
+
# 6. Emit TRACEABILITY_LATEST.md
|
|
273
|
+
# -----------------------------------------------------------------------
|
|
274
|
+
lines = ["# Traceability Matrix", "",
|
|
275
|
+
f"**Generated:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
|
|
276
|
+
f"**Total stories:** {len(stories)}",
|
|
277
|
+
f"**Tagged stories:** {len(tagged_sids & set(stories.keys()))}",
|
|
278
|
+
f"**Dark stories:** {len(dark_stories)}",
|
|
279
|
+
f"**Orphan tags:** {len(orphan_tags)}",
|
|
280
|
+
f"**Stale tags:** {len(stale_tags)}",
|
|
281
|
+
"", "## Oracle Stats", "",
|
|
282
|
+
f"- **High** (explicit tag): {matrix['summary']['oracle_stats']['high']}",
|
|
283
|
+
f"- **Medium** (file heuristic): {matrix['summary']['oracle_stats']['medium']}",
|
|
284
|
+
f"- **Low** (task reference): {matrix['summary']['oracle_stats']['low']}",
|
|
285
|
+
"", "## Story Coverage", "",
|
|
286
|
+
"| Story | Title | Epic | BCP | WSJF | Status | Links |",
|
|
287
|
+
"|-------|-------|------|-----|------|--------|-------|"]
|
|
288
|
+
for s in matrix_stories:
|
|
289
|
+
lines.append(f"| {s['id']} | {s['title'][:60]} | {s['epic_id']} | {s['bcp']} | {s['wsjf']} | {s['status']} | {s['link_count']} |")
|
|
290
|
+
lines.append("")
|
|
291
|
+
if dark_stories:
|
|
292
|
+
lines.append("## Dark Stories (no code links)\n")
|
|
293
|
+
for ds in dark_stories:
|
|
294
|
+
si = stories.get(ds, {})
|
|
295
|
+
lines.append(f"- **{ds}**: {si.get('title', 'Unknown')} (status: {dev_status.get(ds, '?')})")
|
|
296
|
+
lines.append("")
|
|
297
|
+
if orphan_tags:
|
|
298
|
+
lines.append("## Orphan Tags (tag in code, no matching story)\n")
|
|
299
|
+
for ot in orphan_tags: lines.append(f"- `{ot}`")
|
|
300
|
+
lines.append("")
|
|
301
|
+
if stale_tags:
|
|
302
|
+
lines.append("## Stale Tags (story done, tag still in code)\n")
|
|
303
|
+
for st in stale_tags: lines.append(f"- `{st}`")
|
|
304
|
+
lines.append("")
|
|
305
|
+
TRACE_MD.parent.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
TRACE_MD.write_text("\n".join(lines), encoding="utf-8")
|
|
307
|
+
|
|
308
|
+
# -----------------------------------------------------------------------
|
|
309
|
+
# 7. Emit OKF bundle (specs/codebase-wiki/)
|
|
310
|
+
# -----------------------------------------------------------------------
|
|
311
|
+
OKF_DIR.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
idx_lines = ["---", "type: Index",
|
|
313
|
+
f"generated_at: {datetime.now(timezone.utc).isoformat()}",
|
|
314
|
+
f"total_concepts: {len(stories)}", "---", "",
|
|
315
|
+
"# Codebase Wiki — Story Traceability", "",
|
|
316
|
+
"Auto-generated OKF bundle from trace-stories.sh.", "",
|
|
317
|
+
"| Story | Title | Confidence | Links |",
|
|
318
|
+
"|-------|-------|------------|-------|"]
|
|
319
|
+
for s in matrix_stories:
|
|
320
|
+
confs = {l["confidence"] for l in s["links"]}
|
|
321
|
+
mc = "high" if "high" in confs else ("medium" if "medium" in confs else ("low" if confs else "none"))
|
|
322
|
+
idx_lines.append(f"| [{s['id']}](./{s['id']}.md) | {s['title'][:60]} | {mc} | {s['link_count']} |")
|
|
323
|
+
idx_lines.append("")
|
|
324
|
+
(OKF_DIR / "index.md").write_text("\n".join(idx_lines), encoding="utf-8")
|
|
325
|
+
for s in matrix_stories:
|
|
326
|
+
concept = ["---", f"type: Story", f"id: {s['id']}", f"epic: {s['epic_id']}",
|
|
327
|
+
f"bcps: {s['bcp']}", f"wsjf: {s['wsjf']}",
|
|
328
|
+
f"implementation_status: {s['status']}"]
|
|
329
|
+
max_confs = {l["confidence"] for l in s["links"]}
|
|
330
|
+
conf = "high" if "high" in max_confs else ("medium" if "medium" in max_confs else ("low" if max_confs else "none"))
|
|
331
|
+
concept.extend([f"coverage_status: {'covered' if s['link_count'] > 0 else 'dark'}",
|
|
332
|
+
f"confidence: {conf}", "links:"])
|
|
333
|
+
for l in s["links"]:
|
|
334
|
+
concept.extend([f" - file: {l['file']}", f" line: {l['line']}",
|
|
335
|
+
f" confidence: {l['confidence']}", f" method: {l['method']}"])
|
|
336
|
+
concept.extend(["---", "", f"# {s['id']}: {s['title']}", "",
|
|
337
|
+
f"**Epic:** {s['epic_id']} — {s.get('epic_title', 'Unknown')}",
|
|
338
|
+
f"**Status:** {s['status']}",
|
|
339
|
+
f"**BCP:** {s['bcp']} | **WSJF:** {s['wsjf']}", ""])
|
|
340
|
+
if s["links"]:
|
|
341
|
+
concept.append("## Implemented In\n")
|
|
342
|
+
for l in s["links"]:
|
|
343
|
+
concept.append(f"- `{l['file']}` (line {l['line']}, confidence: {l['confidence']}, method: {l['method']})")
|
|
344
|
+
else:
|
|
345
|
+
concept.append("*No code links found — this is a dark story.*")
|
|
346
|
+
concept.append("")
|
|
347
|
+
(OKF_DIR / f"{s['id']}.md").write_text("\n".join(concept), encoding="utf-8")
|
|
348
|
+
|
|
349
|
+
# -----------------------------------------------------------------------
|
|
350
|
+
# 8. Strict mode
|
|
351
|
+
# -----------------------------------------------------------------------
|
|
352
|
+
if STRICT:
|
|
353
|
+
if matrix_stories:
|
|
354
|
+
wsjf_sorted = sorted(set(s["wsjf"] for s in matrix_stories), reverse=True)
|
|
355
|
+
cutoff_idx = max(0, len(wsjf_sorted) // 4 - 1)
|
|
356
|
+
p0_threshold = wsjf_sorted[cutoff_idx] if cutoff_idx < len(wsjf_sorted) else 0
|
|
357
|
+
uncovered_p0 = [s["id"] for s in matrix_stories if s["wsjf"] >= p0_threshold and len(s["links"]) == 0]
|
|
358
|
+
if uncovered_p0:
|
|
359
|
+
print(f"trace-stories.sh: STRICT FAIL — P0 stories with 0% coverage: {', '.join(uncovered_p0)}", file=sys.stderr)
|
|
360
|
+
sys.exit(2)
|
|
361
|
+
|
|
362
|
+
sys.exit(0)
|