aspark-graph 0.7.0__py3-none-any.whl
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.
- aspark_graph/__init__.py +3 -0
- aspark_graph/artifacts.py +365 -0
- aspark_graph/build.py +417 -0
- aspark_graph/cli.py +204 -0
- aspark_graph/confinement.py +116 -0
- aspark_graph/extractors/__init__.py +36 -0
- aspark_graph/extractors/base.py +62 -0
- aspark_graph/extractors/code_go.py +153 -0
- aspark_graph/extractors/code_java.py +74 -0
- aspark_graph/extractors/code_py.py +98 -0
- aspark_graph/extractors/code_rust.py +162 -0
- aspark_graph/extractors/code_ts.py +118 -0
- aspark_graph/git.py +103 -0
- aspark_graph/graph.py +156 -0
- aspark_graph/inference.py +124 -0
- aspark_graph/model.py +89 -0
- aspark_graph/parse_cache.py +161 -0
- aspark_graph/queries.py +417 -0
- aspark_graph/server.py +123 -0
- aspark_graph-0.7.0.dist-info/METADATA +314 -0
- aspark_graph-0.7.0.dist-info/RECORD +24 -0
- aspark_graph-0.7.0.dist-info/WHEEL +4 -0
- aspark_graph-0.7.0.dist-info/entry_points.txt +2 -0
- aspark_graph-0.7.0.dist-info/licenses/LICENSE +21 -0
aspark_graph/__init__.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""aSPARK ``.spark/`` artifact parser — the crown jewel.
|
|
2
|
+
|
|
3
|
+
Parses the five aSPARK templates (spec / plan / review / qa / release) into the
|
|
4
|
+
graph's artifact layer and links them with *declared* edges. It is pinned to a
|
|
5
|
+
supported template shape and **fails loudly on drift** (AC-1.3): a structurally
|
|
6
|
+
malformed artifact raises :class:`TemplateDriftError` naming the file and the
|
|
7
|
+
mismatch — it never silently skips or guesses.
|
|
8
|
+
|
|
9
|
+
Deterministic and offline. Feature directories and rows are processed in sorted
|
|
10
|
+
order so the artifact layer is byte-stable like the code layer (AC-1.2).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .graph import Graph
|
|
19
|
+
from .model import (
|
|
20
|
+
Confidence,
|
|
21
|
+
EdgeType,
|
|
22
|
+
NodeType,
|
|
23
|
+
ac_id,
|
|
24
|
+
feature_id,
|
|
25
|
+
file_id,
|
|
26
|
+
finding_id,
|
|
27
|
+
qacheck_id,
|
|
28
|
+
story_id,
|
|
29
|
+
task_id,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# The template shape this parser understands. There is no version string in the
|
|
33
|
+
# artifacts themselves, so drift is detected structurally against this pin.
|
|
34
|
+
SUPPORTED_TEMPLATE = "aspark/0.1.0"
|
|
35
|
+
|
|
36
|
+
_SPARK_DIRNAME = ".spark"
|
|
37
|
+
|
|
38
|
+
_STORY_RE = re.compile(r"^###\s+(US-\d+)\s*\(([^)]*)\)\s*:\s*(.+?)\s*$")
|
|
39
|
+
_STORY_ANY_RE = re.compile(r"^###\s+US-", re.IGNORECASE)
|
|
40
|
+
_AC_RE = re.compile(r"^\s*-\s*\[[ xX]?\]\s*(AC-\d+\.\d+)\s*:\s*(.+?)\s*$")
|
|
41
|
+
_US_REF_RE = re.compile(r"(US-\d+)")
|
|
42
|
+
_FILES_NOTE_RE = re.compile(r"files:\s*([^|]+)", re.IGNORECASE)
|
|
43
|
+
_LOCATION_RE = re.compile(r"([\w./\-]+?\.[A-Za-z0-9]+)(?::(\d+))?")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TemplateDriftError(Exception):
|
|
47
|
+
"""A ``.spark/`` artifact does not match the supported template (AC-1.3)."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, file: str, mismatch: str) -> None:
|
|
50
|
+
self.file = file
|
|
51
|
+
self.mismatch = mismatch
|
|
52
|
+
super().__init__(f"template drift in {file}: {mismatch} (supported: {SUPPORTED_TEMPLATE})")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --- public entry point ----------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def extract_features(repo_root: Path, graph: Graph) -> int:
|
|
58
|
+
"""Parse every ``.spark/<feature>/`` trail into ``graph``. Returns node count."""
|
|
59
|
+
spark = Path(repo_root) / _SPARK_DIRNAME
|
|
60
|
+
if not spark.is_dir():
|
|
61
|
+
return 0
|
|
62
|
+
added = 0
|
|
63
|
+
for feature_dir in sorted(p for p in spark.iterdir() if p.is_dir()):
|
|
64
|
+
added += _parse_feature(feature_dir, graph)
|
|
65
|
+
return added
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_feature(feature_dir: Path, graph: Graph) -> int:
|
|
69
|
+
feature = feature_dir.name
|
|
70
|
+
fid = feature_id(feature)
|
|
71
|
+
added = 0
|
|
72
|
+
|
|
73
|
+
statuses: dict[str, str | None] = {}
|
|
74
|
+
version: str | None = None
|
|
75
|
+
|
|
76
|
+
spec = feature_dir / "spec.md"
|
|
77
|
+
plan = feature_dir / "plan.md"
|
|
78
|
+
review = feature_dir / "review-report.md"
|
|
79
|
+
qa = feature_dir / "qa-report.md"
|
|
80
|
+
release = feature_dir / "release-notes.md"
|
|
81
|
+
|
|
82
|
+
graph.add_node(fid, NodeType.FEATURE, name=feature)
|
|
83
|
+
added += 1
|
|
84
|
+
|
|
85
|
+
if spec.exists():
|
|
86
|
+
statuses["spec"] = _status(spec)
|
|
87
|
+
added += _parse_spec(spec, feature, fid, graph)
|
|
88
|
+
if plan.exists():
|
|
89
|
+
statuses["plan"] = _status(plan)
|
|
90
|
+
added += _parse_plan(plan, feature, fid, graph)
|
|
91
|
+
if review.exists():
|
|
92
|
+
statuses["review"] = _status(review)
|
|
93
|
+
added += _parse_review(review, feature, graph)
|
|
94
|
+
if qa.exists():
|
|
95
|
+
statuses["qa"] = _status(qa)
|
|
96
|
+
added += _parse_qa(qa, feature, graph)
|
|
97
|
+
if release.exists():
|
|
98
|
+
statuses["release"] = _status(release)
|
|
99
|
+
version = _release_version(release)
|
|
100
|
+
|
|
101
|
+
# Stamp phase statuses onto the Feature node (feeds gate_health / a board).
|
|
102
|
+
graph.add_node(
|
|
103
|
+
fid, NodeType.FEATURE, name=feature,
|
|
104
|
+
spec_status=statuses.get("spec"), plan_status=statuses.get("plan"),
|
|
105
|
+
review_status=statuses.get("review"), qa_status=statuses.get("qa"),
|
|
106
|
+
release_status=statuses.get("release"), version=version,
|
|
107
|
+
)
|
|
108
|
+
return added
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --- per-artifact parsers --------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def _parse_spec(path: Path, feature: str, fid: str, graph: Graph) -> int:
|
|
114
|
+
lines = _read(path).splitlines()
|
|
115
|
+
section = _section(lines, "user stories")
|
|
116
|
+
if section is None:
|
|
117
|
+
raise TemplateDriftError(str(path), "missing a '## … User Stories' section")
|
|
118
|
+
|
|
119
|
+
added = 0
|
|
120
|
+
current_story: str | None = None
|
|
121
|
+
saw_story = False
|
|
122
|
+
for line in section:
|
|
123
|
+
if _STORY_ANY_RE.match(line):
|
|
124
|
+
m = _STORY_RE.match(line)
|
|
125
|
+
if not m:
|
|
126
|
+
raise TemplateDriftError(
|
|
127
|
+
str(path),
|
|
128
|
+
f"user-story heading not in 'US-n (MoSCoW): title' form: {line.strip()!r}",
|
|
129
|
+
)
|
|
130
|
+
us, moscow, title = m.group(1), m.group(2).strip(), m.group(3).strip()
|
|
131
|
+
current_story = story_id(feature, us)
|
|
132
|
+
graph.add_node(
|
|
133
|
+
current_story, NodeType.STORY,
|
|
134
|
+
story=us, title=title, moscow=_moscow(moscow), feature=feature,
|
|
135
|
+
)
|
|
136
|
+
graph.add_edge(fid, current_story, EdgeType.HAS_STORY, Confidence.DECLARED)
|
|
137
|
+
added += 1
|
|
138
|
+
saw_story = True
|
|
139
|
+
continue
|
|
140
|
+
m = _AC_RE.match(line)
|
|
141
|
+
if m and current_story is not None:
|
|
142
|
+
ac, text = m.group(1), m.group(2).strip()
|
|
143
|
+
aid = ac_id(feature, ac)
|
|
144
|
+
graph.add_node(aid, NodeType.ACCEPTANCE_CRITERION, ac=ac, text=text, feature=feature, story=current_story)
|
|
145
|
+
graph.add_edge(current_story, aid, EdgeType.HAS_AC, Confidence.DECLARED)
|
|
146
|
+
added += 1
|
|
147
|
+
|
|
148
|
+
if not saw_story:
|
|
149
|
+
raise TemplateDriftError(str(path), "no user stories (US-n) found in the User Stories section")
|
|
150
|
+
return added
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parse_plan(path: Path, feature: str, fid: str, graph: Graph) -> int:
|
|
154
|
+
lines = _read(path).splitlines()
|
|
155
|
+
section = _section(lines, "task breakdown")
|
|
156
|
+
if section is None:
|
|
157
|
+
raise TemplateDriftError(str(path), "missing a '## … Task Breakdown' section")
|
|
158
|
+
table = _first_table(section)
|
|
159
|
+
if table is None:
|
|
160
|
+
raise TemplateDriftError(str(path), "Task Breakdown section has no task table")
|
|
161
|
+
header = {k.lower() for k in table["header"]}
|
|
162
|
+
for required in ("task", "story", "status"):
|
|
163
|
+
if not any(required in h for h in header):
|
|
164
|
+
raise TemplateDriftError(str(path), f"task table missing a '{required}' column (found {sorted(header)})")
|
|
165
|
+
|
|
166
|
+
added = 0
|
|
167
|
+
for row in table["rows"]:
|
|
168
|
+
tid_raw = _col(row, "#")
|
|
169
|
+
if not tid_raw or not re.match(r"^T\d+$", tid_raw):
|
|
170
|
+
continue # placeholder / non-task row
|
|
171
|
+
node_id = task_id(feature, tid_raw)
|
|
172
|
+
story_cell = _col(row, "story")
|
|
173
|
+
status = _strip_ticks(_col(row, "status"))
|
|
174
|
+
dod = _col(row, "definition of done") or _col(row, "definition")
|
|
175
|
+
graph.add_node(
|
|
176
|
+
node_id, NodeType.TASK,
|
|
177
|
+
task=tid_raw, status=status, dod=dod, feature=feature, story_ref=story_cell,
|
|
178
|
+
)
|
|
179
|
+
graph.add_edge(fid, node_id, EdgeType.HAS_TASK, Confidence.DECLARED)
|
|
180
|
+
added += 1
|
|
181
|
+
m = _US_REF_RE.search(story_cell or "")
|
|
182
|
+
if m:
|
|
183
|
+
target = story_id(feature, m.group(1))
|
|
184
|
+
if graph.has_node(target):
|
|
185
|
+
graph.add_edge(node_id, target, EdgeType.MAPS_TO, Confidence.DECLARED)
|
|
186
|
+
# Best-effort implements (Q1): only where an explicit `files:` note exists
|
|
187
|
+
# in the task row and resolves to a real File node. No template change.
|
|
188
|
+
for rel in _files_note(dod):
|
|
189
|
+
if graph.has_node(file_id(rel)):
|
|
190
|
+
graph.add_edge(node_id, file_id(rel), EdgeType.IMPLEMENTS, Confidence.DECLARED)
|
|
191
|
+
return added
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _parse_review(path: Path, feature: str, graph: Graph) -> int:
|
|
195
|
+
lines = _read(path).splitlines()
|
|
196
|
+
section = _section(lines, "findings")
|
|
197
|
+
if section is None:
|
|
198
|
+
raise TemplateDriftError(str(path), "missing a '## … Findings' section")
|
|
199
|
+
table = _first_table(section)
|
|
200
|
+
if table is None:
|
|
201
|
+
return 0 # a review with no findings table logged is not drift
|
|
202
|
+
header = {k.lower() for k in table["header"]}
|
|
203
|
+
for required in ("severity", "location", "status"):
|
|
204
|
+
if not any(required in h for h in header):
|
|
205
|
+
raise TemplateDriftError(str(path), f"findings table missing a '{required}' column (found {sorted(header)})")
|
|
206
|
+
|
|
207
|
+
added = 0
|
|
208
|
+
for row in table["rows"]:
|
|
209
|
+
fid_raw = _col(row, "#")
|
|
210
|
+
if not fid_raw or not re.match(r"^F\d+$", fid_raw):
|
|
211
|
+
continue
|
|
212
|
+
node_id = finding_id(feature, fid_raw)
|
|
213
|
+
location = _strip_ticks(_col(row, "location"))
|
|
214
|
+
graph.add_node(
|
|
215
|
+
node_id, NodeType.FINDING,
|
|
216
|
+
finding=fid_raw, severity=_col(row, "severity"),
|
|
217
|
+
location=location, status=_strip_ticks(_col(row, "status")), feature=feature,
|
|
218
|
+
)
|
|
219
|
+
added += 1
|
|
220
|
+
rel = _location_file(location)
|
|
221
|
+
if rel and graph.has_node(file_id(rel)):
|
|
222
|
+
graph.add_edge(node_id, file_id(rel), EdgeType.FOUND_IN, Confidence.DECLARED)
|
|
223
|
+
return added
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _parse_qa(path: Path, feature: str, graph: Graph) -> int:
|
|
227
|
+
lines = _read(path).splitlines()
|
|
228
|
+
section = _section(lines, "acceptance criteria verification")
|
|
229
|
+
if section is None:
|
|
230
|
+
raise TemplateDriftError(str(path), "missing a '## … Acceptance Criteria Verification' section")
|
|
231
|
+
table = _first_table(section)
|
|
232
|
+
if table is None:
|
|
233
|
+
return 0
|
|
234
|
+
header = {k.lower() for k in table["header"]}
|
|
235
|
+
if not any("ac" == h or h.startswith("ac") for h in header):
|
|
236
|
+
raise TemplateDriftError(str(path), f"QA table missing an 'AC' column (found {sorted(header)})")
|
|
237
|
+
if not any("result" in h for h in header):
|
|
238
|
+
raise TemplateDriftError(str(path), f"QA table missing a 'Result' column (found {sorted(header)})")
|
|
239
|
+
|
|
240
|
+
added = 0
|
|
241
|
+
for index, row in enumerate(table["rows"]):
|
|
242
|
+
ac_cell = _col(row, "ac")
|
|
243
|
+
m = re.search(r"(AC-\d+\.\d+)", ac_cell or "")
|
|
244
|
+
if not m:
|
|
245
|
+
continue
|
|
246
|
+
ac = m.group(1)
|
|
247
|
+
result = _normalise_result(_col(row, "result"))
|
|
248
|
+
node_id = qacheck_id(feature, ac, index)
|
|
249
|
+
graph.add_node(
|
|
250
|
+
node_id, NodeType.QA_CHECK,
|
|
251
|
+
ac=ac, result=result, date=_col(row, "date"), feature=feature, order=index,
|
|
252
|
+
)
|
|
253
|
+
target = ac_id(feature, ac)
|
|
254
|
+
if graph.has_node(target):
|
|
255
|
+
graph.add_edge(node_id, target, EdgeType.VERIFIES, Confidence.DECLARED)
|
|
256
|
+
added += 1
|
|
257
|
+
return added
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# --- markdown helpers ------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def _read(path: Path) -> str:
|
|
263
|
+
return path.read_text(encoding="utf-8")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _status(path: Path) -> str | None:
|
|
267
|
+
m = re.search(r"\|\s*\*\*Status\*\*\s*\|\s*([^|]+?)\s*\|", _read(path))
|
|
268
|
+
return _strip_ticks(m.group(1)) if m else None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _release_version(path: Path) -> str | None:
|
|
272
|
+
m = re.search(r"\|\s*\*\*Version\*\*\s*\|\s*([^|]+?)\s*\|", _read(path))
|
|
273
|
+
return _strip_ticks(m.group(1)) if m else None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _section(lines: list[str], keyword: str) -> list[str] | None:
|
|
277
|
+
"""Lines under a ``##`` heading containing ``keyword`` until the next ``#``/``##``."""
|
|
278
|
+
start = None
|
|
279
|
+
for i, line in enumerate(lines):
|
|
280
|
+
if re.match(r"^##\s", line) and keyword.lower() in line.lower():
|
|
281
|
+
start = i + 1
|
|
282
|
+
break
|
|
283
|
+
if start is None:
|
|
284
|
+
return None
|
|
285
|
+
end = len(lines)
|
|
286
|
+
for j in range(start, len(lines)):
|
|
287
|
+
if re.match(r"^#{1,2}\s", lines[j]):
|
|
288
|
+
end = j
|
|
289
|
+
break
|
|
290
|
+
return lines[start:end]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _first_table(block: list[str]) -> dict | None:
|
|
294
|
+
"""First markdown pipe-table in ``block`` as {header: [...], rows: [{col: val}]}."""
|
|
295
|
+
rows: list[list[str]] = []
|
|
296
|
+
in_table = False
|
|
297
|
+
for line in block:
|
|
298
|
+
stripped = line.strip()
|
|
299
|
+
if stripped.startswith("|") and stripped.endswith("|"):
|
|
300
|
+
rows.append(_split_row(stripped))
|
|
301
|
+
in_table = True
|
|
302
|
+
elif in_table:
|
|
303
|
+
break # table ended
|
|
304
|
+
if len(rows) < 2:
|
|
305
|
+
return None
|
|
306
|
+
header = rows[0]
|
|
307
|
+
# rows[1] is the |---|---| separator
|
|
308
|
+
if not all(set(cell) <= set("-: ") for cell in rows[1]):
|
|
309
|
+
# No separator row → not a real table shape
|
|
310
|
+
return None
|
|
311
|
+
data = []
|
|
312
|
+
for raw in rows[2:]:
|
|
313
|
+
cells = raw + [""] * (len(header) - len(raw))
|
|
314
|
+
data.append({header[i].lower(): cells[i] for i in range(len(header))})
|
|
315
|
+
return {"header": header, "rows": data}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _split_row(line: str) -> list[str]:
|
|
319
|
+
return [c.strip() for c in line.strip().strip("|").split("|")]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _col(row: dict, name: str) -> str:
|
|
323
|
+
name = name.lower()
|
|
324
|
+
if name in row:
|
|
325
|
+
return row[name]
|
|
326
|
+
for k, v in row.items():
|
|
327
|
+
if name in k:
|
|
328
|
+
return v
|
|
329
|
+
return ""
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _strip_ticks(value: str | None) -> str | None:
|
|
333
|
+
if value is None:
|
|
334
|
+
return None
|
|
335
|
+
return value.strip().strip("`").strip()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _moscow(value: str) -> str:
|
|
339
|
+
return value.strip().split()[0].rstrip(",") if value.strip() else ""
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _normalise_result(cell: str) -> str:
|
|
343
|
+
low = (cell or "").lower()
|
|
344
|
+
if "✅" in cell or re.search(r"\bpass(ed|es|ing)?\b", low):
|
|
345
|
+
return "pass"
|
|
346
|
+
if "❌" in cell or re.search(r"\bfail(ed|s|ing|ure)?\b", low):
|
|
347
|
+
return "fail"
|
|
348
|
+
return "unknown"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _files_note(dod: str | None) -> list[str]:
|
|
352
|
+
"""Extract paths from an optional inline ``files: a.py, b.py`` note (Q1)."""
|
|
353
|
+
if not dod:
|
|
354
|
+
return []
|
|
355
|
+
m = _FILES_NOTE_RE.search(dod)
|
|
356
|
+
if not m:
|
|
357
|
+
return []
|
|
358
|
+
return [tok.strip().strip("`") for tok in re.split(r"[,\s]+", m.group(1)) if tok.strip()]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _location_file(location: str | None) -> str | None:
|
|
362
|
+
if not location:
|
|
363
|
+
return None
|
|
364
|
+
m = _LOCATION_RE.search(location)
|
|
365
|
+
return m.group(1) if m else None
|