workflow-toolkit 1.0.1
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/.agents/skills/ponytail/SKILL.md +111 -0
- package/.agents/skills/ponytail-audit/SKILL.md +37 -0
- package/.agents/skills/ponytail-debt/SKILL.md +41 -0
- package/.agents/skills/ponytail-gain/SKILL.md +47 -0
- package/.agents/skills/ponytail-help/SKILL.md +70 -0
- package/.agents/skills/ponytail-review/SKILL.md +52 -0
- package/.agents/skills/prompt-review/SKILL.md +60 -0
- package/.agents/skills/wtk/SKILL.md +64 -0
- package/.agents/skills/wtk/references/artifacts.md +82 -0
- package/.agents/skills/wtk/references/evidence.md +68 -0
- package/.agents/skills/wtk/references/git.md +75 -0
- package/.agents/skills/wtk/references/test-contract.md +76 -0
- package/.agents/skills/wtk/references/validation.md +127 -0
- package/.agents/skills/wtk-config/SKILL.md +102 -0
- package/.agents/skills/wtk-config/assets/agents/claude/deep-reviewer.md +40 -0
- package/.agents/skills/wtk-config/assets/agents/claude/designer.md +57 -0
- package/.agents/skills/wtk-config/assets/agents/claude/explorer.md +41 -0
- package/.agents/skills/wtk-config/assets/agents/claude/implementer.md +57 -0
- package/.agents/skills/wtk-config/assets/agents/claude/planner.md +68 -0
- package/.agents/skills/wtk-config/assets/agents/claude/verifier.md +80 -0
- package/.agents/skills/wtk-config/assets/agents/codex/deep-reviewer.toml +41 -0
- package/.agents/skills/wtk-config/assets/agents/codex/designer.toml +55 -0
- package/.agents/skills/wtk-config/assets/agents/codex/explorer.toml +35 -0
- package/.agents/skills/wtk-config/assets/agents/codex/implementer.toml +52 -0
- package/.agents/skills/wtk-config/assets/agents/codex/planner.toml +66 -0
- package/.agents/skills/wtk-config/assets/agents/codex/verifier.toml +78 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/deep-reviewer.md +38 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/designer.md +55 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/explorer.md +39 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/implementer.md +55 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/planner.md +66 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/verifier.md +79 -0
- package/.agents/skills/wtk-config/scripts/ad-index.py +123 -0
- package/.agents/skills/wtk-config/scripts/repository_intelligence.py +671 -0
- package/.agents/skills/wtk-config/scripts/workflow_config.py +972 -0
- package/.agents/skills/wtk-deep-review/SKILL.md +164 -0
- package/.agents/skills/wtk-deep-review/assets/PROMPT.md +56 -0
- package/.agents/skills/wtk-deep-review/assets/REVIEW_UI.html +1340 -0
- package/.agents/skills/wtk-deep-review/assets/findings.schema.json +113 -0
- package/.agents/skills/wtk-deep-review/references/context-pack.md +88 -0
- package/.agents/skills/wtk-deep-review/references/orchestration.md +164 -0
- package/.agents/skills/wtk-deep-review/references/output-contracts.md +149 -0
- package/.agents/skills/wtk-deep-review/references/publish-github.md +83 -0
- package/.agents/skills/wtk-deep-review/references/state-and-learnings.md +64 -0
- package/.agents/skills/wtk-deep-review/references/subagent-runtimes.md +33 -0
- package/.agents/skills/wtk-deep-review/references/taxonomy.md +70 -0
- package/.agents/skills/wtk-deep-review/scripts/_common.py +371 -0
- package/.agents/skills/wtk-deep-review/scripts/build_jobs.py +561 -0
- package/.agents/skills/wtk-deep-review/scripts/build_knowledge.py +349 -0
- package/.agents/skills/wtk-deep-review/scripts/build_manifest.py +479 -0
- package/.agents/skills/wtk-deep-review/scripts/graft_context.py +160 -0
- package/.agents/skills/wtk-deep-review/scripts/graphify_context.py +75 -0
- package/.agents/skills/wtk-deep-review/scripts/merge_findings.py +313 -0
- package/.agents/skills/wtk-deep-review/scripts/render_html.py +240 -0
- package/.agents/skills/wtk-deep-review/scripts/render_review.py +282 -0
- package/.agents/skills/wtk-deep-review/scripts/run_jobs.py +436 -0
- package/.agents/skills/wtk-deep-review/scripts/token_metrics.py +369 -0
- package/.agents/skills/wtk-discover/SKILL.md +270 -0
- package/.agents/skills/wtk-discover/references/document-format.md +149 -0
- package/.agents/skills/wtk-implement/SKILL.md +95 -0
- package/.agents/skills/wtk-implement/references/checklist-format.md +116 -0
- package/.agents/skills/wtk-implement/references/screens.md +129 -0
- package/.agents/skills/wtk-implement/references/test-policy.md +152 -0
- package/.agents/skills/wtk-implement/references/verify.md +297 -0
- package/.agents/skills/wtk-knowledge-check/SKILL.md +16 -0
- package/.agents/skills/wtk-knowledge-check/scripts/check.ts +502 -0
- package/.agents/skills/wtk-knowledge-check/scripts/cli.ts +32 -0
- package/.agents/skills/wtk-knowledge-check/scripts/frontmatter.ts +44 -0
- package/.agents/skills/wtk-lean/.skill-meta.json +6 -0
- package/.agents/skills/wtk-lean/NOTICE.md +18 -0
- package/.agents/skills/wtk-lean/SKILL.md +137 -0
- package/.agents/skills/wtk-lean/references/build.md +168 -0
- package/.agents/skills/wtk-lean/references/checks.md +253 -0
- package/.agents/skills/wtk-lean/references/memory.md +156 -0
- package/.agents/skills/wtk-lean/references/plan.md +514 -0
- package/.agents/skills/wtk-lean/references/verify.md +336 -0
- package/.agents/skills/wtk-lean/scripts/check_commit.py +121 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/checks.md +98 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/plan.md +119 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/verification.md +41 -0
- package/.agents/skills/wtk-lean/scripts/lessons.py +412 -0
- package/.agents/skills/wtk-lean/scripts/selftest.py +401 -0
- package/.agents/skills/wtk-lean/scripts/validate_checks.py +417 -0
- package/.agents/skills/wtk-lean/scripts/validate_plan.py +580 -0
- package/.agents/skills/wtk-lean/scripts/validate_verification.py +357 -0
- package/.agents/skills/wtk-plan/SKILL.md +102 -0
- package/.agents/skills/wtk-plan/references/document-format.md +222 -0
- package/.agents/skills/wtk-qa/SKILL.md +14 -0
- package/.agents/skills/wtk-qa-execute/SKILL.md +115 -0
- package/.agents/skills/wtk-qa-execute/references/fix-loop.md +23 -0
- package/.agents/skills/wtk-qa-execute/references/session-protocol.md +25 -0
- package/.agents/skills/wtk-qa-plan/SKILL.md +111 -0
- package/.agents/skills/wtk-qa-plan/references/profile.md +35 -0
- package/.agents/skills/wtk-ship/SKILL.md +49 -0
- package/.agents/skills/wtk-ship/remediation.py +170 -0
- package/.agents/skills/wtk-ship/scripts/close_feature.py +83 -0
- package/.agents/skills/wtk-ship/scripts/review_convergence.py +373 -0
- package/.wtk.toml.example +93 -0
- package/AGENTS.md +129 -0
- package/NOTICE.md +12 -0
- package/README.md +440 -0
- package/bin/wtk.js +25 -0
- package/docs/toolkit/README.md +56 -0
- package/docs/toolkit/decisions.md +61 -0
- package/docs/toolkit/guidelines/CONTEXT-BUDGET.md +64 -0
- package/docs/toolkit/guidelines/DX.md +67 -0
- package/docs/toolkit/guidelines/FRONTEND.md +73 -0
- package/docs/toolkit/guidelines/KNOWLEDGE-WIKI.md +128 -0
- package/docs/toolkit/guidelines/MODELING.md +134 -0
- package/docs/toolkit/guidelines/QA-EXECUTION.md +46 -0
- package/docs/toolkit/guidelines/QA-SCENARIOS.md +165 -0
- package/docs/toolkit/guidelines/REVIEW-ROUNDS.md +160 -0
- package/docs/toolkit/guidelines/SECURITY.md +160 -0
- package/docs/toolkit/guidelines/UI-UX.md +117 -0
- package/docs/toolkit/guidelines/WORKFLOW-MEMORY.md +73 -0
- package/docs/toolkit/guidelines.md +50 -0
- package/docs/toolkit/loop.md +77 -0
- package/docs/toolkit/purpose.md +51 -0
- package/docs/toolkit/repository-intelligence.md +53 -0
- package/docs/toolkit/reviews.md +77 -0
- package/knowledge/AGENTS.md +316 -0
- package/knowledge/raw/README.md +17 -0
- package/package.json +74 -0
- package/scripts/install_security_skills.py +816 -0
- package/scripts/installer/engine.js +254 -0
- package/scripts/installer/knowledge.js +28 -0
- package/scripts/installer/packets.js +77 -0
- package/scripts/installer/terminal.js +98 -0
- package/scripts/installer/transaction.js +75 -0
- package/skills-lock.json +92 -0
- package/templates/adoption/agents/core.md +23 -0
- package/templates/adoption/agents/quality.md +3 -0
- package/templates/adoption/knowledge/wiki/architecture/index.md +3 -0
- package/templates/adoption/knowledge/wiki/decisions/index.md +3 -0
- package/templates/adoption/knowledge/wiki/design/index.md +3 -0
- package/templates/adoption/knowledge/wiki/domain/index.md +3 -0
- package/templates/adoption/knowledge/wiki/index.md +3 -0
- package/templates/adoption/knowledge/wiki/log.md +3 -0
- package/templates/adoption/knowledge/wiki/open-questions/index.md +3 -0
- package/templates/adoption/knowledge/wiki/product/index.md +3 -0
- package/templates/adoption/knowledge/wiki/research/index.md +3 -0
- package/templates/adoption/product/AGENT-CONTEXT.md +25 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
validate_plan.py - deterministic gate for a feature plan.md, run before writing checks.
|
|
4
|
+
|
|
5
|
+
One artifact, one gate. The plan carries both halves a human confirms - what must be
|
|
6
|
+
true (problem, criteria, assumptions) and what is being built (flow, relations,
|
|
7
|
+
surface, landing, impact) - so this script turns the closure gate into a pass/fail run
|
|
8
|
+
instead of trusting the model to remember it.
|
|
9
|
+
|
|
10
|
+
The failures it exists to catch are the ones that make a plan lie rather than inform:
|
|
11
|
+
|
|
12
|
+
- a criterion with no SHALL, which reads like a requirement and cannot be tested;
|
|
13
|
+
- an assumption with no chosen default, which is an open question wearing a
|
|
14
|
+
decision's clothes;
|
|
15
|
+
- a section quietly absent, which is indistinguishable from "nothing to say here"
|
|
16
|
+
unless the file is required to say it out loud;
|
|
17
|
+
- a `Landing` row with no literal shape, which reads like a decision and cannot be
|
|
18
|
+
copied, so the next person re-decides it;
|
|
19
|
+
- a `Landing` row with no rejected alternative, which is the difference between a
|
|
20
|
+
decision and a description of what happened;
|
|
21
|
+
- columns and types inside `Relations`, which is the exact detail that goes stale and
|
|
22
|
+
then misleads the next reader with the authority of a written diagram;
|
|
23
|
+
- a `Surface` row with no statuses, since the statuses are what becomes a Coverage set
|
|
24
|
+
in checks.md - drop them here and the join has nothing to enumerate.
|
|
25
|
+
|
|
26
|
+
Pure standard library, zero dependencies. Operates only on the plan.md markdown
|
|
27
|
+
artifact - never on the target codebase - so it stays stack-agnostic.
|
|
28
|
+
|
|
29
|
+
What it checks:
|
|
30
|
+
ERROR - a required section is missing
|
|
31
|
+
ERROR - an acceptance criterion has no SHALL (not testable / not EARS-shaped)
|
|
32
|
+
ERROR - an Assumptions row has an empty "Chosen default" or "Rationale" cell
|
|
33
|
+
ERROR - a Traceability row has a malformed requirement ID
|
|
34
|
+
ERROR - the Observable section is empty, or has no rows and no `None - <why>`
|
|
35
|
+
ERROR - an Observable row whose Landing cell is blank
|
|
36
|
+
ERROR - an Observable `n/a` or `existing` landing with no reason after it
|
|
37
|
+
ERROR - Flow, Relations, Surface or Impact is empty, or still the template placeholder
|
|
38
|
+
(each has a one-line "None - <why>" answer, so a blank is never "nothing to say")
|
|
39
|
+
ERROR - the Landing section is empty (state `None - <why>` instead)
|
|
40
|
+
ERROR - Landing has no door rows and does not state `None - <why>`
|
|
41
|
+
ERROR - a Landing row with no literal shape
|
|
42
|
+
ERROR - a Landing row with no rejected alternative named
|
|
43
|
+
ERROR - a Relations diagram carrying an attribute block: columns and types go stale
|
|
44
|
+
ERROR - a Surface row whose Status cell names no status code
|
|
45
|
+
ERROR - a check id written into Surface, which cannot exist before checks.md does
|
|
46
|
+
WARN - an AC has SHALL but no recognizable EARS lead keyword
|
|
47
|
+
WARN - an AC carries a vague word instead of a concrete value
|
|
48
|
+
WARN - no numbered acceptance criteria found at all
|
|
49
|
+
WARN - template placeholder rows are still present (plan not filled in)
|
|
50
|
+
WARN - open questions are not explicitly resolved
|
|
51
|
+
WARN - no Sources section ('nothing' is valid, a missing section is not)
|
|
52
|
+
WARN - an Impact section with no rows
|
|
53
|
+
WARN - a Flow hop, or a flowchart node, naming a module marked neither existing
|
|
54
|
+
nor as a Landing door
|
|
55
|
+
WARN - a source marked binding (only Verify step 1 opens those, and only at profile ui)
|
|
56
|
+
|
|
57
|
+
Usage:
|
|
58
|
+
python3 <skill-dir>/scripts/validate_plan.py [target] [--root DIR] [--strict]
|
|
59
|
+
|
|
60
|
+
Invoke from the skill directory that ships this script (not the project root).
|
|
61
|
+
target Path to a plan.md, a feature directory, a feature name, or a project root.
|
|
62
|
+
Omitted -> auto-detect the single feature under <root>/.specs/features/.
|
|
63
|
+
--root Project root that contains .specs/ (default: current dir).
|
|
64
|
+
--strict Treat warnings as errors.
|
|
65
|
+
|
|
66
|
+
Exit codes: 0 pass, 1 errors found (or warnings under --strict), 2 usage error.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
import argparse
|
|
70
|
+
import os
|
|
71
|
+
import re
|
|
72
|
+
import sys
|
|
73
|
+
|
|
74
|
+
# Each entry is a tuple of acceptable heading names (first is canonical).
|
|
75
|
+
REQUIRED_SECTIONS = [
|
|
76
|
+
("Problem", "Problem Statement"),
|
|
77
|
+
("Out of scope", "Out of Scope"),
|
|
78
|
+
("Assumptions", "Assumptions & Open Questions"),
|
|
79
|
+
("Criteria", "User Stories"),
|
|
80
|
+
("Traceability", "Requirement Traceability"),
|
|
81
|
+
("Observable",),
|
|
82
|
+
("Flow",),
|
|
83
|
+
("Relations",),
|
|
84
|
+
("Surface",),
|
|
85
|
+
("Landing",),
|
|
86
|
+
("Impact",),
|
|
87
|
+
]
|
|
88
|
+
ADVISORY_SECTIONS = ["Sources"]
|
|
89
|
+
|
|
90
|
+
# The shape half: each has a one-line answer when it does not apply.
|
|
91
|
+
SHAPE_HINTS = (
|
|
92
|
+
("Flow", "state the hops in order, or `single module - <name>`"),
|
|
93
|
+
("Relations", "state the entities and cardinality, or `None - no stored-data shape change`"),
|
|
94
|
+
("Surface", "state the route signature, or `None - nothing consumed outside`"),
|
|
95
|
+
("Impact", "state what changes underneath, or `nothing` - a missing row is not an answer"),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
ID_RE = re.compile(r"^[A-Z][A-Z0-9]*-\d+$")
|
|
99
|
+
PLACEHOLDER_RE = re.compile(r"^\s*[\[<].+[\]>]\s*$")
|
|
100
|
+
CID_RE = re.compile(r"\bC\d+\b")
|
|
101
|
+
STATUS_RE = re.compile(r"\b[1-5]\d\d\b")
|
|
102
|
+
NONE_RE = re.compile(r"\b(none|nothing|n/?a|single module)\b", re.IGNORECASE)
|
|
103
|
+
VAGUE_RE = re.compile(
|
|
104
|
+
r"\b(gracefully|properly|correctly|quickly|fast|slow|efficiently|reasonably|"
|
|
105
|
+
r"appropriately|as expected|user-friendly|robust)\b",
|
|
106
|
+
re.IGNORECASE,
|
|
107
|
+
)
|
|
108
|
+
HEADER_CELL_RE = re.compile(
|
|
109
|
+
r"^(assumption|#|id|kind|criterion|slice|set|check|front|decision|excluded)\b", re.IGNORECASE
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# `Entity { string name }` in an erDiagram is the columns-and-types syntax, which is
|
|
113
|
+
# precisely the reversible detail this artifact keeps out.
|
|
114
|
+
ER_ATTRIBUTE_RE = re.compile(r"^\s*\w+\s*\{\s*$")
|
|
115
|
+
|
|
116
|
+
# A hop is answered when it says the module already exists, or names the door that creates it.
|
|
117
|
+
# `out:` cannot keep a trailing \b: the colon is not a word character, so the
|
|
118
|
+
# boundary never fires and an `out:` hop that names a slug in backticks is
|
|
119
|
+
# misread as an unresolved module.
|
|
120
|
+
HOP_RESOLVED_RE = re.compile(r"\b(exists|existing|new\b|door\s*\d+)|out\s*:", re.IGNORECASE)
|
|
121
|
+
MODULE_RE = re.compile(r"`([^`]+)`")
|
|
122
|
+
# A mermaid flowchart node: `A[Label]`, `A["Label"]`, `A(Label)`. The label is what has to
|
|
123
|
+
# carry the resolution marker, the same as a list hop does.
|
|
124
|
+
NODE_LABEL_RE = re.compile(r"[\[(]\s*\"?([^\"\]()|]+?)\"?\s*[\])]")
|
|
125
|
+
EDGE_LINE_RE = re.compile(r"--+>|--+\s")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve_plan(target, root):
|
|
129
|
+
"""Return the path to a plan.md from a file, dir, feature name, or auto-detect."""
|
|
130
|
+
if target:
|
|
131
|
+
if os.path.isfile(target):
|
|
132
|
+
return target
|
|
133
|
+
if os.path.isdir(target):
|
|
134
|
+
cand = os.path.join(target, "plan.md")
|
|
135
|
+
if os.path.isfile(cand):
|
|
136
|
+
return cand
|
|
137
|
+
return _autodetect(target)
|
|
138
|
+
cand = os.path.join(root, ".specs", "features", target, "plan.md")
|
|
139
|
+
if os.path.isfile(cand):
|
|
140
|
+
return cand
|
|
141
|
+
return None
|
|
142
|
+
return _autodetect(root)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _autodetect(root):
|
|
146
|
+
base = os.path.join(root, ".specs", "features")
|
|
147
|
+
if not os.path.isdir(base):
|
|
148
|
+
return None
|
|
149
|
+
features = [
|
|
150
|
+
d for d in sorted(os.listdir(base))
|
|
151
|
+
if os.path.isfile(os.path.join(base, d, "plan.md"))
|
|
152
|
+
]
|
|
153
|
+
if len(features) == 1:
|
|
154
|
+
return os.path.join(base, features[0], "plan.md")
|
|
155
|
+
if len(features) == 0:
|
|
156
|
+
return None
|
|
157
|
+
raise SystemExit(
|
|
158
|
+
"validate_plan: multiple features found; pass one explicitly:\n "
|
|
159
|
+
+ "\n ".join(os.path.join(base, f, "plan.md") for f in features)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def split_row(line):
|
|
164
|
+
return [c.strip() for c in line.strip().strip("|").split("|")]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def is_separator(line):
|
|
168
|
+
return bool(re.match(r"^\s*\|?[\s:|-]+\|?\s*$", line)) and "-" in line
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def section_bounds(lines, names):
|
|
172
|
+
"""Return (start, end) body indices for the first heading matching any name."""
|
|
173
|
+
if isinstance(names, str):
|
|
174
|
+
names = (names,)
|
|
175
|
+
pattern = re.compile(
|
|
176
|
+
r"^#{1,4}\s+(?:" + "|".join(re.escape(n) for n in names) + r")\b.*$",
|
|
177
|
+
re.IGNORECASE,
|
|
178
|
+
)
|
|
179
|
+
start = None
|
|
180
|
+
for i, ln in enumerate(lines):
|
|
181
|
+
if pattern.match(ln.strip()):
|
|
182
|
+
start = i + 1
|
|
183
|
+
break
|
|
184
|
+
if start is None:
|
|
185
|
+
return None
|
|
186
|
+
end = len(lines)
|
|
187
|
+
for j in range(start, len(lines)):
|
|
188
|
+
if re.match(r"^#{1,2}\s+\S", lines[j]): # only a top-level heading closes it
|
|
189
|
+
end = j
|
|
190
|
+
break
|
|
191
|
+
return (start, end)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def first_table(lines, bounds):
|
|
195
|
+
"""Return the data rows of the FIRST contiguous markdown table in a section.
|
|
196
|
+
|
|
197
|
+
Scoping to the first table matters: a section may carry a second table (e.g. an
|
|
198
|
+
unresolved-questions table under Assumptions), and validating its rows against
|
|
199
|
+
the first table's column meaning produces false errors.
|
|
200
|
+
"""
|
|
201
|
+
if not bounds:
|
|
202
|
+
return []
|
|
203
|
+
rows, started = [], False
|
|
204
|
+
for i in range(*bounds):
|
|
205
|
+
stripped = lines[i].strip()
|
|
206
|
+
if stripped.startswith("|"):
|
|
207
|
+
started = True
|
|
208
|
+
rows.append(stripped)
|
|
209
|
+
elif started and stripped == "":
|
|
210
|
+
continue
|
|
211
|
+
elif started:
|
|
212
|
+
break
|
|
213
|
+
data = [r for r in rows if not is_separator(r)]
|
|
214
|
+
return data[1:] if data else [] # the first row is the header
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def strip_fences(lines, keep=("mermaid",)):
|
|
218
|
+
"""
|
|
219
|
+
Drop fenced code blocks so a template example inside the file is not parsed.
|
|
220
|
+
|
|
221
|
+
Blocks whose info string matches `keep` survive: a mermaid erDiagram is content in
|
|
222
|
+
this artifact, not an example, and the columns check has to see inside it.
|
|
223
|
+
"""
|
|
224
|
+
out, fence_info = [], None
|
|
225
|
+
for ln in lines:
|
|
226
|
+
stripped = ln.strip()
|
|
227
|
+
if stripped.startswith("```"):
|
|
228
|
+
if fence_info is None:
|
|
229
|
+
fence_info = stripped.strip("`").strip().lower()
|
|
230
|
+
else:
|
|
231
|
+
fence_info = None
|
|
232
|
+
out.append("")
|
|
233
|
+
continue
|
|
234
|
+
out.append(ln if (fence_info is None or fence_info in keep) else "")
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def classify_ears(text):
|
|
239
|
+
"""Return (ok, note). ok requires a SHALL; note records the EARS pattern."""
|
|
240
|
+
low = text.strip().lower()
|
|
241
|
+
if not re.search(r"\bshall\b", low):
|
|
242
|
+
return (False, "no SHALL")
|
|
243
|
+
kws = []
|
|
244
|
+
if re.search(r"\bwhile\b", low):
|
|
245
|
+
kws.append("WHILE")
|
|
246
|
+
if re.search(r"\bwhen\b", low):
|
|
247
|
+
kws.append("WHEN")
|
|
248
|
+
if re.match(r"^\s*if\b", low) or re.search(r"\bif\b.*\bthen\b", low):
|
|
249
|
+
kws.append("IF/THEN")
|
|
250
|
+
if re.search(r"\bwhere\b", low):
|
|
251
|
+
kws.append("WHERE")
|
|
252
|
+
if len(kws) >= 2:
|
|
253
|
+
return (True, "complex (" + "+".join(kws) + ")")
|
|
254
|
+
if kws:
|
|
255
|
+
return (True, {
|
|
256
|
+
"WHILE": "state-driven",
|
|
257
|
+
"WHEN": "event-driven",
|
|
258
|
+
"IF/THEN": "unwanted-behavior",
|
|
259
|
+
"WHERE": "optional-feature",
|
|
260
|
+
}[kws[0]])
|
|
261
|
+
if re.match(r"^\s*the\b", low):
|
|
262
|
+
return (True, "ubiquitous")
|
|
263
|
+
return (True, "warn: SHALL present but no EARS lead keyword")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def sibling_profile(plan_path):
|
|
267
|
+
"""The profile checks.md declares, when it exists yet."""
|
|
268
|
+
checks = os.path.join(os.path.dirname(os.path.abspath(plan_path)), "checks.md")
|
|
269
|
+
if not os.path.isfile(checks):
|
|
270
|
+
return None
|
|
271
|
+
with open(checks, "r", encoding="utf-8") as f:
|
|
272
|
+
for ln in f:
|
|
273
|
+
m = re.match(r"^\**Profile\**\s*:\s*`?(\w+)`?", ln.strip(), re.IGNORECASE)
|
|
274
|
+
if m:
|
|
275
|
+
return m.group(1).lower()
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def check_criteria(lines):
|
|
280
|
+
"""EARS shape of every numbered acceptance criterion."""
|
|
281
|
+
errors, warnings = [], []
|
|
282
|
+
in_ac, ac_count, blanks = False, 0, 0
|
|
283
|
+
# A blank line does NOT close the block - well-formed markdown puts one between the
|
|
284
|
+
# label and the list, and treating it as a terminator silently skips every criterion.
|
|
285
|
+
for i, ln in enumerate(lines, start=1):
|
|
286
|
+
stripped = ln.strip()
|
|
287
|
+
if re.match(r"^\*{0,2}Acceptance Criteria\*{0,2}\s*:?\s*$", stripped, re.IGNORECASE):
|
|
288
|
+
in_ac, blanks = True, 0
|
|
289
|
+
continue
|
|
290
|
+
if not in_ac:
|
|
291
|
+
continue
|
|
292
|
+
if stripped == "":
|
|
293
|
+
blanks += 1
|
|
294
|
+
if blanks >= 2:
|
|
295
|
+
in_ac = False
|
|
296
|
+
continue
|
|
297
|
+
blanks = 0
|
|
298
|
+
m = re.match(r"^\s*\d+\.\s+(.*)$", ln)
|
|
299
|
+
if m:
|
|
300
|
+
item = m.group(1).strip()
|
|
301
|
+
if PLACEHOLDER_RE.match(item):
|
|
302
|
+
continue
|
|
303
|
+
ac_count += 1
|
|
304
|
+
ok, note = classify_ears(item)
|
|
305
|
+
if not ok:
|
|
306
|
+
errors.append(f"L{i}: acceptance criterion has no SHALL (not testable): {item[:70]}")
|
|
307
|
+
elif note.startswith("warn"):
|
|
308
|
+
warnings.append(
|
|
309
|
+
f"L{i}: AC has SHALL but no EARS keyword (WHEN/WHILE/WHERE/IF or "
|
|
310
|
+
f"ubiquitous 'The … shall'): {item[:60]}"
|
|
311
|
+
)
|
|
312
|
+
vague = VAGUE_RE.search(item)
|
|
313
|
+
if vague:
|
|
314
|
+
warnings.append(f"L{i}: AC uses '{vague.group(0)}' instead of a concrete value: {item[:60]}")
|
|
315
|
+
elif re.match(r"^#{1,4}\s", ln) or stripped.startswith("**") or re.match(r"^\s*[-*+]\s", ln):
|
|
316
|
+
in_ac = False
|
|
317
|
+
if ac_count == 0:
|
|
318
|
+
warnings.append("no numbered acceptance criteria found - is the plan filled in?")
|
|
319
|
+
return errors, warnings
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _warn_unresolved(warnings, idx, names, land_text, kind):
|
|
323
|
+
"""Warn when no name in `names` is marked as existing or present in Landing."""
|
|
324
|
+
if not names:
|
|
325
|
+
return
|
|
326
|
+
if any(n.lower() in land_text for n in names):
|
|
327
|
+
return
|
|
328
|
+
warnings.append(
|
|
329
|
+
f"L{idx + 1}: Flow {kind} names `{names[0]}` without marking it as existing or as a "
|
|
330
|
+
"Landing door - if it is neither, it is placement and belongs in the diff"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def check_file(path):
|
|
335
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
336
|
+
raw = f.read().splitlines()
|
|
337
|
+
lines = strip_fences(raw)
|
|
338
|
+
errors, warnings = [], []
|
|
339
|
+
|
|
340
|
+
# Sections. A missing one and a deliberate "None" are different answers, and only the
|
|
341
|
+
# second is contestable - which is the whole reason every section is required.
|
|
342
|
+
present = {}
|
|
343
|
+
for names in REQUIRED_SECTIONS:
|
|
344
|
+
b = section_bounds(lines, names)
|
|
345
|
+
present[names[0]] = b
|
|
346
|
+
if b is None:
|
|
347
|
+
errors.append(f"missing required section: ## {names[0]}")
|
|
348
|
+
for name in ADVISORY_SECTIONS:
|
|
349
|
+
label = re.compile(r"^\**" + re.escape(name) + r"\**\s*:", re.IGNORECASE)
|
|
350
|
+
if section_bounds(lines, name) is None and not any(label.match(ln.strip()) for ln in lines):
|
|
351
|
+
warnings.append(f"no {name} section - 'nothing' is a valid answer, a missing section is not")
|
|
352
|
+
|
|
353
|
+
# A source marked binding is only ever opened by step 1 of Verify, which runs at `ui`.
|
|
354
|
+
if any(re.search(r"\bbinding\b", ln, re.IGNORECASE) for ln in lines):
|
|
355
|
+
profile = sibling_profile(path)
|
|
356
|
+
if profile and profile != "ui":
|
|
357
|
+
warnings.append(
|
|
358
|
+
f"a source is marked binding but checks.md declares profile {profile}, so nobody "
|
|
359
|
+
"opens it - raise the profile to ui or drop the marking"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# --- what must be true ---
|
|
363
|
+
|
|
364
|
+
e, w = check_criteria(lines)
|
|
365
|
+
errors += e
|
|
366
|
+
warnings += w
|
|
367
|
+
|
|
368
|
+
b = present.get("Assumptions")
|
|
369
|
+
if b:
|
|
370
|
+
template_seen = False
|
|
371
|
+
for r in first_table(lines, b):
|
|
372
|
+
cells = split_row(r)
|
|
373
|
+
if len(cells) < 3:
|
|
374
|
+
continue
|
|
375
|
+
assumption, chosen, rationale = cells[0], cells[1], cells[2]
|
|
376
|
+
if PLACEHOLDER_RE.match(assumption) and PLACEHOLDER_RE.match(chosen):
|
|
377
|
+
template_seen = True
|
|
378
|
+
continue
|
|
379
|
+
if not assumption:
|
|
380
|
+
continue
|
|
381
|
+
if not chosen or PLACEHOLDER_RE.match(chosen):
|
|
382
|
+
errors.append(f"assumption '{assumption[:40]}' has empty 'Chosen default'")
|
|
383
|
+
if not rationale or PLACEHOLDER_RE.match(rationale):
|
|
384
|
+
errors.append(f"assumption '{assumption[:40]}' has empty 'Rationale'")
|
|
385
|
+
if template_seen:
|
|
386
|
+
warnings.append("Assumptions table still contains template placeholder rows")
|
|
387
|
+
oq = [lines[i] for i in range(*b) if "open questions" in lines[i].lower()]
|
|
388
|
+
oq_clean = re.sub(r"[*_]", "", " ".join(oq)).lower()
|
|
389
|
+
if not oq:
|
|
390
|
+
warnings.append("no 'Open questions:' line in the Assumptions section")
|
|
391
|
+
elif not re.search(r"open questions.*:\s*none", oq_clean):
|
|
392
|
+
warnings.append("open questions do not read as resolved ('Open questions: none')")
|
|
393
|
+
|
|
394
|
+
b = present.get("Traceability")
|
|
395
|
+
if b:
|
|
396
|
+
template_seen, real_ids = False, 0
|
|
397
|
+
for r in first_table(lines, b):
|
|
398
|
+
cells = split_row(r)
|
|
399
|
+
if not cells or not cells[0]:
|
|
400
|
+
continue
|
|
401
|
+
rid = cells[0]
|
|
402
|
+
if PLACEHOLDER_RE.match(rid) or "[" in rid or "<" in rid:
|
|
403
|
+
template_seen = True
|
|
404
|
+
continue
|
|
405
|
+
if not ID_RE.match(rid):
|
|
406
|
+
errors.append(f"malformed requirement ID: '{rid}' (expected e.g. AUTH-01)")
|
|
407
|
+
else:
|
|
408
|
+
real_ids += 1
|
|
409
|
+
if template_seen and real_ids == 0:
|
|
410
|
+
warnings.append("Traceability has only template rows (no real IDs yet)")
|
|
411
|
+
|
|
412
|
+
# Observable: every item of every surface present. A surface carries the same decisions
|
|
413
|
+
# every time it appears, so a blank here is an item nobody decided - not one that does not
|
|
414
|
+
# apply, which is what the mandatory `n/a - <reason>` escape is for.
|
|
415
|
+
obs = present.get("Observable")
|
|
416
|
+
if obs:
|
|
417
|
+
body = [lines[i].strip() for i in range(*obs) if lines[i].strip()]
|
|
418
|
+
rows = first_table(lines, obs)
|
|
419
|
+
declares_none = any(NONE_RE.search(x) for x in body)
|
|
420
|
+
if not body:
|
|
421
|
+
errors.append(
|
|
422
|
+
"Observable section is empty - walk each surface's decisions, or state "
|
|
423
|
+
"`None - no user-facing surface`"
|
|
424
|
+
)
|
|
425
|
+
elif not rows and not declares_none:
|
|
426
|
+
errors.append("Observable has no rows and does not state `None - no user-facing surface`")
|
|
427
|
+
for r in rows:
|
|
428
|
+
cells = split_row(r)
|
|
429
|
+
if len(cells) < 3 or not cells[0] or PLACEHOLDER_RE.match(cells[0]):
|
|
430
|
+
continue
|
|
431
|
+
label = f"{cells[0][:28]} / {cells[1][:28]}"
|
|
432
|
+
landing = cells[2]
|
|
433
|
+
if not landing or PLACEHOLDER_RE.match(landing) or landing in ("-", "\u2014"):
|
|
434
|
+
errors.append(
|
|
435
|
+
f"Observable '{label}': landing is blank - a criterion, `existing - <what>`, "
|
|
436
|
+
"or `n/a - <reason>`"
|
|
437
|
+
)
|
|
438
|
+
continue
|
|
439
|
+
low = landing.lower()
|
|
440
|
+
for kw in ("n/a", "na -", "existing"):
|
|
441
|
+
if low.startswith(kw):
|
|
442
|
+
rest = landing[len(kw):].strip(" -\u2013\u2014:")
|
|
443
|
+
if len(rest) < 3:
|
|
444
|
+
errors.append(
|
|
445
|
+
f"Observable '{label}': `{kw}` with no reason - say why it does not "
|
|
446
|
+
"apply, or name what already behaves that way"
|
|
447
|
+
)
|
|
448
|
+
break
|
|
449
|
+
|
|
450
|
+
# --- what is being built ---
|
|
451
|
+
|
|
452
|
+
for sec, hint in SHAPE_HINTS:
|
|
453
|
+
b = present.get(sec)
|
|
454
|
+
if not b:
|
|
455
|
+
continue
|
|
456
|
+
body = [lines[i].strip() for i in range(*b) if lines[i].strip()]
|
|
457
|
+
if not body:
|
|
458
|
+
errors.append(f"{sec} section is empty - {hint}")
|
|
459
|
+
elif all(PLACEHOLDER_RE.match(x) for x in body):
|
|
460
|
+
errors.append(f"{sec} section is still the template placeholder")
|
|
461
|
+
|
|
462
|
+
# Relations: no columns, no types. In mermaid that detail is an attribute block.
|
|
463
|
+
rel = present.get("Relations")
|
|
464
|
+
if rel:
|
|
465
|
+
for i in range(*rel):
|
|
466
|
+
if ER_ATTRIBUTE_RE.match(lines[i]):
|
|
467
|
+
errors.append(
|
|
468
|
+
f"L{i + 1}: Relations carries an attribute block ('{lines[i].strip()}') - columns and "
|
|
469
|
+
"types are reversible, come from the repo's conventions, and go stale here"
|
|
470
|
+
)
|
|
471
|
+
break
|
|
472
|
+
|
|
473
|
+
# Surface: statuses are what becomes a Coverage set in checks.md.
|
|
474
|
+
surf = present.get("Surface")
|
|
475
|
+
if surf:
|
|
476
|
+
body = [lines[i].strip() for i in range(*surf) if lines[i].strip()]
|
|
477
|
+
declares_none = any(NONE_RE.search(x) for x in body)
|
|
478
|
+
rows = first_table(lines, surf)
|
|
479
|
+
for r in rows:
|
|
480
|
+
cells = split_row(r)
|
|
481
|
+
if not cells or not cells[0] or PLACEHOLDER_RE.match(cells[0]):
|
|
482
|
+
continue
|
|
483
|
+
status_cell = cells[3] if len(cells) > 3 else ""
|
|
484
|
+
if not STATUS_RE.search(status_cell):
|
|
485
|
+
errors.append(
|
|
486
|
+
f"Surface '{cells[0][:48]}': Status names no status code - those statuses are the "
|
|
487
|
+
"set that owes a Coverage row in checks.md"
|
|
488
|
+
)
|
|
489
|
+
for i in range(*surf):
|
|
490
|
+
if CID_RE.search(lines[i]):
|
|
491
|
+
errors.append(
|
|
492
|
+
f"L{i + 1}: Surface names a check id, but checks.md does not exist yet - each route's "
|
|
493
|
+
"statuses become a Coverage set there instead"
|
|
494
|
+
)
|
|
495
|
+
break
|
|
496
|
+
if not rows and not declares_none:
|
|
497
|
+
errors.append("Surface has no route rows and does not state `None - nothing consumed outside`")
|
|
498
|
+
|
|
499
|
+
# Landing: the doors.
|
|
500
|
+
land = present.get("Landing")
|
|
501
|
+
if land:
|
|
502
|
+
body = [lines[i].strip() for i in range(*land) if lines[i].strip()]
|
|
503
|
+
rows_data = first_table(lines, land)
|
|
504
|
+
declares_none = any(NONE_RE.search(x) for x in body)
|
|
505
|
+
if not body:
|
|
506
|
+
errors.append("Landing section is empty - state `None - <why nothing here is one-way>`")
|
|
507
|
+
elif not rows_data and not declares_none:
|
|
508
|
+
errors.append(
|
|
509
|
+
"Landing has no door rows and does not state `None - <why>` - the omission has to be contestable"
|
|
510
|
+
)
|
|
511
|
+
for r in rows_data:
|
|
512
|
+
cells = split_row(r)
|
|
513
|
+
if len(cells) >= 3 and cells[0] and not PLACEHOLDER_RE.match(cells[0]):
|
|
514
|
+
if not cells[1] or PLACEHOLDER_RE.match(cells[1]):
|
|
515
|
+
errors.append(f"Landing '{cells[0][:40]}': no literal shape - the next person copies this")
|
|
516
|
+
if not cells[2] or PLACEHOLDER_RE.match(cells[2]):
|
|
517
|
+
errors.append(f"Landing '{cells[0][:40]}': no rejected alternative named")
|
|
518
|
+
|
|
519
|
+
# Impact: a section with no rows is a sweep nobody did.
|
|
520
|
+
imp = present.get("Impact")
|
|
521
|
+
if imp:
|
|
522
|
+
body = [lines[i].strip() for i in range(*imp) if lines[i].strip()]
|
|
523
|
+
if body and not first_table(lines, imp) and not any(x.startswith(("-", "*")) for x in body):
|
|
524
|
+
warnings.append("Impact has neither rows nor bullets - name the fronts, even to say nothing changes")
|
|
525
|
+
|
|
526
|
+
# Flow: a module that neither exists nor is created by a door is placement, which this
|
|
527
|
+
# artifact deliberately leaves to the diff. Naming one here is how the catalogue creeps back.
|
|
528
|
+
flow = present.get("Flow")
|
|
529
|
+
if flow:
|
|
530
|
+
land_text = "\n".join(lines[i] for i in range(*land)).lower() if land else ""
|
|
531
|
+
for i in range(*flow):
|
|
532
|
+
ln = lines[i].strip()
|
|
533
|
+
if not re.match(r"^\s*(\d+\.|[-*])\s", ln):
|
|
534
|
+
continue
|
|
535
|
+
if HOP_RESOLVED_RE.search(ln):
|
|
536
|
+
continue
|
|
537
|
+
_warn_unresolved(warnings, i, MODULE_RE.findall(ln), land_text, "hop")
|
|
538
|
+
|
|
539
|
+
# A branching path is drawn instead of listed, and the same rule holds inside the
|
|
540
|
+
# diagram: a node that neither exists nor is created by a door is placement.
|
|
541
|
+
for i in range(*flow):
|
|
542
|
+
ln = lines[i]
|
|
543
|
+
if not EDGE_LINE_RE.search(ln) or HOP_RESOLVED_RE.search(ln):
|
|
544
|
+
continue
|
|
545
|
+
labels = [lbl.strip() for lbl in NODE_LABEL_RE.findall(ln) if lbl.strip()]
|
|
546
|
+
_warn_unresolved(warnings, i, labels, land_text, "diagram node")
|
|
547
|
+
|
|
548
|
+
return errors, warnings
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def main(argv=None):
|
|
552
|
+
p = argparse.ArgumentParser(
|
|
553
|
+
prog="validate_plan.py",
|
|
554
|
+
description="Pre-checks gate for a feature plan.md: the criteria, and the shape they land in.",
|
|
555
|
+
)
|
|
556
|
+
p.add_argument("target", nargs="?", default=None)
|
|
557
|
+
p.add_argument("--root", default=".")
|
|
558
|
+
p.add_argument("--strict", action="store_true")
|
|
559
|
+
args = p.parse_args(argv)
|
|
560
|
+
|
|
561
|
+
path = resolve_plan(args.target, args.root)
|
|
562
|
+
if not path:
|
|
563
|
+
print(
|
|
564
|
+
"validate_plan: could not locate a plan.md. Pass a path or run from the project root.",
|
|
565
|
+
file=sys.stderr,
|
|
566
|
+
)
|
|
567
|
+
return 2
|
|
568
|
+
|
|
569
|
+
errors, warnings = check_file(path)
|
|
570
|
+
for w in warnings:
|
|
571
|
+
print(f" WARN {w}")
|
|
572
|
+
for e in errors:
|
|
573
|
+
print(f" ERROR {e}")
|
|
574
|
+
fail = errors or (warnings and args.strict)
|
|
575
|
+
print(f"\nvalidate_plan: {len(errors)} error(s), {len(warnings)} warning(s) in {path}")
|
|
576
|
+
return 1 if fail else 0
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
if __name__ == "__main__":
|
|
580
|
+
raise SystemExit(main())
|