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.
@@ -0,0 +1,3 @@
1
+ """aspark-graph — a lean, local code-and-artifact knowledge graph that speaks SPARK."""
2
+
3
+ __version__ = "0.1.0"
@@ -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