@educa-corp/fw 0.8.1 → 0.11.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/CHANGELOG.md +30 -0
- package/commands/bdd.md +70 -0
- package/commands/prd.md +17 -22
- package/commands/product.md +10 -26
- package/docs/guide/02-khai-niem.md +3 -0
- package/docs/guide/README.md +10 -6
- package/docs/guide/cach-lam/quyet-finding-refine.md +3 -1
- package/docs/guide/lenh/bdd.md +94 -0
- package/docs/guide/lenh/prd-refine.md +7 -5
- package/docs/guide/lenh/prd-review.md +53 -0
- package/docs/guide/lenh/prd.md +4 -3
- package/docs/guide/vai-tro/po-ba.md +7 -5
- package/docs/guide/xu-ly-su-co.md +8 -0
- package/package.json +1 -1
- package/ref/bdd/change.md +47 -0
- package/ref/bdd/new.md +39 -0
- package/ref/bdd/writing.md +44 -0
- package/ref/common.md +47 -0
- package/ref/prd/change.md +3 -2
- package/ref/prd/new.md +6 -3
- package/ref/prd/refine.md +32 -10
- package/ref/prd/review.md +77 -0
- package/ref/product/epic.md +2 -0
- package/templates/bdd.feature +32 -0
- package/templates/prd.md +3 -0
- package/tools/spec_edit.py +692 -12
package/tools/spec_edit.py
CHANGED
|
@@ -11,15 +11,26 @@ Cách dùng (nội dung truyền qua stdin):
|
|
|
11
11
|
spec_edit.py --check
|
|
12
12
|
spec_edit.py create <file> stdin = toàn bộ nội dung; lỗi nếu file đã có
|
|
13
13
|
spec_edit.py edit <file> stdin = JSON [{"old": "...", "new": "...", "all": false}]
|
|
14
|
+
spec_edit.py edit --form <file> như edit, nhưng chặn mọi chỗ sửa làm đổi số, mã, dấu, nguồn (/prd review)
|
|
14
15
|
spec_edit.py set <file> key=value… sửa frontmatter
|
|
15
16
|
spec_edit.py pending <file> liệt kê các dòng còn dấu 🤖 (kèm số dòng)
|
|
16
|
-
spec_edit.py confirm <file> <mã>… đổi 🤖 → ✅ trên dòng chứa mã (vd AC2 BR8b ACT-03)
|
|
17
|
+
spec_edit.py confirm <file> <mã>… đổi 🤖 → ✅ trên dòng chứa mã (vd AC2 BR8b ACT-03), hoặc theo số dòng L24
|
|
17
18
|
spec_edit.py section <file> <mã> in đúng một mục theo mã `<!-- sec:… -->` (vd constraints)
|
|
18
19
|
spec_edit.py upgrade <file> gắn mã mục cho file theo khuôn cũ (chạy một lần)
|
|
19
20
|
spec_edit.py next-uc <thư mục specs> in mã UC kế tiếp (quét mọi prd.md)
|
|
21
|
+
spec_edit.py item <file> stdin = JSON [{"id": mã, "new": dòng} | {"after": mã, "new": dòng}] — sửa / thêm theo mã
|
|
22
|
+
spec_edit.py uc <file> <UC-…>… in đúng các UC đó
|
|
23
|
+
spec_edit.py finding <file> <F…>… in đúng các finding đó (file refine)
|
|
24
|
+
spec_edit.py decide <file> stdin = JSON [{"f": "F17", "decision": "nhận | sửa: … | bác: … | hoãn"}]
|
|
20
25
|
spec_edit.py flowcheck <prd> kiểm User flow: có sơ đồ, mọi UC có mặt trong một hành trình
|
|
21
26
|
spec_edit.py create --replace <file> chỉ cho file tạm trong .fw/tmp/ (kết quả của agent)
|
|
22
27
|
spec_edit.py coverage <epic> <prd> liệt kê BR / AC của epic chưa được PRD dùng tới
|
|
28
|
+
spec_edit.py where <specs> <UC-…> in đường dẫn PRD chứa UC đó
|
|
29
|
+
spec_edit.py context <prd> <UC-…> phần PRD cần cho BDD của một UC (Tổng quan, hành trình, màn hình, UC, mục được trỏ tới)
|
|
30
|
+
spec_edit.py next-sc <feature> mã kịch bản kế tiếp (tính cả mã đã bỏ)
|
|
31
|
+
spec_edit.py scenario <feature> <SC…>… in đúng các kịch bản đó
|
|
32
|
+
spec_edit.py sc <feature> stdin = JSON [{"id": mã, "new": khối} | {"after": mã | "end", "new": khối} | {"remove": mã, "version": "1.1"}]
|
|
33
|
+
spec_edit.py bddcheck <feature> [prd] kiểm khuôn, tag, độ phủ AC / BR của file BDD
|
|
23
34
|
|
|
24
35
|
Mã thoát: 0 = thành công · 1 = lỗi dữ liệu (không ghi gì) · 2 = sai cách dùng.
|
|
25
36
|
"""
|
|
@@ -38,9 +49,10 @@ FRONTMATTER = {
|
|
|
38
49
|
"epic": {"status": ["in-progress", "ready", "handed-off"], "checkpoint": (0, 3)},
|
|
39
50
|
"prd": {"status": ["draft", "approved"]},
|
|
40
51
|
"prd-refine": {"status": ["pending", "applied", "done"], "round": (1, 2)},
|
|
52
|
+
"bdd": {"status": ["draft", "approved"]},
|
|
41
53
|
}
|
|
42
54
|
# Trạng thái "đã xong": chỉ đặt được khi không còn 🤖 và không còn câu hỏi mở.
|
|
43
|
-
DONE_STATUS = {"product": "ready", "epic": "ready", "prd": "approved", "prd-refine": "done"}
|
|
55
|
+
DONE_STATUS = {"product": "ready", "epic": "ready", "prd": "approved", "prd-refine": "done", "bdd": "approved"}
|
|
44
56
|
COMMON_INT = {"open_questions": (0, 999)}
|
|
45
57
|
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
46
58
|
VERSION = re.compile(r"^\d+\.\d+$")
|
|
@@ -89,13 +101,30 @@ def cmd_create(path, replace=False):
|
|
|
89
101
|
if os.path.exists(path) and not replace:
|
|
90
102
|
fail(path + " đã tồn tại — không ghi đè. Dùng `edit` hoặc `set`.")
|
|
91
103
|
text = stdin_text()
|
|
104
|
+
if path.endswith(".feature"):
|
|
105
|
+
head = feature_header(text)
|
|
106
|
+
if head.get("type") != "bdd" or head.get("uc") != uc_of(path):
|
|
107
|
+
fail("file BDD phải mở đầu bằng header theo khuôn bdd.feature: `# type: bdd`, `# uc: %s`, …" % uc_of(path))
|
|
92
108
|
check_keys(file_type(text), [], sec_keys(text), creating=True)
|
|
93
109
|
check_sources(file_type(text), text)
|
|
94
110
|
write(path, text)
|
|
95
111
|
print("đã tạo " + path)
|
|
96
112
|
|
|
97
113
|
|
|
98
|
-
|
|
114
|
+
# Sửa hình thức (/prd review): không được đổi số, mã, dấu 🤖/✅, gạch ngang, nguồn — những thứ mang nghĩa.
|
|
115
|
+
FORM_KEEP = re.compile("UC-\\d{3}(?:-(?:BR|AC|SC)\\d+)?|(?:CON|ACT)-\\d+|\\b(?:BR|AC|F|J)\\d+[a-z]?\\b|\\d+(?:[.,]\\d+)*"
|
|
116
|
+
"|[\U0001F916✅]|~~|\\(nguồn:[^)]*\\)")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def form_changes(old, new):
|
|
120
|
+
"""Những thứ mang nghĩa bị thêm / mất khi đổi old → new. Rỗng = chỉ đổi hình thức."""
|
|
121
|
+
a, b = sorted(FORM_KEEP.findall(old)), sorted(FORM_KEEP.findall(new))
|
|
122
|
+
lost = [x for x in a if a.count(x) > b.count(x)]
|
|
123
|
+
added = [x for x in b if b.count(x) > a.count(x)]
|
|
124
|
+
return sorted(set(lost)), sorted(set(added))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_edit(path, form=False):
|
|
99
128
|
try:
|
|
100
129
|
edits = json.loads(stdin_text())
|
|
101
130
|
except json.JSONDecodeError as e:
|
|
@@ -116,6 +145,12 @@ def cmd_edit(path):
|
|
|
116
145
|
if n > 1 and not replace_all:
|
|
117
146
|
fail("chỗ sửa #%d: đoạn cần sửa xuất hiện %d lần. Thêm ngữ cảnh cho đoạn `old` đủ duy nhất, "
|
|
118
147
|
"hoặc đặt \"all\": true.\n old = %r" % (i, n, old[:120]))
|
|
148
|
+
if form:
|
|
149
|
+
lost, added = form_changes(old, e["new"])
|
|
150
|
+
if lost or added:
|
|
151
|
+
fail("chỗ sửa #%d không phải sửa hình thức: %s%s. Số, mã, dấu 🤖/✅, gạch ngang và nguồn phải giữ nguyên. "
|
|
152
|
+
"Sửa làm đổi nghĩa thì ghi thành ghi chú, đi qua `/prd … change`.\n old = %r"
|
|
153
|
+
% (i, ("mất " + " ".join(lost)) if lost else "", (" · thêm " + " ".join(added)) if added else "", old[:120]))
|
|
119
154
|
before = sec_keys(text)
|
|
120
155
|
for e in edits:
|
|
121
156
|
text = text.replace(e["old"], e["new"]) if e.get("all") else text.replace(e["old"], e["new"], 1)
|
|
@@ -148,8 +183,9 @@ def validate(ftype, key, value):
|
|
|
148
183
|
fail("approved_at=%s không hợp lệ. Định dạng YYYY-MM-DD, hoặc — nếu chưa duyệt" % value)
|
|
149
184
|
if key in ("version", "prd_version") and not VERSION.match(value):
|
|
150
185
|
fail("%s=%s không hợp lệ. Định dạng số.số, ví dụ 1.0, 1.1" % (key, value))
|
|
151
|
-
if key
|
|
152
|
-
fail("
|
|
186
|
+
if key in ("refined", "reviewed") and value != "—" and not VERSION.match(value):
|
|
187
|
+
fail("%s=%s không hợp lệ. Là version PRD đã %s xong (vd 1.1), hoặc —"
|
|
188
|
+
% (key, value, "refine" if key == "refined" else "rà hình thức"))
|
|
153
189
|
|
|
154
190
|
|
|
155
191
|
# ── Refine: finding critical / major chưa có quyết định thì chưa được đi tiếp ──────────────
|
|
@@ -201,6 +237,7 @@ def cmd_flowcheck(path):
|
|
|
201
237
|
if problems:
|
|
202
238
|
fail("\n - ".join([""] + problems))
|
|
203
239
|
print("User flow đủ — mọi UC đều có mặt trong ít nhất một hành trình")
|
|
240
|
+
size_warning(path)
|
|
204
241
|
|
|
205
242
|
|
|
206
243
|
def refine_path(prd_path):
|
|
@@ -224,6 +261,15 @@ def guard_refined(path, block):
|
|
|
224
261
|
% (", ".join(pending), rp))
|
|
225
262
|
|
|
226
263
|
|
|
264
|
+
def guard_reviewed(block):
|
|
265
|
+
"""PRD chỉ được duyệt khi đã rà hình thức (/prd review) đúng version đang có. Review đi sau refine."""
|
|
266
|
+
ver = re.search(r"^version:\s*(\S+)", block, re.M)
|
|
267
|
+
rev = re.search(r"^reviewed:\s*(\S+)", block, re.M)
|
|
268
|
+
if not rev or not ver or rev.group(1) != ver.group(1):
|
|
269
|
+
fail("không đặt được status=approved: bản v%s chưa rà hình thức (reviewed=%s). Chạy `/prd <EPIC-ID> review` trước."
|
|
270
|
+
% (ver.group(1) if ver else "?", rev.group(1) if rev else "chưa có"))
|
|
271
|
+
|
|
272
|
+
|
|
227
273
|
def guard_done(ftype, block, body, path):
|
|
228
274
|
"""Chặn đặt trạng thái 'đã xong' khi còn mục chờ PO — AI không được tự khẳng định."""
|
|
229
275
|
done = DONE_STATUS.get(ftype)
|
|
@@ -244,18 +290,36 @@ def guard_done(ftype, block, body, path):
|
|
|
244
290
|
fail("không đặt được status=%s: chưa có %s. Đặt cùng lệnh, ví dụ: "
|
|
245
291
|
"set <file> status=%s approved_by=<tên> approved_at=YYYY-MM-DD" % (done, field, done))
|
|
246
292
|
if ftype == "prd":
|
|
293
|
+
problems = platform_problems(read(path))
|
|
294
|
+
if problems:
|
|
295
|
+
fail("không đặt được status=approved:\n - " + "\n - ".join(problems))
|
|
247
296
|
guard_refined(path, block)
|
|
297
|
+
guard_reviewed(block)
|
|
298
|
+
if ftype == "bdd":
|
|
299
|
+
guard_bdd(path, block)
|
|
248
300
|
if ftype == "prd-refine" and undecided_findings(body):
|
|
249
301
|
fail("không đặt được status=done: còn finding critical/major chưa có quyết định: %s"
|
|
250
302
|
% ", ".join(undecided_findings(body)))
|
|
251
303
|
|
|
252
304
|
|
|
305
|
+
def header_span(path, text):
|
|
306
|
+
"""(đầu, cuối, chỗ bắt đầu thân file, tiền tố dòng) của khối frontmatter.
|
|
307
|
+
File .feature không có `---` (Gherkin không đọc được), header là các dòng `# key: value` ở đầu file."""
|
|
308
|
+
if path.endswith(".feature"):
|
|
309
|
+
m = FEATURE_HEADER.match(text)
|
|
310
|
+
if not m:
|
|
311
|
+
fail("file .feature không có header (các dòng `# key: value` ở đầu file, theo khuôn bdd.feature)")
|
|
312
|
+
return 0, m.end(), m.end(), "# "
|
|
313
|
+
m = split_frontmatter(text)
|
|
314
|
+
return m.start(1), m.end(1), m.end(), ""
|
|
315
|
+
|
|
316
|
+
|
|
253
317
|
def cmd_set(path, pairs):
|
|
254
318
|
if not pairs:
|
|
255
319
|
fail("cần ít nhất một cặp key=value", 2)
|
|
256
320
|
text = read(path)
|
|
257
|
-
|
|
258
|
-
block =
|
|
321
|
+
start, end, body_at, prefix = header_span(path, text)
|
|
322
|
+
block = "\n".join(l[len(prefix):] if l.startswith(prefix) else l for l in text[start:end].split("\n"))
|
|
259
323
|
tm = re.search(r"^type:\s*(\S+)", block, re.M)
|
|
260
324
|
ftype = tm.group(1) if tm else None
|
|
261
325
|
|
|
@@ -271,8 +335,10 @@ def cmd_set(path, pairs):
|
|
|
271
335
|
# Giữ nguyên comment đi kèm ở cuối dòng, chỉ đổi giá trị.
|
|
272
336
|
block = line.sub(lambda mm: mm.group(1) + value + (mm.group(3) or ""), block, count=1)
|
|
273
337
|
|
|
274
|
-
guard_done(ftype, block, text[
|
|
275
|
-
|
|
338
|
+
guard_done(ftype, block, text[body_at:], path)
|
|
339
|
+
if prefix:
|
|
340
|
+
block = "\n".join(prefix + l if l else l for l in block.split("\n"))
|
|
341
|
+
write(path, text[:start] + block + text[end:])
|
|
276
342
|
print("đã đặt %s trong %s" % (", ".join(pairs), path))
|
|
277
343
|
|
|
278
344
|
|
|
@@ -300,7 +366,7 @@ def sec_keys(text):
|
|
|
300
366
|
|
|
301
367
|
|
|
302
368
|
def file_type(text):
|
|
303
|
-
m = re.search(r"^type:\s*(\S+)", text, re.M)
|
|
369
|
+
m = re.search(r"^(?:# )?type:\s*(\S+)", text, re.M)
|
|
304
370
|
return m.group(1) if m else None
|
|
305
371
|
|
|
306
372
|
|
|
@@ -448,6 +514,120 @@ def cmd_upgrade(path):
|
|
|
448
514
|
+ " · ".join("%s (sec:%s)" % (orig, k) for k, orig in missing))
|
|
449
515
|
|
|
450
516
|
|
|
517
|
+
# ── Theo mã: sửa / thêm một dòng BR, AC; đọc một UC, một finding; ghi quyết định ──────────
|
|
518
|
+
# Dòng có mã ở đầu: "| ✅ UC-003-BR10 | …" (bảng BR) · "- ✅ UC-003-AC01. …" (AC) · có thể có ~~ khi đã bỏ.
|
|
519
|
+
def item_line_re(code):
|
|
520
|
+
return re.compile(r"^(?:\|\s*|-\s+)(?:[\U0001F916\u2705]\s*)?(?:~~)?%s(?![\w-])" % re.escape(code))
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def find_item(lines, code):
|
|
524
|
+
pat = item_line_re(code)
|
|
525
|
+
return [i for i, l in enumerate(lines) if pat.match(l)]
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def cmd_item(path):
|
|
529
|
+
"""stdin = JSON [{"id": mã, "new": dòng mới} | {"after": mã, "new": dòng(s) mới}]. Sai một là không ghi gì."""
|
|
530
|
+
try:
|
|
531
|
+
ops = json.loads(stdin_text())
|
|
532
|
+
except json.JSONDecodeError as e:
|
|
533
|
+
fail("stdin không phải JSON hợp lệ: " + str(e), 2)
|
|
534
|
+
if not isinstance(ops, list) or not ops:
|
|
535
|
+
fail('stdin phải là danh sách [{"id": …, "new": …}] hoặc [{"after": …, "new": …}]', 2)
|
|
536
|
+
text = read(path)
|
|
537
|
+
before = sec_keys(text)
|
|
538
|
+
lines = text.split("\n")
|
|
539
|
+
for n, op in enumerate(ops, 1):
|
|
540
|
+
new = op.get("new")
|
|
541
|
+
if not isinstance(new, str) or not new.strip():
|
|
542
|
+
fail("thao tác #%d thiếu `new`" % n, 2)
|
|
543
|
+
code = op.get("id") or op.get("after")
|
|
544
|
+
if not code or ("id" in op) == ("after" in op):
|
|
545
|
+
fail("thao tác #%d phải có đúng một trong `id` (sửa) hoặc `after` (thêm sau)" % n, 2)
|
|
546
|
+
hits = find_item(lines, code)
|
|
547
|
+
if len(hits) != 1:
|
|
548
|
+
fail("thao tác #%d: mã %s khớp %d dòng (cần đúng 1). Dùng `SE uc` để xem lại." % (n, code, len(hits)))
|
|
549
|
+
if "id" in op:
|
|
550
|
+
if "\n" in new:
|
|
551
|
+
fail("thao tác #%d: sửa theo `id` chỉ nhận một dòng" % n, 2)
|
|
552
|
+
if not item_line_re(code).match(new):
|
|
553
|
+
fail("thao tác #%d: dòng mới phải giữ nguyên mã %s ở đầu dòng (mã không bao giờ đổi)" % (n, code))
|
|
554
|
+
lines[hits[0]] = new
|
|
555
|
+
else:
|
|
556
|
+
for nl in new.split("\n"):
|
|
557
|
+
m = re.match(r"^(?:\|\s*|-\s+)(?:[\U0001F916\u2705]\s*)?(?:~~)?(UC-\d{3}-(?:BR|AC)\d+)", nl)
|
|
558
|
+
if m and find_item(lines, m.group(1)):
|
|
559
|
+
fail("thao tác #%d: mã %s đã có — mã không bao giờ dùng lại, lấy số kế tiếp" % (n, m.group(1)))
|
|
560
|
+
lines[hits[0] + 1:hits[0] + 1] = new.split("\n")
|
|
561
|
+
out = "\n".join(lines)
|
|
562
|
+
check_keys(file_type(out), before, sec_keys(out), creating=False)
|
|
563
|
+
check_sources(file_type(out), out)
|
|
564
|
+
write(path, out)
|
|
565
|
+
print("đã áp %d thao tác theo mã trong %s" % (len(ops), path))
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def print_blocks(path, codes, start_re, kind):
|
|
569
|
+
lines = read(path).split("\n")
|
|
570
|
+
for code in codes:
|
|
571
|
+
pat = start_re(code)
|
|
572
|
+
hits = [i for i, l in enumerate(lines) if pat.match(l)]
|
|
573
|
+
if not hits:
|
|
574
|
+
fail("không có %s %s trong %s" % (kind, code, path))
|
|
575
|
+
i = hits[0]
|
|
576
|
+
end = next((j for j in range(i + 1, len(lines)) if re.match(r"^#{2,3}\s", lines[j])), len(lines))
|
|
577
|
+
print("\n".join(lines[i:end]).rstrip())
|
|
578
|
+
print()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def cmd_uc(path, codes):
|
|
582
|
+
if not codes:
|
|
583
|
+
fail("cách dùng: uc <file> UC-003 [UC-005 …]", 2)
|
|
584
|
+
print_blocks(path, codes, lambda c: re.compile(r"^###\s+(?:~~)?%s(?![\d-])" % re.escape(c)), "UC")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def cmd_finding(path, codes):
|
|
588
|
+
if not codes:
|
|
589
|
+
fail("cách dùng: finding <file refine> F17 [F18 …]", 2)
|
|
590
|
+
print_blocks(path, codes, lambda c: re.compile(r"^###\s+%s\s·" % re.escape(c)), "finding")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
DECISION_VALUE = re.compile(r"^(nhận|hoãn|chờ|sửa:\s*\S.*|bác:\s*\S.*)$")
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def cmd_decide(path):
|
|
597
|
+
"""stdin = JSON [{"f": "F17", "decision": "nhận" | "sửa: …" | "bác: lý do" | "hoãn"}]. Ghi ngay sau mỗi đợt PO quyết."""
|
|
598
|
+
try:
|
|
599
|
+
ops = json.loads(stdin_text())
|
|
600
|
+
except json.JSONDecodeError as e:
|
|
601
|
+
fail("stdin không phải JSON hợp lệ: " + str(e), 2)
|
|
602
|
+
if not isinstance(ops, list) or not ops:
|
|
603
|
+
fail('stdin phải là danh sách [{"f": "F17", "decision": "…"}]', 2)
|
|
604
|
+
lines = read(path).split("\n")
|
|
605
|
+
for n, op in enumerate(ops, 1):
|
|
606
|
+
code, dec = op.get("f"), (op.get("decision") or "").strip()
|
|
607
|
+
if not DECISION_VALUE.match(dec):
|
|
608
|
+
fail("quyết định #%d (%s) không hợp lệ: \"%s\". Nhận: nhận · sửa: <nội dung> · bác: <lý do> · hoãn" % (n, code, dec))
|
|
609
|
+
head = [i for i, l in enumerate(lines) if re.match(r"^###\s+%s\s·" % re.escape(code or "?"), l)]
|
|
610
|
+
if len(head) != 1:
|
|
611
|
+
fail("quyết định #%d: không tìm thấy đúng một finding %s" % (n, code))
|
|
612
|
+
end = next((j for j in range(head[0] + 1, len(lines)) if re.match(r"^#{2,3}\s", lines[j])), len(lines))
|
|
613
|
+
dl = [j for j in range(head[0], end) if lines[j].startswith("- Quyết định:")]
|
|
614
|
+
if len(dl) != 1:
|
|
615
|
+
fail("finding %s không có đúng một dòng `- Quyết định:`" % code)
|
|
616
|
+
lines[dl[0]] = "- Quyết định: " + dec
|
|
617
|
+
write(path, "\n".join(lines))
|
|
618
|
+
print("đã ghi %d quyết định vào %s" % (len(ops), path))
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
PRD_SIZE_WARN = 60 * 1024
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def size_warning(path):
|
|
625
|
+
size = os.path.getsize(path)
|
|
626
|
+
if size > PRD_SIZE_WARN:
|
|
627
|
+
print("⚠ PRD %d KB (ngưỡng %d KB): mọi bước sau đều nạp toàn bộ PRD. Cân nhắc tách epic thành nhiều PRD nhỏ hơn."
|
|
628
|
+
% (size // 1024, PRD_SIZE_WARN // 1024))
|
|
629
|
+
|
|
630
|
+
|
|
451
631
|
def cmd_next_uc(specs_dir):
|
|
452
632
|
if not os.path.isdir(specs_dir):
|
|
453
633
|
fail("không có thư mục " + specs_dir)
|
|
@@ -481,6 +661,470 @@ def cmd_coverage(epic_path, prd_path):
|
|
|
481
661
|
if problems:
|
|
482
662
|
fail("\n - ".join([""] + problems))
|
|
483
663
|
print("đủ — mọi BR/AC của epic đều có trong PRD")
|
|
664
|
+
size_warning(prd_path)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# ── BDD: một file .feature cho mỗi UC, tag Gherkin thật (xem docs/design/bdd.md) ──────────
|
|
668
|
+
FEATURE_HEADER = re.compile(r"(?:# [a-z_]+:[^\n]*\n)+")
|
|
669
|
+
ITEM_CODE = re.compile(r"UC-\d{3}-(?:BR|AC)\d+")
|
|
670
|
+
ITEM_LINE = re.compile("^(?:\\|\\s*|-\\s+)(?:[\U0001F916\u2705]\\s*)?(~~)?(UC-\\d{3}-(?:BR|AC)\\d+)")
|
|
671
|
+
SC_TAG = re.compile(r"@(UC-\d{3}-SC\d+)(?![\w-])")
|
|
672
|
+
SC_ANY = re.compile(r"(UC-(\d{3})-SC(\d+))(?![\w-])")
|
|
673
|
+
SC_TYPES = ("happy", "alternative", "edge", "negative")
|
|
674
|
+
SCENARIO_LINE = re.compile(r"^\s*(Scenario Outline|Scenario Template|Scenario|Example):\s*(.*)$")
|
|
675
|
+
STEP_LINE = re.compile(r"^\s*(Given|When|Then|And|But|\*)\s")
|
|
676
|
+
REMOVED_SC = re.compile(r"^\s*#\s*Đã bỏ \(v[\d.]+\)\s*@(UC-\d{3}-SC\d+)")
|
|
677
|
+
GROUP_LINE = re.compile(r"^\s*#\s*──")
|
|
678
|
+
MARK_COMMENT = re.compile("^\\s*#\\s*[\U0001F916\u2705]")
|
|
679
|
+
PLATFORM_LINE = re.compile("^\\s*-\\s*(?:[\U0001F916\u2705]\\s*)?\\*\\*Nền tảng:\\*\\*\\s*(.+)$")
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def prd_of(feature):
|
|
683
|
+
"""bdd/UC-001.feature nằm cạnh prd.md: {specs}/{domain}/{slug}/bdd/ → {specs}/{domain}/{slug}/prd.md"""
|
|
684
|
+
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(feature))), "prd.md")
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def uc_of(feature):
|
|
688
|
+
m = re.match(r"^(UC-\d{3})\.feature$", os.path.basename(feature))
|
|
689
|
+
if not m:
|
|
690
|
+
fail("tên file BDD phải là <UC-NNN>.feature, ví dụ UC-001.feature (đang là %s)" % os.path.basename(feature))
|
|
691
|
+
return m.group(1)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def uc_span(lines, uc):
|
|
695
|
+
"""(đầu, cuối, đã bỏ?) của khối UC trong PRD, hoặc None."""
|
|
696
|
+
pat = re.compile(r"^###\s+(~~)?%s(?![\d-])" % re.escape(uc))
|
|
697
|
+
for i, l in enumerate(lines):
|
|
698
|
+
m = pat.match(l)
|
|
699
|
+
if m:
|
|
700
|
+
end = next((j for j in range(i + 1, len(lines)) if re.match(r"^#{2,3}\s", lines[j])), len(lines))
|
|
701
|
+
return i, end, bool(m.group(1)) or "Đã bỏ (v" in l
|
|
702
|
+
return None
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def prd_items(text):
|
|
706
|
+
"""{mã BR/AC: còn dùng?} trên toàn PRD. Dòng có ~~ trước mã, hoặc thuộc UC đã bỏ, là đã bỏ."""
|
|
707
|
+
lines = text.split("\n")
|
|
708
|
+
removed_ucs = set(re.findall(r"^###\s+~~(UC-\d{3})", text, re.M))
|
|
709
|
+
out = {}
|
|
710
|
+
for l in lines:
|
|
711
|
+
m = ITEM_LINE.match(l)
|
|
712
|
+
if m:
|
|
713
|
+
out[m.group(2)] = not m.group(1) and m.group(2)[:6] not in removed_ucs
|
|
714
|
+
return out
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
# web · app: nơi người dùng dùng tính năng · system: luồng tự động, không có màn hình (job, tích hợp).
|
|
718
|
+
PLATFORMS = ("web", "app", "system")
|
|
719
|
+
PLATFORM_RULE = ("dòng Nền tảng chỉ ghi mã `web` · `app` · `system` (luồng tự động, không có màn hình), "
|
|
720
|
+
"giải thích để sau dấu ` — `. Ví dụ: `- **Nền tảng:** system — luồng tự động nhận đơn từ EP-03`")
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def platform_values(value):
|
|
724
|
+
"""(các mã nền tảng, các chữ không phải mã). Phần sau ` — ` hoặc `(` là lời giải thích."""
|
|
725
|
+
head = re.split(r"\(| — | – | - ", value.replace("✅", "").replace("\U0001F916", ""))[0]
|
|
726
|
+
words = [w for w in re.split(r"[·,/\s]+", head.strip()) if w]
|
|
727
|
+
return [w for w in words if w in PLATFORMS], [w for w in words if w not in PLATFORMS]
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def uc_platform_line(text, uc):
|
|
731
|
+
"""Giá trị dòng `Nền tảng` áp cho UC: trong UC, không có thì ở Tổng quan. None = chưa khai."""
|
|
732
|
+
lines = text.split("\n")
|
|
733
|
+
span = uc_span(lines, uc)
|
|
734
|
+
if span:
|
|
735
|
+
for l in lines[span[0]:span[1]]:
|
|
736
|
+
m = PLATFORM_LINE.match(l)
|
|
737
|
+
if m:
|
|
738
|
+
return m.group(1)
|
|
739
|
+
for l in (section_text(text, "overview") or "").split("\n"):
|
|
740
|
+
m = PLATFORM_LINE.match(l)
|
|
741
|
+
if m:
|
|
742
|
+
return m.group(1)
|
|
743
|
+
return None
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def uc_platforms(text, uc):
|
|
747
|
+
"""Mã nền tảng của UC. [] = chưa khai, hoặc khai sai khuôn (xem platform_problems)."""
|
|
748
|
+
value = uc_platform_line(text, uc)
|
|
749
|
+
if value is None:
|
|
750
|
+
return []
|
|
751
|
+
good, bad = platform_values(value)
|
|
752
|
+
return [] if bad else good
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def platform_problems(text):
|
|
756
|
+
"""Mỗi UC còn dùng phải có nền tảng đúng khuôn. Trả về danh sách lỗi (rỗng = ổn)."""
|
|
757
|
+
missing, wrong = [], []
|
|
758
|
+
for uc in re.findall(r"^###\s+(UC-\d{3})(?![\d-])", text, re.M):
|
|
759
|
+
value = uc_platform_line(text, uc)
|
|
760
|
+
if value is None:
|
|
761
|
+
missing.append(uc)
|
|
762
|
+
continue
|
|
763
|
+
good, bad = platform_values(value)
|
|
764
|
+
if bad or not good:
|
|
765
|
+
wrong.append('%s ("%s")' % (uc, value.strip()[:50]))
|
|
766
|
+
out = []
|
|
767
|
+
if missing:
|
|
768
|
+
out.append("chưa khai Nền tảng cho %s. Thêm dòng `- **Nền tảng:** web · app` ở Tổng quan (mặc định cho cả PRD), "
|
|
769
|
+
"hoặc trong UC chạy khác mặc định." % ", ".join(missing))
|
|
770
|
+
if wrong:
|
|
771
|
+
out.append("Nền tảng ghi sai khuôn ở %s: %s" % (", ".join(wrong), PLATFORM_RULE))
|
|
772
|
+
return out
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def prd_version(text):
|
|
776
|
+
m = re.search(r"^version:\s*(\S+)", text, re.M)
|
|
777
|
+
return m.group(1) if m else None
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def feature_header(text):
|
|
781
|
+
m = FEATURE_HEADER.match(text)
|
|
782
|
+
if not m:
|
|
783
|
+
return {}
|
|
784
|
+
out = {}
|
|
785
|
+
for l in m.group(0).split("\n"):
|
|
786
|
+
k = re.match(r"^# ([a-z_]+):\s*([^#\n]*?)\s*(?:#.*)?$", l)
|
|
787
|
+
if k:
|
|
788
|
+
out[k.group(1)] = k.group(2)
|
|
789
|
+
return out
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def parse_feature(text):
|
|
793
|
+
"""Danh sách kịch bản: {line, kind, title, tags, steps, examples} + các mã đã bỏ."""
|
|
794
|
+
scenarios, removed, tags, cur, in_examples = [], [], [], None, False
|
|
795
|
+
for i, l in enumerate(text.split("\n")):
|
|
796
|
+
s = l.strip()
|
|
797
|
+
rm = REMOVED_SC.match(l)
|
|
798
|
+
if rm:
|
|
799
|
+
removed.append(rm.group(1))
|
|
800
|
+
continue
|
|
801
|
+
if s.startswith("#") or not s:
|
|
802
|
+
continue
|
|
803
|
+
if s.startswith("@"):
|
|
804
|
+
tags += re.findall(r"@([^\s@]+)", s)
|
|
805
|
+
continue
|
|
806
|
+
if re.match(r"^(Feature|Background|Rule):", s):
|
|
807
|
+
tags, cur = [], None
|
|
808
|
+
continue
|
|
809
|
+
m = SCENARIO_LINE.match(l)
|
|
810
|
+
if m:
|
|
811
|
+
cur = {"line": i + 1, "outline": m.group(1) in ("Scenario Outline", "Scenario Template"),
|
|
812
|
+
"title": m.group(2).strip(), "tags": tags, "steps": [], "examples": -1}
|
|
813
|
+
scenarios.append(cur)
|
|
814
|
+
tags, in_examples = [], False
|
|
815
|
+
continue
|
|
816
|
+
if cur is None:
|
|
817
|
+
continue
|
|
818
|
+
if re.match(r"^(Examples|Scenarios):", s):
|
|
819
|
+
in_examples, cur["examples"] = True, max(cur["examples"], 0) - 1
|
|
820
|
+
continue
|
|
821
|
+
if in_examples and s.startswith("|"):
|
|
822
|
+
cur["examples"] += 1 # dòng đầu là tiêu đề cột
|
|
823
|
+
continue
|
|
824
|
+
st = STEP_LINE.match(l)
|
|
825
|
+
if st:
|
|
826
|
+
cur["steps"].append(st.group(1))
|
|
827
|
+
for sc in scenarios:
|
|
828
|
+
sc["ids"] = [t for t in sc["tags"] if SC_TAG.match("@" + t)]
|
|
829
|
+
return scenarios, removed
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def bdd_report(feature, prd=None):
|
|
833
|
+
"""(lỗi, cảnh báo, số liệu) của một file BDD. Độ phủ tính trên mọi .feature trong cùng thư mục."""
|
|
834
|
+
prd = prd or prd_of(feature)
|
|
835
|
+
uc = uc_of(feature)
|
|
836
|
+
text, ptext = read(feature), read(prd)
|
|
837
|
+
errors, warns = [], []
|
|
838
|
+
head = feature_header(text)
|
|
839
|
+
if head.get("type") != "bdd":
|
|
840
|
+
errors.append("header thiếu `# type: bdd` ở đầu file")
|
|
841
|
+
if head.get("uc") != uc:
|
|
842
|
+
errors.append("header `# uc:` phải là %s (theo tên file), đang là %s" % (uc, head.get("uc") or "trống"))
|
|
843
|
+
for key in ("prd_version", "version"):
|
|
844
|
+
if not VERSION.match(head.get(key, "")):
|
|
845
|
+
errors.append("header `# %s:` phải dạng số.số, ví dụ 1.0" % key)
|
|
846
|
+
if head.get("status") not in FRONTMATTER["bdd"]["status"]:
|
|
847
|
+
errors.append("header `# status:` chỉ nhận draft | approved")
|
|
848
|
+
plines = ptext.split("\n")
|
|
849
|
+
span = uc_span(plines, uc)
|
|
850
|
+
if not span:
|
|
851
|
+
errors.append("PRD %s không có %s" % (prd, uc))
|
|
852
|
+
return errors, warns, {}
|
|
853
|
+
if span[2]:
|
|
854
|
+
errors.append("%s đã bỏ trong PRD — không viết BDD cho UC đã bỏ" % uc)
|
|
855
|
+
items = prd_items(ptext)
|
|
856
|
+
own = [c for c, alive in items.items() if alive and c.startswith(uc + "-")]
|
|
857
|
+
platforms = uc_platforms(ptext, uc)
|
|
858
|
+
if uc_platform_line(ptext, uc) is None:
|
|
859
|
+
errors.append("PRD chưa khai Nền tảng cho %s (dòng `- **Nền tảng:** web · app` ở Tổng quan, hoặc trong UC). "
|
|
860
|
+
"Chạy `/prd <EPIC-ID> change khai nền tảng …` trước." % uc)
|
|
861
|
+
elif not platforms:
|
|
862
|
+
errors.append("Nền tảng của %s trong PRD ghi sai khuôn: %s. Sửa PRD bằng `/prd <EPIC-ID> review`." % (uc, PLATFORM_RULE))
|
|
863
|
+
scenarios, removed = parse_feature(text)
|
|
864
|
+
if not scenarios:
|
|
865
|
+
errors.append("file chưa có kịch bản nào")
|
|
866
|
+
seen = list(removed)
|
|
867
|
+
per_platform = {p: 0 for p in platforms}
|
|
868
|
+
for sc in scenarios:
|
|
869
|
+
where = "dòng %d (%s)" % (sc["line"], sc["title"][:60])
|
|
870
|
+
if len(sc["ids"]) != 1:
|
|
871
|
+
errors.append("%s: cần đúng 1 mã @%s-SCnn, đang có %d" % (where, uc, len(sc["ids"])))
|
|
872
|
+
for sid in sc["ids"]:
|
|
873
|
+
if not sid.startswith(uc + "-"):
|
|
874
|
+
errors.append("%s: mã %s không thuộc %s" % (where, sid, uc))
|
|
875
|
+
if sid in seen:
|
|
876
|
+
errors.append("%s: mã %s đã dùng (kể cả mã đã bỏ) — lấy `next-sc`" % (where, sid))
|
|
877
|
+
seen.append(sid)
|
|
878
|
+
refs = [t for t in sc["tags"] if ITEM_CODE.fullmatch(t)]
|
|
879
|
+
others = [t for t in sc["tags"] if t not in sc["ids"] and t not in refs]
|
|
880
|
+
kinds = [t for t in others if t in SC_TYPES]
|
|
881
|
+
plats = [t for t in others if t in platforms]
|
|
882
|
+
unknown = [t for t in others if t not in SC_TYPES and t not in platforms]
|
|
883
|
+
if len(kinds) != 1:
|
|
884
|
+
errors.append("%s: cần đúng 1 loại (@%s), đang có %d" % (where, " @".join(SC_TYPES), len(kinds)))
|
|
885
|
+
# PRD chưa khai nền tảng thì đã có một lỗi chung ở trên — không báo lặp lại ở từng kịch bản.
|
|
886
|
+
if platforms and not plats:
|
|
887
|
+
errors.append("%s: thiếu tag nền tảng (UC khai: %s)" % (where, " · ".join(platforms)))
|
|
888
|
+
if platforms and unknown:
|
|
889
|
+
errors.append("%s: tag lạ %s — không phải nền tảng UC khai (%s), loại, hay mã AC/BR"
|
|
890
|
+
% (where, " ".join("@" + t for t in unknown), " · ".join(platforms)))
|
|
891
|
+
for p in plats:
|
|
892
|
+
per_platform[p] += 1
|
|
893
|
+
if not refs:
|
|
894
|
+
errors.append("%s: không trỏ tới AC/BR nào — mọi kịch bản phải có nguồn trong PRD" % where)
|
|
895
|
+
for r in refs:
|
|
896
|
+
if r not in items:
|
|
897
|
+
errors.append("%s: @%s không có trong PRD" % (where, r))
|
|
898
|
+
elif not items[r]:
|
|
899
|
+
errors.append("%s: @%s đã bỏ trong PRD — sửa hoặc bỏ kịch bản này" % (where, r))
|
|
900
|
+
if "Then" not in sc["steps"]:
|
|
901
|
+
errors.append("%s: thiếu bước Then (kết quả quan sát được)" % where)
|
|
902
|
+
if sc["outline"] and sc["examples"] < 1:
|
|
903
|
+
errors.append("%s: Scenario Outline cần Examples có ít nhất 1 dòng dữ liệu" % where)
|
|
904
|
+
if not sc["outline"] and sc["examples"] >= 0:
|
|
905
|
+
errors.append("%s: có Examples thì phải là Scenario Outline" % where)
|
|
906
|
+
covered = set()
|
|
907
|
+
for f in glob.glob(os.path.join(os.path.dirname(os.path.abspath(feature)), "*.feature")):
|
|
908
|
+
for sc in parse_feature(read(f))[0]:
|
|
909
|
+
covered.update(t for t in sc["tags"] if ITEM_CODE.fullmatch(t))
|
|
910
|
+
missing = [c for c in own if c not in covered]
|
|
911
|
+
if missing:
|
|
912
|
+
errors.append("AC/BR của %s chưa có kịch bản nào: %s" % (uc, ", ".join(missing)))
|
|
913
|
+
pv = prd_version(ptext)
|
|
914
|
+
if pv and head.get("prd_version") and pv != head.get("prd_version"):
|
|
915
|
+
warns.append("PRD đã ở v%s, BDD viết theo v%s — xem Lịch sử thay đổi của PRD, chạy `/bdd %s` để bù"
|
|
916
|
+
% (pv, head.get("prd_version"), uc))
|
|
917
|
+
acs = [c for c in own if "-AC" in c]
|
|
918
|
+
stats = {"scenarios": len(scenarios), "removed": len(removed),
|
|
919
|
+
"ac": (len([c for c in acs if c in covered]), len(acs)),
|
|
920
|
+
"br": (len([c for c in own if "-BR" in c and c in covered]), len(own) - len(acs)),
|
|
921
|
+
"platforms": per_platform}
|
|
922
|
+
return errors, warns, stats
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def cmd_bddcheck(feature, prd=None):
|
|
926
|
+
errors, warns, st = bdd_report(feature, prd)
|
|
927
|
+
if st:
|
|
928
|
+
print("%s: %d kịch bản (%d đã bỏ) · AC %d/%d · BR %d/%d · %s" % (
|
|
929
|
+
uc_of(feature), st["scenarios"], st["removed"], st["ac"][0], st["ac"][1], st["br"][0], st["br"][1],
|
|
930
|
+
" · ".join("%s %d" % kv for kv in st["platforms"].items()) or "chưa có nền tảng hợp lệ"), flush=True)
|
|
931
|
+
ptext = read(prd or prd_of(feature))
|
|
932
|
+
todo = [u for u in re.findall(r"^###\s+(UC-\d{3})(?![\d-])", ptext, re.M)
|
|
933
|
+
if not os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(feature)), u + ".feature"))]
|
|
934
|
+
print("UC chưa có BDD trong PRD này: " + (", ".join(todo) or "không còn"), flush=True)
|
|
935
|
+
for w in warns:
|
|
936
|
+
print("⚠ " + w, flush=True)
|
|
937
|
+
if errors:
|
|
938
|
+
fail("\n - ".join([""] + errors))
|
|
939
|
+
print("đủ — khuôn, tag và độ phủ AC/BR đều đạt")
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def guard_bdd(path, block):
|
|
943
|
+
"""BDD chỉ được duyệt khi bddcheck sạch, PRD đang approved và BDD viết đúng version PRD hiện tại."""
|
|
944
|
+
errors, _, _ = bdd_report(path)
|
|
945
|
+
if errors:
|
|
946
|
+
fail("không đặt được status=approved: `bddcheck` còn lỗi:\n - " + "\n - ".join(errors))
|
|
947
|
+
ptext = read(prd_of(path))
|
|
948
|
+
ps = re.search(r"^status:\s*(\S+)", ptext, re.M)
|
|
949
|
+
if not ps or ps.group(1) != "approved":
|
|
950
|
+
fail("không đặt được status=approved: PRD chưa approved. Duyệt PRD trước.")
|
|
951
|
+
rv = re.search(r"^reviewed:\s*(\S+)", ptext, re.M)
|
|
952
|
+
if not rv or rv.group(1) != prd_version(ptext):
|
|
953
|
+
fail("không đặt được status=approved: PRD v%s chưa rà hình thức. Chạy `/prd <EPIC-ID> review` trước."
|
|
954
|
+
% prd_version(ptext))
|
|
955
|
+
bv = re.search(r"^prd_version:\s*(\S+)", block, re.M)
|
|
956
|
+
if not bv or bv.group(1) != prd_version(ptext):
|
|
957
|
+
fail("không đặt được status=approved: BDD viết theo PRD v%s, PRD đang ở v%s. Chạy `/bdd <UC>` để bù trước."
|
|
958
|
+
% (bv.group(1) if bv else "?", prd_version(ptext)))
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def cmd_where(specs_dir, ucs):
|
|
962
|
+
if len(ucs) != 1:
|
|
963
|
+
fail("cách dùng: where <thư mục specs> UC-001", 2)
|
|
964
|
+
pat = re.compile(r"^###\s+(?:~~)?%s(?![\d-])" % re.escape(ucs[0]), re.M)
|
|
965
|
+
hits = [p for p in glob.glob(os.path.join(specs_dir, "**", "prd.md"), recursive=True) if pat.search(read(p))]
|
|
966
|
+
if len(hits) != 1:
|
|
967
|
+
fail("%s có trong %d PRD (cần đúng 1)%s" % (ucs[0], len(hits), (": " + ", ".join(hits)) if hits else ""))
|
|
968
|
+
print(hits[0].replace("\\", "/"))
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def cmd_context(path, ucs):
|
|
972
|
+
"""Phần PRD cần để viết BDD của một UC — thay cho đọc cả PRD."""
|
|
973
|
+
if len(ucs) != 1:
|
|
974
|
+
fail("cách dùng: context <prd> UC-001", 2)
|
|
975
|
+
uc = ucs[0]
|
|
976
|
+
text = read(path)
|
|
977
|
+
lines = text.split("\n")
|
|
978
|
+
span = uc_span(lines, uc)
|
|
979
|
+
if not span:
|
|
980
|
+
fail("không có %s trong %s" % (uc, path))
|
|
981
|
+
mention = re.compile(r"%s(?![\d-])" % re.escape(uc))
|
|
982
|
+
fm = lambda k: (re.search(r"^%s:\s*(\S+)" % k, text, re.M) or [None, "—"])[1]
|
|
983
|
+
value = uc_platform_line(text, uc)
|
|
984
|
+
plat = "chưa khai" if value is None else (" · ".join(uc_platforms(text, uc)) or "sai khuôn")
|
|
985
|
+
# Dòng đầu đủ để /bdd chọn chế độ (Bước 2) mà không phải đọc frontmatter riêng.
|
|
986
|
+
out = ["# PRD %s — v%s · status %s · reviewed %s · nền tảng của %s: %s"
|
|
987
|
+
% (path, prd_version(text), fm("status"), fm("reviewed"), uc, plat)]
|
|
988
|
+
out.append(section_text(text, "overview") or "(PRD chưa có mục Tổng quan)")
|
|
989
|
+
flow = (section_text(text, "userflow") or "").split("\n")
|
|
990
|
+
rows = [l for l in flow if l.startswith("| J") and mention.search(l)]
|
|
991
|
+
if rows:
|
|
992
|
+
out += ["", "## User flow — các hành trình có " + uc, "| Mã | Hành trình | Actor | Đi qua UC | Điểm vào | Điểm ra |"] + rows
|
|
993
|
+
blocks = [i for i, l in enumerate(flow) if re.match(r"^### J\d+", l)]
|
|
994
|
+
for n, i in enumerate(blocks):
|
|
995
|
+
end = blocks[n + 1] if n + 1 < len(blocks) else len(flow)
|
|
996
|
+
if mention.search("\n".join(flow[i:end])):
|
|
997
|
+
out += [""] + [l for l in flow[i:end]]
|
|
998
|
+
screens = (section_text(text, "screens") or "").split("\n")
|
|
999
|
+
srows = [l for l in screens if l.startswith("|") and mention.search(l)]
|
|
1000
|
+
if srows:
|
|
1001
|
+
out += ["", "## Màn hình có " + uc, "| Màn hình | Thành phần chính | Hành động → kết quả | UC |"] + srows
|
|
1002
|
+
block = lines[span[0]:span[1]]
|
|
1003
|
+
out += [""] + block
|
|
1004
|
+
body = "\n".join(block)
|
|
1005
|
+
foreign = sorted({c for c in ITEM_CODE.findall(body) if not c.startswith(uc + "-")})
|
|
1006
|
+
ucs_ref = sorted({u for u in re.findall(r"UC-\d{3}(?![\d-])", body) if u != uc})
|
|
1007
|
+
if foreign or ucs_ref:
|
|
1008
|
+
out += ["", "## Mục của UC khác mà %s trỏ tới" % uc]
|
|
1009
|
+
for u in ucs_ref:
|
|
1010
|
+
s = uc_span(lines, u)
|
|
1011
|
+
if s:
|
|
1012
|
+
out.append(lines[s[0]])
|
|
1013
|
+
for c in foreign:
|
|
1014
|
+
hits = find_item(lines, c)
|
|
1015
|
+
out.append(lines[hits[0]] if hits else "(không tìm thấy %s trong PRD)" % c)
|
|
1016
|
+
print("\n".join(out).rstrip())
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def used_sc(text, uc):
|
|
1020
|
+
return sorted({int(m.group(3)) for m in SC_ANY.finditer(text) if m.group(2) == uc[3:]})
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def cmd_next_sc(path):
|
|
1024
|
+
uc = uc_of(path)
|
|
1025
|
+
nums = used_sc(read(path), uc) if os.path.isfile(path) else []
|
|
1026
|
+
print("%s-SC%02d" % (uc, max(nums, default=0) + 1))
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def scenario_blocks(lines):
|
|
1030
|
+
"""[(mã, đầu, cuối)] — khối gồm tag, comment 🤖/✅ ngay trên, dòng Scenario, các bước, Examples."""
|
|
1031
|
+
starts = []
|
|
1032
|
+
for i, l in enumerate(lines):
|
|
1033
|
+
if SCENARIO_LINE.match(l):
|
|
1034
|
+
s = i
|
|
1035
|
+
while s > 0 and (lines[s - 1].strip().startswith("@") or MARK_COMMENT.match(lines[s - 1])):
|
|
1036
|
+
s -= 1
|
|
1037
|
+
ids = SC_TAG.findall("\n".join(lines[s:i]))
|
|
1038
|
+
starts.append((ids[0] if len(ids) == 1 else None, s))
|
|
1039
|
+
out = []
|
|
1040
|
+
for n, (sid, s) in enumerate(starts):
|
|
1041
|
+
limit = starts[n + 1][1] if n + 1 < len(starts) else len(lines)
|
|
1042
|
+
e = next((j for j in range(s + 1, limit) if GROUP_LINE.match(lines[j]) or REMOVED_SC.match(lines[j])), limit)
|
|
1043
|
+
while e > s and not lines[e - 1].strip():
|
|
1044
|
+
e -= 1
|
|
1045
|
+
out.append((sid, s, e))
|
|
1046
|
+
return out
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def find_block(lines, sid):
|
|
1050
|
+
hits = [b for b in scenario_blocks(lines) if b[0] == sid]
|
|
1051
|
+
if len(hits) != 1:
|
|
1052
|
+
fail("kịch bản %s khớp %d khối (cần đúng 1). Dùng `scenario <file> %s` để xem lại." % (sid, len(hits), sid))
|
|
1053
|
+
return hits[0]
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def cmd_scenario(path, codes):
|
|
1057
|
+
"""Mã kịch bản → đúng khối đó. Mã AC/BR → mọi kịch bản gắn tag mã đó."""
|
|
1058
|
+
if not codes:
|
|
1059
|
+
fail("cách dùng: scenario <feature> UC-001-SC03 | UC-001-BR04 […]", 2)
|
|
1060
|
+
lines = read(path).split("\n")
|
|
1061
|
+
shown = set()
|
|
1062
|
+
for code in codes:
|
|
1063
|
+
if ITEM_CODE.fullmatch(code):
|
|
1064
|
+
tag = re.compile(r"@%s(?![\w-])" % re.escape(code))
|
|
1065
|
+
blocks = [b for b in scenario_blocks(lines) if tag.search("\n".join(lines[b[1]:b[2]]))]
|
|
1066
|
+
if not blocks:
|
|
1067
|
+
print("(không có kịch bản nào gắn @%s)\n" % code)
|
|
1068
|
+
else:
|
|
1069
|
+
blocks = [find_block(lines, code)]
|
|
1070
|
+
for _, s, e in blocks:
|
|
1071
|
+
if s not in shown:
|
|
1072
|
+
shown.add(s)
|
|
1073
|
+
print("\n".join(lines[s:e]))
|
|
1074
|
+
print()
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def cmd_sc(path):
|
|
1078
|
+
"""stdin = JSON: {"id", "new"} thay cả khối · {"after": mã | "end", "new"} thêm · {"remove", "version"} bỏ."""
|
|
1079
|
+
try:
|
|
1080
|
+
ops = json.loads(stdin_text())
|
|
1081
|
+
except json.JSONDecodeError as e:
|
|
1082
|
+
fail("stdin không phải JSON hợp lệ: " + str(e), 2)
|
|
1083
|
+
if not isinstance(ops, list) or not ops:
|
|
1084
|
+
fail('stdin phải là danh sách [{"id"|"after"|"remove": …}]', 2)
|
|
1085
|
+
uc = uc_of(path)
|
|
1086
|
+
lines = read(path).split("\n")
|
|
1087
|
+
for n, op in enumerate(ops, 1):
|
|
1088
|
+
keys = [k for k in ("id", "after", "remove") if k in op]
|
|
1089
|
+
if len(keys) != 1:
|
|
1090
|
+
fail("thao tác #%d phải có đúng một trong `id` (thay), `after` (thêm sau), `remove` (bỏ)" % n, 2)
|
|
1091
|
+
if "remove" in op:
|
|
1092
|
+
if not VERSION.match(str(op.get("version", ""))):
|
|
1093
|
+
fail("thao tác #%d: bỏ kịch bản cần `version` mới của file BDD, ví dụ \"1.1\"" % n, 2)
|
|
1094
|
+
sid = op["remove"]
|
|
1095
|
+
_, s, e = find_block(lines, sid)
|
|
1096
|
+
title = next((SCENARIO_LINE.match(l).group(2) for l in lines[s:e] if SCENARIO_LINE.match(l)), "")
|
|
1097
|
+
lines[s:e] = [" # Đã bỏ (v%s) @%s — %s" % (op["version"], sid, title.strip())]
|
|
1098
|
+
continue
|
|
1099
|
+
new = op.get("new")
|
|
1100
|
+
if not isinstance(new, str) or not new.strip():
|
|
1101
|
+
fail("thao tác #%d thiếu `new`" % n, 2)
|
|
1102
|
+
new_lines = new.rstrip("\n").split("\n")
|
|
1103
|
+
ids = SC_TAG.findall(new)
|
|
1104
|
+
if sum(1 for l in new_lines if SCENARIO_LINE.match(l)) != 1 or len(ids) != 1:
|
|
1105
|
+
fail("thao tác #%d: `new` phải là đúng một kịch bản, mang đúng một mã @%s-SCnn" % (n, uc), 2)
|
|
1106
|
+
if "id" in op:
|
|
1107
|
+
if ids[0] != op["id"]:
|
|
1108
|
+
fail("thao tác #%d: kịch bản mới phải giữ mã %s (mã không bao giờ đổi)" % (n, op["id"]))
|
|
1109
|
+
_, s, e = find_block(lines, op["id"])
|
|
1110
|
+
lines[s:e] = new_lines
|
|
1111
|
+
else:
|
|
1112
|
+
if not ids[0].startswith(uc + "-"):
|
|
1113
|
+
fail("thao tác #%d: mã %s không thuộc %s" % (n, ids[0], uc))
|
|
1114
|
+
if int(SC_ANY.match(ids[0]).group(3)) in used_sc("\n".join(lines), uc):
|
|
1115
|
+
fail("thao tác #%d: mã %s đã dùng (kể cả mã đã bỏ) — lấy `next-sc`" % (n, ids[0]))
|
|
1116
|
+
if op["after"] == "end":
|
|
1117
|
+
at = len(lines)
|
|
1118
|
+
while at > 0 and not lines[at - 1].strip():
|
|
1119
|
+
at -= 1
|
|
1120
|
+
else:
|
|
1121
|
+
at = find_block(lines, op["after"])[2]
|
|
1122
|
+
lines[at:at] = [""] + new_lines
|
|
1123
|
+
out = "\n".join(lines)
|
|
1124
|
+
if not out.endswith("\n"):
|
|
1125
|
+
out += "\n"
|
|
1126
|
+
write(path, out)
|
|
1127
|
+
print("đã áp %d thao tác theo mã kịch bản trong %s" % (len(ops), path))
|
|
484
1128
|
|
|
485
1129
|
|
|
486
1130
|
PENDING, CONFIRMED = "\U0001F916", "✅"
|
|
@@ -505,6 +1149,14 @@ def cmd_confirm(path, codes):
|
|
|
505
1149
|
# Mã khớp nguyên từ: AC2 không được khớp nhầm AC20.
|
|
506
1150
|
targets = {}
|
|
507
1151
|
for code in codes:
|
|
1152
|
+
# Dòng không có mã (vd dòng Nền tảng): xác nhận theo số dòng mà `pending` in ra, viết L24.
|
|
1153
|
+
ln = re.match(r"^L(\d+)$", code)
|
|
1154
|
+
if ln:
|
|
1155
|
+
i = int(ln.group(1)) - 1
|
|
1156
|
+
if not 0 <= i < len(lines) or not is_pending(lines[i]):
|
|
1157
|
+
fail("dòng %s không phải dòng 🤖. Chạy `pending` để xem số dòng các mục còn chờ." % ln.group(1))
|
|
1158
|
+
targets[i] = code
|
|
1159
|
+
continue
|
|
508
1160
|
pat = re.compile(r"(?<![\w-])%s(?![\w])" % re.escape(code))
|
|
509
1161
|
hits = [i for i, line in enumerate(lines) if is_pending(line) and pat.search(line)]
|
|
510
1162
|
if not hits:
|
|
@@ -528,7 +1180,8 @@ def main(argv):
|
|
|
528
1180
|
if argv[:1] == ["--check"]:
|
|
529
1181
|
print("ok — Python %s" % sys.version.split()[0])
|
|
530
1182
|
return
|
|
531
|
-
if len(argv) < 2 or argv[0] not in ("create", "edit", "set", "pending", "confirm", "next-uc", "coverage", "section", "upgrade", "flowcheck"
|
|
1183
|
+
if len(argv) < 2 or argv[0] not in ("create", "edit", "set", "pending", "confirm", "next-uc", "coverage", "section", "upgrade", "flowcheck", "item", "uc", "finding", "decide",
|
|
1184
|
+
"where", "context", "next-sc", "scenario", "sc", "bddcheck"):
|
|
532
1185
|
fail(__doc__.split("Cách dùng")[1], 2)
|
|
533
1186
|
cmd, path, rest = argv[0], argv[1], argv[2:]
|
|
534
1187
|
if cmd == "create":
|
|
@@ -539,7 +1192,12 @@ def main(argv):
|
|
|
539
1192
|
else:
|
|
540
1193
|
cmd_create(path)
|
|
541
1194
|
elif cmd == "edit":
|
|
542
|
-
|
|
1195
|
+
if path == "--form":
|
|
1196
|
+
if not rest:
|
|
1197
|
+
fail("cách dùng: edit --form <file>", 2)
|
|
1198
|
+
cmd_edit(rest[0], form=True)
|
|
1199
|
+
else:
|
|
1200
|
+
cmd_edit(path)
|
|
543
1201
|
elif cmd == "pending":
|
|
544
1202
|
cmd_pending(path)
|
|
545
1203
|
elif cmd == "confirm":
|
|
@@ -552,8 +1210,30 @@ def main(argv):
|
|
|
552
1210
|
cmd_upgrade(path)
|
|
553
1211
|
elif cmd == "flowcheck":
|
|
554
1212
|
cmd_flowcheck(path)
|
|
1213
|
+
elif cmd == "item":
|
|
1214
|
+
cmd_item(path)
|
|
1215
|
+
elif cmd == "uc":
|
|
1216
|
+
cmd_uc(path, rest)
|
|
1217
|
+
elif cmd == "finding":
|
|
1218
|
+
cmd_finding(path, rest)
|
|
1219
|
+
elif cmd == "decide":
|
|
1220
|
+
cmd_decide(path)
|
|
555
1221
|
elif cmd == "next-uc":
|
|
556
1222
|
cmd_next_uc(path)
|
|
1223
|
+
elif cmd == "where":
|
|
1224
|
+
cmd_where(path, rest)
|
|
1225
|
+
elif cmd == "context":
|
|
1226
|
+
cmd_context(path, rest)
|
|
1227
|
+
elif cmd == "next-sc":
|
|
1228
|
+
cmd_next_sc(path)
|
|
1229
|
+
elif cmd == "scenario":
|
|
1230
|
+
cmd_scenario(path, rest)
|
|
1231
|
+
elif cmd == "sc":
|
|
1232
|
+
cmd_sc(path)
|
|
1233
|
+
elif cmd == "bddcheck":
|
|
1234
|
+
if len(rest) > 1:
|
|
1235
|
+
fail("cách dùng: bddcheck <feature> [prd]", 2)
|
|
1236
|
+
cmd_bddcheck(path, rest[0] if rest else None)
|
|
557
1237
|
elif cmd == "coverage":
|
|
558
1238
|
if len(rest) != 1:
|
|
559
1239
|
fail("cách dùng: coverage <epic> <prd>", 2)
|