-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathspec_parser.py
More file actions
288 lines (237 loc) · 9.84 KB
/
Copy pathspec_parser.py
File metadata and controls
288 lines (237 loc) · 9.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
"""Shared OpenSpec spec parser and semantic diff.
Importable by both ``tools.py`` (agent tool handlers) and
``dashboard/plugin_api.py`` (dashboard backend) so the spec parsing
and diffing logic lives in one place.
The parser mirrors the frontend ``parseSpec`` function in
``dashboard/dist/index.js`` — same state machine, same output structure.
"""
from __future__ import annotations
import difflib
from typing import Any
def parse_spec(md: str | None) -> dict[str, Any]:
"""Parse OpenSpec spec markdown into structured data.
Returns ``{ title, purpose, requirements: [...] }`` where each
requirement is ``{ name, description, scenarios: [...] }`` and each
scenario is ``{ name, steps: [{ type, text }] }``.
"""
if not md:
return {"title": "", "purpose": "", "requirements": []}
lines = md.split("\n")
title = ""
purpose = ""
reqs: list[dict[str, Any]] = []
state = "top" # top | purpose | reqDesc | scenario
current_req: dict[str, Any] | None = None
current_scn: dict[str, Any] | None = None
for line in lines:
trim = line.strip()
# Title: "# Title" but not "## Title"
if trim.startswith("# ") and not trim.startswith("## "):
title = trim[2:].strip().removesuffix("Specification").strip()
state = "top"
continue
if trim.lower().startswith("## purpose"):
state = "purpose"
continue
if trim.lower().startswith("## requirements"):
state = "top"
continue
m_req = re_match(r"^###\s+Requirement:\s*(.+)$", trim)
if m_req:
current_req = {"name": m_req.group(1).strip(), "description": "", "scenarios": []}
reqs.append(current_req)
current_scn = None
state = "reqDesc"
continue
m_scn = re_match(r"^####\s+Scenario:\s*(.+)$", trim)
if m_scn:
current_scn = {"name": m_scn.group(1).strip(), "steps": []}
if current_req is not None:
current_req["scenarios"].append(current_scn)
state = "scenario"
continue
# Accumulate text based on state
if not trim:
continue
if state == "purpose":
purpose = f"{purpose}\n{trim}" if purpose else trim
elif state == "reqDesc":
if current_req is not None:
current_req["description"] = (
f"{current_req['description']}\n{trim}"
if current_req["description"]
else trim
)
elif state == "scenario":
if current_scn is not None:
step = re_match(r"^[-*]\s+\*\*(\w+)\*\*\s*(.+)$", trim)
if step:
current_scn["steps"].append({
"type": step.group(1).upper(),
"text": step.group(2).strip(),
})
elif current_scn["steps"]:
# Non-bullet text inside scenario — append to last step
current_scn["steps"][-1]["text"] += f" {trim}"
return {"title": title, "purpose": purpose, "requirements": reqs}
def _scenario_key(scn: dict[str, Any]) -> str:
"""Normalize a scenario for comparison by name."""
return scn["name"]
def _requirement_key(req: dict[str, Any]) -> str:
"""Normalize a requirement for comparison by name."""
return req["name"]
def _scenarios_equal(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool:
if a is None or b is None:
return a is b
return a["name"] == b["name"] and a["steps"] == b["steps"]
def _requirements_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
return (
a["name"] == b["name"]
and a["description"] == b["description"]
and a["scenarios"] == b["scenarios"]
)
def semantic_spec_diff(before_md: str | None, after_md: str | None) -> dict[str, Any]:
"""Compare two spec markdown strings at the requirement/scenario level.
Returns a structured delta::
{
"status": "added" | "modified" | "deleted" | "unchanged",
"requirements": {
"added": [...],
"modified": [{ name, before, after, scenarios_added, ... }],
"removed": [...],
"unchanged": ["name", ...],
},
}
"""
before = parse_spec(before_md)
after = parse_spec(after_md)
before_reqs = {r["name"]: r for r in before["requirements"]}
after_reqs = {r["name"]: r for r in after["requirements"]}
added: list[dict[str, Any]] = []
modified: list[dict[str, Any]] = []
removed: list[dict[str, Any]] = []
unchanged: list[str] = []
all_names = set(before_reqs) | set(after_reqs)
for name in sorted(all_names):
b_req = before_reqs.get(name)
a_req = after_reqs.get(name)
if b_req is None and a_req is not None:
added.append(a_req)
elif b_req is not None and a_req is None:
removed.append(b_req)
elif b_req is not None and a_req is not None:
if _requirements_equal(b_req, a_req):
unchanged.append(name)
else:
# Diff scenarios within the modified requirement
b_scns = {s["name"]: s for s in b_req["scenarios"]}
a_scns = {s["name"]: s for s in a_req["scenarios"]}
scn_added: list[dict[str, Any]] = []
scn_modified: list[dict[str, Any]] = []
scn_removed: list[dict[str, Any]] = []
for scn_name in sorted(set(b_scns) | set(a_scns)):
b_scn = b_scns.get(scn_name)
a_scn = a_scns.get(scn_name)
if b_scn is None and a_scn is not None:
scn_added.append(a_scn)
elif b_scn is not None and a_scn is None:
scn_removed.append(b_scn)
elif b_scn is not None and a_scn is not None:
if not _scenarios_equal(b_scn, a_scn):
scn_modified.append({
"name": scn_name,
"before": b_scn,
"after": a_scn,
})
modified.append({
"name": name,
"before": {
"description": b_req["description"],
"scenarios": b_req["scenarios"],
},
"after": {
"description": a_req["description"],
"scenarios": a_req["scenarios"],
},
"scenarios_added": scn_added,
"scenarios_modified": scn_modified,
"scenarios_removed": scn_removed,
})
# Overall status
if not before_md and not after_md:
status = "unchanged"
elif not before_md:
status = "added"
elif not after_md:
status = "deleted"
elif not added and not modified and not removed:
status = "unchanged"
else:
status = "modified"
return {
"status": status,
"requirements": {
"added": added,
"modified": modified,
"removed": removed,
"unchanged": unchanged,
},
}
def semantic_summary(diff: dict[str, Any]) -> dict[str, int]:
"""Extract compact counts from a semantic diff for list display."""
reqs = diff.get("requirements", {})
return {
"added": len(reqs.get("added", [])),
"modified": len(reqs.get("modified", [])),
"removed": len(reqs.get("removed", [])),
}
def unified_diff(before_md: str | None, after_md: str | None, path: str = "spec") -> str:
"""Return a unified line diff for fallback display."""
return "\n".join(
difflib.unified_diff(
(before_md or "").splitlines(),
(after_md or "").splitlines(),
fromfile=f"before/{path}",
tofile=f"after/{path}",
lineterm="",
)
)
def spec_to_markdown(title: str, purpose: str, requirements: list[dict[str, Any]]) -> str:
"""Serialize structured spec data into OpenSpec spec markdown.
Inverse of ``parse_spec`` — takes ``{title, purpose, requirements}`` and
produces ``# Title`` / ``## Purpose`` / ``## Requirements`` /
``### Requirement:`` / ``#### Scenario:`` formatted markdown.
Each requirement is ``{name, description, scenarios}`` and each
scenario is ``{name, steps: [{type, text}]}``.
"""
lines: list[str] = [f"# {title}", ""]
if purpose:
lines.append("## Purpose")
lines.append("")
lines.append(purpose)
lines.append("")
if requirements:
lines.append("## Requirements")
lines.append("")
for req in requirements:
name = str(req.get("name") or "").strip()
desc = str(req.get("description") or "").strip()
lines.append(f"### Requirement: {name}")
if desc:
lines.append(desc)
lines.append("")
for scn in req.get("scenarios") or []:
scn_name = str(scn.get("name") or "").strip()
lines.append(f"#### Scenario: {scn_name}")
lines.append("")
for step in scn.get("steps") or []:
step_type = str(step.get("type") or "THEN").strip().upper()
step_text = str(step.get("text") or "").strip()
lines.append(f"- **{step_type}** {step_text}")
lines.append("")
# Trim trailing blank lines, ensure single trailing newline
return "\n".join(lines).rstrip() + "\n"
def re_match(pattern: str, string: str):
"""Wrapper to avoid importing re at module level in callers."""
import re
return re.match(pattern, string, re.IGNORECASE)