@massa-ai/cursor-plugin 1.21.0 → 1.22.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/.cursor-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/massa-ai/references/evidence-gate.md +1 -1
- package/skills/massa-ai/references/hook-enforcement.md +2 -2
- package/skills/massa-ai/references/implementation-delivery.md +3 -3
- package/skills/massa-ai/references/lessons.md +9 -10
- package/skills/massa-ai/references/mcp-tools.md +1 -1
- package/skills/massa-ai/references/project-context.md +1 -1
- package/skills/massa-ai/references/spec-driven/artifact-store.md +7 -8
- package/skills/massa-ai/references/spec-driven/design.md +1 -1
- package/skills/massa-ai/references/spec-driven/execute.md +5 -5
- package/skills/massa-ai/references/spec-driven/specify.md +6 -6
- package/skills/massa-ai/references/spec-driven/sub-agents.md +1 -1
- package/skills/massa-ai/references/spec-driven/tasks.md +2 -2
- package/skills/massa-ai/references/spec-driven/validate.md +3 -3
- package/skills/massa-ai/scripts/check_commit.ts +231 -0
- package/skills/massa-ai/scripts/check_specs_delivered.ts +209 -0
- package/skills/massa-ai/scripts/lessons.ts +907 -0
- package/skills/massa-ai/scripts/validate_spec.ts +413 -0
- package/skills/massa-ai/scripts/validate_state.ts +276 -0
- package/skills/massa-ai/scripts/validate_tasks.ts +498 -0
- package/skills/massa-ai/workflows/architecture/architecture-fix.md +1 -1
- package/skills/massa-ai/workflows/bugs/bugs-fix.md +1 -1
- package/skills/massa-ai/workflows/code-quality/code-quality-fix.md +1 -1
- package/skills/massa-ai/workflows/debug.md +1 -1
- package/skills/massa-ai/workflows/feature.md +1 -1
- package/skills/massa-ai/workflows/general.md +2 -2
- package/skills/massa-ai/workflows/implementation/implementation-fix.md +1 -1
- package/skills/massa-ai/workflows/maestro/maestro-fix.md +1 -1
- package/skills/massa-ai/workflows/mobile-figma/mobile-figma-fix.md +1 -1
- package/skills/massa-ai/workflows/refactor.md +1 -1
- package/skills/massa-ai/workflows/requirements/requirements-fix.md +1 -1
- package/skills/massa-ai/workflows/security/security-fix.md +1 -1
- package/skills/massa-ai/workflows/spec-driven.md +3 -3
- package/skills/massa-ai/workflows/tests/tests-fix.md +1 -1
- package/skills/massa-ai/scripts/check_commit.py +0 -128
- package/skills/massa-ai/scripts/check_specs_delivered.py +0 -137
- package/skills/massa-ai/scripts/lessons.py +0 -630
- package/skills/massa-ai/scripts/validate_spec.py +0 -272
- package/skills/massa-ai/scripts/validate_state.py +0 -183
- package/skills/massa-ai/scripts/validate_tasks.py +0 -302
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
validate_spec.py - deterministic closure-gate checks for a feature spec.md.
|
|
4
|
-
|
|
5
|
-
Turns the Requirement Closure Gate (Specify phase) into a checkable pass/fail
|
|
6
|
-
run BEFORE a spec is presented for confirmation, instead of trusting the model
|
|
7
|
-
to remember the checks. Pure standard library, zero dependencies. Operates only
|
|
8
|
-
on the spec.md markdown artifact - never on the target codebase - so it stays
|
|
9
|
-
stack-agnostic and tool-agnostic.
|
|
10
|
-
|
|
11
|
-
What it checks (heuristic markdown inspection, not a full parser):
|
|
12
|
-
ERROR - a required section is missing
|
|
13
|
-
ERROR - an acceptance criterion has no SHALL (not testable / not EARS-shaped)
|
|
14
|
-
ERROR - an Assumptions row has an empty "Chosen default" or "Rationale" cell
|
|
15
|
-
ERROR - a Requirement Traceability row has a malformed ID
|
|
16
|
-
WARN - an AC has SHALL but no recognizable EARS lead keyword
|
|
17
|
-
WARN - template placeholder rows are still present (spec not filled in)
|
|
18
|
-
WARN - open questions are not explicitly resolved
|
|
19
|
-
|
|
20
|
-
Usage:
|
|
21
|
-
python3 skills/massa-ai/scripts/validate_spec.py [target] [--root DIR] [--strict]
|
|
22
|
-
|
|
23
|
-
Invoke with the repo-root-relative script path shown above (matches
|
|
24
|
-
lessons.py's convention), not a project-local copy.
|
|
25
|
-
target Path to a spec.md, a feature directory, or a project root.
|
|
26
|
-
Omitted -> auto-detect the single feature under <root>/.specs/features/.
|
|
27
|
-
--root Project root that contains .specs/ (default: current dir).
|
|
28
|
-
--strict Treat warnings as errors.
|
|
29
|
-
|
|
30
|
-
Exit codes: 0 pass, 1 errors found (or warnings under --strict), 2 usage error.
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
import argparse
|
|
34
|
-
import os
|
|
35
|
-
import re
|
|
36
|
-
import sys
|
|
37
|
-
|
|
38
|
-
REQUIRED_SECTIONS = [
|
|
39
|
-
"Problem Statement",
|
|
40
|
-
"Out of Scope",
|
|
41
|
-
"Assumptions & Open Questions",
|
|
42
|
-
"User Stories",
|
|
43
|
-
"Requirement Traceability",
|
|
44
|
-
]
|
|
45
|
-
|
|
46
|
-
ID_RE = re.compile(r"^[A-Z][A-Z0-9]*-\d+$")
|
|
47
|
-
PLACEHOLDER_RE = re.compile(r"^\s*\[.+\]\s*$")
|
|
48
|
-
STATUS_VALUES = {"pending", "in design", "in tasks", "implementing", "verified"}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def resolve_spec(target, root):
|
|
52
|
-
"""Return the path to a spec.md from a file, dir, or auto-detect."""
|
|
53
|
-
if target:
|
|
54
|
-
if os.path.isfile(target):
|
|
55
|
-
return target
|
|
56
|
-
if os.path.isdir(target):
|
|
57
|
-
cand = os.path.join(target, "spec.md")
|
|
58
|
-
if os.path.isfile(cand):
|
|
59
|
-
return cand
|
|
60
|
-
# maybe it's a project root
|
|
61
|
-
return _autodetect(target)
|
|
62
|
-
# Not a path: treat as a feature name under <root>/.specs/features/<name>/
|
|
63
|
-
cand = os.path.join(root, ".specs", "features", target, "spec.md")
|
|
64
|
-
if os.path.isfile(cand):
|
|
65
|
-
return cand
|
|
66
|
-
return None
|
|
67
|
-
return _autodetect(root)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _autodetect(root):
|
|
71
|
-
base = os.path.join(root, ".specs", "features")
|
|
72
|
-
if not os.path.isdir(base):
|
|
73
|
-
return None
|
|
74
|
-
features = [
|
|
75
|
-
d for d in sorted(os.listdir(base))
|
|
76
|
-
if os.path.isfile(os.path.join(base, d, "spec.md"))
|
|
77
|
-
]
|
|
78
|
-
if len(features) == 1:
|
|
79
|
-
return os.path.join(base, features[0], "spec.md")
|
|
80
|
-
if len(features) == 0:
|
|
81
|
-
return None
|
|
82
|
-
# Ambiguous: signal the caller with the list.
|
|
83
|
-
raise SystemExit(
|
|
84
|
-
"validate_spec: multiple features found; pass one explicitly:\n "
|
|
85
|
-
+ "\n ".join(os.path.join(base, f, "spec.md") for f in features)
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def split_row(line):
|
|
90
|
-
cells = line.strip().strip("|").split("|")
|
|
91
|
-
return [c.strip() for c in cells]
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def is_separator(line):
|
|
95
|
-
return bool(re.match(r"^\s*\|?[\s:|-]+\|?\s*$", line)) and "-" in line
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def section_bounds(lines, name):
|
|
99
|
-
"""Return (start, end) line indices for a `## name` section body."""
|
|
100
|
-
start = None
|
|
101
|
-
for i, ln in enumerate(lines):
|
|
102
|
-
if re.match(r"^#{1,3}\s+" + re.escape(name) + r"\s*$", ln.strip()):
|
|
103
|
-
start = i + 1
|
|
104
|
-
break
|
|
105
|
-
if start is None:
|
|
106
|
-
return None
|
|
107
|
-
end = len(lines)
|
|
108
|
-
for j in range(start, len(lines)):
|
|
109
|
-
if re.match(r"^#{1,3}\s+\S", lines[j]):
|
|
110
|
-
end = j
|
|
111
|
-
break
|
|
112
|
-
return (start, end)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def classify_ears(text):
|
|
116
|
-
"""Return (ok, note). ok requires a SHALL; note records the EARS pattern."""
|
|
117
|
-
t = text.strip()
|
|
118
|
-
low = t.lower()
|
|
119
|
-
has_shall = bool(re.search(r"\bshall\b", low))
|
|
120
|
-
if not has_shall:
|
|
121
|
-
return (False, "no SHALL")
|
|
122
|
-
kws = []
|
|
123
|
-
if re.search(r"\bwhile\b", low):
|
|
124
|
-
kws.append("WHILE")
|
|
125
|
-
if re.search(r"\bwhen\b", low):
|
|
126
|
-
kws.append("WHEN")
|
|
127
|
-
if re.match(r"^\s*if\b", low) or re.search(r"\bif\b.*\bthen\b", low):
|
|
128
|
-
kws.append("IF/THEN")
|
|
129
|
-
if re.search(r"\bwhere\b", low):
|
|
130
|
-
kws.append("WHERE")
|
|
131
|
-
if len(kws) >= 2:
|
|
132
|
-
return (True, "complex (" + "+".join(kws) + ")")
|
|
133
|
-
if kws:
|
|
134
|
-
pattern = {
|
|
135
|
-
"WHILE": "state-driven",
|
|
136
|
-
"WHEN": "event-driven",
|
|
137
|
-
"IF/THEN": "unwanted-behavior",
|
|
138
|
-
"WHERE": "optional-feature",
|
|
139
|
-
}[kws[0]]
|
|
140
|
-
return (True, pattern)
|
|
141
|
-
if re.match(r"^\s*the\b", low):
|
|
142
|
-
return (True, "ubiquitous")
|
|
143
|
-
return (True, "warn: SHALL present but no EARS lead keyword")
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def check(spec_path):
|
|
147
|
-
with open(spec_path, "r", encoding="utf-8") as f:
|
|
148
|
-
text = f.read()
|
|
149
|
-
lines = text.splitlines()
|
|
150
|
-
errors, warnings = [], []
|
|
151
|
-
|
|
152
|
-
# 1. Required sections.
|
|
153
|
-
for name in REQUIRED_SECTIONS:
|
|
154
|
-
if section_bounds(lines, name) is None:
|
|
155
|
-
errors.append(f"missing required section: ## {name}")
|
|
156
|
-
|
|
157
|
-
# 2. Acceptance criteria are EARS-shaped (have a SHALL).
|
|
158
|
-
#
|
|
159
|
-
# massa-ai patch (beyond D1): upstream terminated the AC scan on the FIRST
|
|
160
|
-
# blank line after the "**Acceptance Criteria**:" header, before any item
|
|
161
|
-
# was ever read - massa-ai's (and TLC's own) template puts a blank line
|
|
162
|
-
# between the header and the numbered list, so the SHALL check was a
|
|
163
|
-
# silent no-op against every realistically-formatted spec. Track whether
|
|
164
|
-
# an item has been seen and only let a blank line end the block once it
|
|
165
|
-
# has, so leading blank lines are skipped instead of ending the scan.
|
|
166
|
-
in_ac = False
|
|
167
|
-
seen_item = False
|
|
168
|
-
for i, ln in enumerate(lines, start=1):
|
|
169
|
-
stripped = ln.strip()
|
|
170
|
-
if re.match(r"^\*{0,2}Acceptance Criteria\*{0,2}\s*:?\s*$", stripped):
|
|
171
|
-
in_ac = True
|
|
172
|
-
seen_item = False
|
|
173
|
-
continue
|
|
174
|
-
if in_ac:
|
|
175
|
-
m = re.match(r"^\s*\d+\.\s+(.*)$", ln)
|
|
176
|
-
if m:
|
|
177
|
-
seen_item = True
|
|
178
|
-
item = m.group(1).strip()
|
|
179
|
-
if PLACEHOLDER_RE.match(item):
|
|
180
|
-
continue # untouched template row
|
|
181
|
-
ok, note = classify_ears(item)
|
|
182
|
-
if not ok:
|
|
183
|
-
errors.append(f"L{i}: acceptance criterion has no SHALL (not testable): {item[:70]}")
|
|
184
|
-
elif note.startswith("warn"):
|
|
185
|
-
warnings.append(f"L{i}: AC has SHALL but no EARS keyword (WHEN/WHILE/WHERE/IF or ubiquitous 'The … shall'): {item[:60]}")
|
|
186
|
-
elif re.match(r"^#{1,3}\s", ln) or stripped.startswith("**") or (stripped == "" and seen_item):
|
|
187
|
-
in_ac = False
|
|
188
|
-
|
|
189
|
-
# 3. Assumptions table cells filled.
|
|
190
|
-
b = section_bounds(lines, "Assumptions & Open Questions")
|
|
191
|
-
if b:
|
|
192
|
-
rows = [lines[i] for i in range(*b) if lines[i].strip().startswith("|")]
|
|
193
|
-
data = [r for r in rows if not is_separator(r)]
|
|
194
|
-
# drop the header row (first table row)
|
|
195
|
-
if data:
|
|
196
|
-
data = data[1:]
|
|
197
|
-
template_seen = False
|
|
198
|
-
for r in data:
|
|
199
|
-
cells = split_row(r)
|
|
200
|
-
if len(cells) < 3:
|
|
201
|
-
continue
|
|
202
|
-
assumption, chosen, rationale = cells[0], cells[1], cells[2]
|
|
203
|
-
if PLACEHOLDER_RE.match(assumption) and PLACEHOLDER_RE.match(chosen):
|
|
204
|
-
template_seen = True
|
|
205
|
-
continue
|
|
206
|
-
if not chosen or PLACEHOLDER_RE.match(chosen):
|
|
207
|
-
errors.append(f"assumption '{assumption[:40]}' has empty 'Chosen default'")
|
|
208
|
-
if not rationale or PLACEHOLDER_RE.match(rationale):
|
|
209
|
-
errors.append(f"assumption '{assumption[:40]}' has empty 'Rationale'")
|
|
210
|
-
if template_seen:
|
|
211
|
-
warnings.append("Assumptions table still contains template placeholder rows")
|
|
212
|
-
# open questions line
|
|
213
|
-
oq = [lines[i] for i in range(*b) if "open questions" in lines[i].lower()]
|
|
214
|
-
oq_clean = re.sub(r"[*_]", "", " ".join(oq)).lower()
|
|
215
|
-
if not oq:
|
|
216
|
-
warnings.append("no 'Open questions:' line in Assumptions section")
|
|
217
|
-
elif not re.search(r"open questions.*:\s*none", oq_clean):
|
|
218
|
-
warnings.append("open questions do not read as resolved ('Open questions: none')")
|
|
219
|
-
|
|
220
|
-
# 4. Requirement traceability IDs.
|
|
221
|
-
b = section_bounds(lines, "Requirement Traceability")
|
|
222
|
-
if b:
|
|
223
|
-
rows = [lines[i] for i in range(*b) if lines[i].strip().startswith("|")]
|
|
224
|
-
data = [r for r in rows if not is_separator(r)]
|
|
225
|
-
if data:
|
|
226
|
-
data = data[1:]
|
|
227
|
-
template_seen = False
|
|
228
|
-
real_ids = 0
|
|
229
|
-
for r in data:
|
|
230
|
-
cells = split_row(r)
|
|
231
|
-
if not cells:
|
|
232
|
-
continue
|
|
233
|
-
rid = cells[0]
|
|
234
|
-
if PLACEHOLDER_RE.match(rid) or "[" in rid:
|
|
235
|
-
template_seen = True
|
|
236
|
-
continue
|
|
237
|
-
if not rid:
|
|
238
|
-
continue
|
|
239
|
-
if not ID_RE.match(rid):
|
|
240
|
-
errors.append(f"malformed requirement ID: '{rid}' (expected e.g. AUTH-01)")
|
|
241
|
-
else:
|
|
242
|
-
real_ids += 1
|
|
243
|
-
if template_seen and real_ids == 0:
|
|
244
|
-
warnings.append("Requirement Traceability has only template rows (no real IDs yet)")
|
|
245
|
-
|
|
246
|
-
return errors, warnings
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def main(argv=None):
|
|
250
|
-
p = argparse.ArgumentParser(prog="validate_spec.py", description="Closure-gate checks for a feature spec.md.")
|
|
251
|
-
p.add_argument("target", nargs="?", default=None)
|
|
252
|
-
p.add_argument("--root", default=".")
|
|
253
|
-
p.add_argument("--strict", action="store_true")
|
|
254
|
-
args = p.parse_args(argv)
|
|
255
|
-
|
|
256
|
-
spec = resolve_spec(args.target, args.root)
|
|
257
|
-
if not spec:
|
|
258
|
-
print("validate_spec: could not locate a spec.md. Pass a path or run from the project root.", file=sys.stderr)
|
|
259
|
-
return 2
|
|
260
|
-
|
|
261
|
-
errors, warnings = check(spec)
|
|
262
|
-
for w in warnings:
|
|
263
|
-
print(f" WARN {w}")
|
|
264
|
-
for e in errors:
|
|
265
|
-
print(f" ERROR {e}")
|
|
266
|
-
fail = errors or (warnings and args.strict)
|
|
267
|
-
print(f"\nvalidate_spec: {len(errors)} error(s), {len(warnings)} warning(s) in {spec}")
|
|
268
|
-
return 1 if fail else 0
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if __name__ == "__main__":
|
|
272
|
-
raise SystemExit(main())
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
validate_state.py - deterministic completion gate for a feature.
|
|
4
|
-
|
|
5
|
-
The skill's strongest invariant is "the Verifier is always-on, never prompted;
|
|
6
|
-
Execute is not done until validation.md reports PASS." That is prose the model
|
|
7
|
-
must remember. This turns it into a checkable pass/fail the closing step runs
|
|
8
|
-
automatically, so declaring a feature done without a real Verifier report fails
|
|
9
|
-
loudly instead of slipping through.
|
|
10
|
-
|
|
11
|
-
It does NOT merely check that validation.md exists - a report that exists but is
|
|
12
|
-
empty, still holds the template placeholder, or has no evidence would pass a
|
|
13
|
-
shallow existence check while proving nothing. This gate requires a real,
|
|
14
|
-
filled verdict plus at least one file:line evidence citation.
|
|
15
|
-
|
|
16
|
-
Operates only on the .specs/ markdown artifacts (stack- and tool-agnostic). No
|
|
17
|
-
dependencies. Run from the project root (the dir that contains .specs), or pass
|
|
18
|
-
--root. Meant to be invoked by the skill as the closing gate of Execute, the
|
|
19
|
-
same way lessons.py is invoked at distillation - not a manual step.
|
|
20
|
-
|
|
21
|
-
Usage:
|
|
22
|
-
python3 skills/massa-ai/scripts/validate_state.py [feature]
|
|
23
|
-
python3 skills/massa-ai/scripts/validate_state.py
|
|
24
|
-
|
|
25
|
-
Invoke with the repo-root-relative script path shown above (matches
|
|
26
|
-
lessons.py's convention), not a project-local copy. Pass --root when cwd is
|
|
27
|
-
not the project that contains .specs/.
|
|
28
|
-
|
|
29
|
-
Exit codes: 0 ok, 1 a completed feature is missing a real PASS report,
|
|
30
|
-
2 usage error.
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
import argparse
|
|
34
|
-
import os
|
|
35
|
-
import re
|
|
36
|
-
import sys
|
|
37
|
-
|
|
38
|
-
# A file:line citation: a path with an extension, then :<line>. e.g. src/a.ts:42
|
|
39
|
-
EVIDENCE_RE = re.compile(r"[\w./-]+\.[A-Za-z0-9]+:\d+")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _feature_dirs(root):
|
|
43
|
-
base = os.path.join(root, ".specs", "features")
|
|
44
|
-
if not os.path.isdir(base):
|
|
45
|
-
return base, []
|
|
46
|
-
dirs = [
|
|
47
|
-
d for d in sorted(os.listdir(base))
|
|
48
|
-
if os.path.isdir(os.path.join(base, d))
|
|
49
|
-
]
|
|
50
|
-
return base, dirs
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _verdict(text):
|
|
54
|
-
"""Return 'pass', 'fail', 'unfilled', or None from a validation report."""
|
|
55
|
-
lines = text.splitlines()
|
|
56
|
-
# Scope to the '## Summary' section when it carries its own Result line:
|
|
57
|
-
# the Discrimination Sensor's per-mutation `**Result**:` sub-line elsewhere
|
|
58
|
-
# in the report can carry the opposite word (sensor PASS, overall FAIL) and
|
|
59
|
-
# must not collide with the report verdict.
|
|
60
|
-
summary_lines = []
|
|
61
|
-
in_summary = False
|
|
62
|
-
for ln in lines:
|
|
63
|
-
stripped = ln.strip()
|
|
64
|
-
if re.match(r"^#{1,4}\s*summary\b", stripped, re.IGNORECASE):
|
|
65
|
-
in_summary = True
|
|
66
|
-
continue
|
|
67
|
-
if in_summary and re.match(r"^#{1,4}\s", stripped):
|
|
68
|
-
break
|
|
69
|
-
if in_summary:
|
|
70
|
-
summary_lines.append(ln)
|
|
71
|
-
result_re = r"\*{0,2}result\*{0,2}\s*:"
|
|
72
|
-
if any(re.search(result_re, ln.strip(), re.IGNORECASE) for ln in summary_lines):
|
|
73
|
-
scope = summary_lines
|
|
74
|
-
else:
|
|
75
|
-
scope = lines
|
|
76
|
-
# Look at the '## Validation' heading first, then a '**Result**' line.
|
|
77
|
-
candidates = [
|
|
78
|
-
ln for ln in scope
|
|
79
|
-
if re.search(r"^#{1,4}\s*validation\b", ln.strip(), re.IGNORECASE)
|
|
80
|
-
or re.search(result_re, ln.strip(), re.IGNORECASE)
|
|
81
|
-
]
|
|
82
|
-
hay = " ".join(candidates) if candidates else "\n".join(scope)
|
|
83
|
-
has_pass = re.search(r"\bPASS\b", hay) is not None
|
|
84
|
-
has_fail = re.search(r"\bFAIL\b", hay) is not None
|
|
85
|
-
if has_pass and has_fail:
|
|
86
|
-
# Both present on the verdict line = unfilled template "[PASS | FAIL]".
|
|
87
|
-
return "unfilled"
|
|
88
|
-
if has_pass:
|
|
89
|
-
return "pass"
|
|
90
|
-
if has_fail:
|
|
91
|
-
return "fail"
|
|
92
|
-
return None
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _appears_complete(fdir):
|
|
96
|
-
"""Conservative completeness heuristic for the cross-check mode.
|
|
97
|
-
|
|
98
|
-
A feature 'appears complete' if it already has a validation.md, or if it has
|
|
99
|
-
a tasks.md with at least one task and no unchecked '- [ ]' boxes left. When
|
|
100
|
-
the signal is ambiguous (no tasks.md, Tasks phase skipped), returns False so
|
|
101
|
-
an in-flight feature is never falsely flagged.
|
|
102
|
-
"""
|
|
103
|
-
if os.path.exists(os.path.join(fdir, "validation.md")):
|
|
104
|
-
return True
|
|
105
|
-
tasks = os.path.join(fdir, "tasks.md")
|
|
106
|
-
if not os.path.exists(tasks):
|
|
107
|
-
return False
|
|
108
|
-
body = open(tasks, encoding="utf-8", errors="replace").read()
|
|
109
|
-
if not re.search(r"^#{2,4}\s+T\d+\s*:", body, re.MULTILINE):
|
|
110
|
-
return False
|
|
111
|
-
if re.search(r"^\s*-\s*\[\s\]", body, re.MULTILINE):
|
|
112
|
-
return False # unchecked box remains -> still in progress
|
|
113
|
-
return True
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _check_feature(fdir, name):
|
|
117
|
-
"""Return list of error strings for one feature (empty = pass)."""
|
|
118
|
-
errors = []
|
|
119
|
-
vpath = os.path.join(fdir, "validation.md")
|
|
120
|
-
if not os.path.exists(vpath):
|
|
121
|
-
errors.append(
|
|
122
|
-
f"{name}: no validation.md - Execute is not done until the Verifier "
|
|
123
|
-
f"writes it (author != verifier). Dispatch validation before marking done."
|
|
124
|
-
)
|
|
125
|
-
return errors
|
|
126
|
-
text = open(vpath, encoding="utf-8", errors="replace").read()
|
|
127
|
-
verdict = _verdict(text)
|
|
128
|
-
if verdict is None:
|
|
129
|
-
errors.append(f"{name}: validation.md has no PASS/FAIL verdict (a prose-only report does not count)")
|
|
130
|
-
elif verdict == "unfilled":
|
|
131
|
-
errors.append(f"{name}: validation.md verdict is still the template placeholder '[PASS | FAIL]' - not filled")
|
|
132
|
-
elif verdict == "fail":
|
|
133
|
-
errors.append(f"{name}: validation.md verdict is FAIL - route the ranked gaps to fix tasks, then re-verify (feature is not done)")
|
|
134
|
-
if verdict == "pass" and not EVIDENCE_RE.search(text):
|
|
135
|
-
errors.append(f"{name}: validation.md is PASS but cites no file:line evidence - evidence-or-zero not satisfied")
|
|
136
|
-
return errors
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _resolve(root, feature):
|
|
140
|
-
base, dirs = _feature_dirs(root)
|
|
141
|
-
if not os.path.isdir(base):
|
|
142
|
-
print(f"validate_state: no {base} directory - nothing to check.")
|
|
143
|
-
return []
|
|
144
|
-
if feature:
|
|
145
|
-
fdir = feature if os.path.isdir(feature) else os.path.join(base, feature)
|
|
146
|
-
if not os.path.isdir(fdir):
|
|
147
|
-
print(f"validate_state: feature not found: {feature}", file=sys.stderr)
|
|
148
|
-
raise SystemExit(2)
|
|
149
|
-
return [(fdir, os.path.basename(fdir.rstrip("/")))]
|
|
150
|
-
if len(dirs) == 1:
|
|
151
|
-
return [(os.path.join(base, dirs[0]), dirs[0])]
|
|
152
|
-
if not dirs:
|
|
153
|
-
print("validate_state: no features under .specs/features/ - nothing to check.")
|
|
154
|
-
return []
|
|
155
|
-
# Cross-check mode: only features that appear complete.
|
|
156
|
-
picked = [(os.path.join(base, d), d) for d in dirs if _appears_complete(os.path.join(base, d))]
|
|
157
|
-
if not picked:
|
|
158
|
-
print("validate_state: no completed feature detected (all in progress) - nothing to gate.")
|
|
159
|
-
return picked
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def main(argv=None):
|
|
163
|
-
p = argparse.ArgumentParser(prog="validate_state.py", description="Deterministic completion gate: a done feature must have a real PASS validation report.")
|
|
164
|
-
p.add_argument("feature", nargs="?", default=None, help="Feature dir or name (default: sole feature, else cross-check all completed)")
|
|
165
|
-
p.add_argument("--root", default=".", help="Project root containing .specs/ (default: current dir)")
|
|
166
|
-
args = p.parse_args(argv)
|
|
167
|
-
root = os.path.abspath(args.root)
|
|
168
|
-
|
|
169
|
-
targets = _resolve(root, args.feature)
|
|
170
|
-
all_errors = []
|
|
171
|
-
for fdir, name in targets:
|
|
172
|
-
all_errors += _check_feature(fdir, name)
|
|
173
|
-
|
|
174
|
-
for e in all_errors:
|
|
175
|
-
print(f" ERROR {e}")
|
|
176
|
-
n = len(all_errors)
|
|
177
|
-
checked = ", ".join(name for _, name in targets) or "(none)"
|
|
178
|
-
print(f"\nvalidate_state: {n} error(s) across [{checked}]")
|
|
179
|
-
return 1 if n else 0
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if __name__ == "__main__":
|
|
183
|
-
raise SystemExit(main())
|