@educa-corp/fw 0.8.1 → 0.10.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 +20 -0
- package/commands/bdd.md +68 -0
- package/commands/prd.md +10 -17
- package/commands/product.md +10 -26
- package/docs/guide/02-khai-niem.md +3 -0
- package/docs/guide/README.md +8 -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.md +3 -2
- package/docs/guide/vai-tro/po-ba.md +5 -4
- package/docs/guide/xu-ly-su-co.md +5 -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 +43 -0
- package/ref/common.md +47 -0
- package/ref/prd/change.md +2 -2
- package/ref/prd/new.md +6 -3
- package/ref/prd/refine.md +17 -8
- package/ref/product/epic.md +2 -0
- package/templates/bdd.feature +32 -0
- package/templates/prd.md +2 -0
- package/tools/spec_edit.py +598 -7
package/tools/spec_edit.py
CHANGED
|
@@ -17,9 +17,19 @@ Cách dùng (nội dung truyền qua stdin):
|
|
|
17
17
|
spec_edit.py section <file> <mã> in đúng một mục theo mã `<!-- sec:… -->` (vd constraints)
|
|
18
18
|
spec_edit.py upgrade <file> gắn mã mục cho file theo khuôn cũ (chạy một lần)
|
|
19
19
|
spec_edit.py next-uc <thư mục specs> in mã UC kế tiếp (quét mọi prd.md)
|
|
20
|
+
spec_edit.py item <file> stdin = JSON [{"id": mã, "new": dòng} | {"after": mã, "new": dòng}] — sửa / thêm theo mã
|
|
21
|
+
spec_edit.py uc <file> <UC-…>… in đúng các UC đó
|
|
22
|
+
spec_edit.py finding <file> <F…>… in đúng các finding đó (file refine)
|
|
23
|
+
spec_edit.py decide <file> stdin = JSON [{"f": "F17", "decision": "nhận | sửa: … | bác: … | hoãn"}]
|
|
20
24
|
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
25
|
spec_edit.py create --replace <file> chỉ cho file tạm trong .fw/tmp/ (kết quả của agent)
|
|
22
26
|
spec_edit.py coverage <epic> <prd> liệt kê BR / AC của epic chưa được PRD dùng tới
|
|
27
|
+
spec_edit.py where <specs> <UC-…> in đường dẫn PRD chứa UC đó
|
|
28
|
+
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)
|
|
29
|
+
spec_edit.py next-sc <feature> mã kịch bản kế tiếp (tính cả mã đã bỏ)
|
|
30
|
+
spec_edit.py scenario <feature> <SC…>… in đúng các kịch bản đó
|
|
31
|
+
spec_edit.py sc <feature> stdin = JSON [{"id": mã, "new": khối} | {"after": mã | "end", "new": khối} | {"remove": mã, "version": "1.1"}]
|
|
32
|
+
spec_edit.py bddcheck <feature> [prd] kiểm khuôn, tag, độ phủ AC / BR của file BDD
|
|
23
33
|
|
|
24
34
|
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
35
|
"""
|
|
@@ -38,9 +48,10 @@ FRONTMATTER = {
|
|
|
38
48
|
"epic": {"status": ["in-progress", "ready", "handed-off"], "checkpoint": (0, 3)},
|
|
39
49
|
"prd": {"status": ["draft", "approved"]},
|
|
40
50
|
"prd-refine": {"status": ["pending", "applied", "done"], "round": (1, 2)},
|
|
51
|
+
"bdd": {"status": ["draft", "approved"]},
|
|
41
52
|
}
|
|
42
53
|
# 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"}
|
|
54
|
+
DONE_STATUS = {"product": "ready", "epic": "ready", "prd": "approved", "prd-refine": "done", "bdd": "approved"}
|
|
44
55
|
COMMON_INT = {"open_questions": (0, 999)}
|
|
45
56
|
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
46
57
|
VERSION = re.compile(r"^\d+\.\d+$")
|
|
@@ -89,6 +100,10 @@ def cmd_create(path, replace=False):
|
|
|
89
100
|
if os.path.exists(path) and not replace:
|
|
90
101
|
fail(path + " đã tồn tại — không ghi đè. Dùng `edit` hoặc `set`.")
|
|
91
102
|
text = stdin_text()
|
|
103
|
+
if path.endswith(".feature"):
|
|
104
|
+
head = feature_header(text)
|
|
105
|
+
if head.get("type") != "bdd" or head.get("uc") != uc_of(path):
|
|
106
|
+
fail("file BDD phải mở đầu bằng header theo khuôn bdd.feature: `# type: bdd`, `# uc: %s`, …" % uc_of(path))
|
|
92
107
|
check_keys(file_type(text), [], sec_keys(text), creating=True)
|
|
93
108
|
check_sources(file_type(text), text)
|
|
94
109
|
write(path, text)
|
|
@@ -201,6 +216,7 @@ def cmd_flowcheck(path):
|
|
|
201
216
|
if problems:
|
|
202
217
|
fail("\n - ".join([""] + problems))
|
|
203
218
|
print("User flow đủ — mọi UC đều có mặt trong ít nhất một hành trình")
|
|
219
|
+
size_warning(path)
|
|
204
220
|
|
|
205
221
|
|
|
206
222
|
def refine_path(prd_path):
|
|
@@ -244,18 +260,37 @@ def guard_done(ftype, block, body, path):
|
|
|
244
260
|
fail("không đặt được status=%s: chưa có %s. Đặt cùng lệnh, ví dụ: "
|
|
245
261
|
"set <file> status=%s approved_by=<tên> approved_at=YYYY-MM-DD" % (done, field, done))
|
|
246
262
|
if ftype == "prd":
|
|
263
|
+
ptext = read(path)
|
|
264
|
+
no_platform = [u for u in re.findall(r"^###\s+(UC-\d{3})(?![\d-])", ptext, re.M) if not uc_platforms(ptext, u)]
|
|
265
|
+
if no_platform:
|
|
266
|
+
fail("không đặt được status=approved: chưa khai Nền tảng cho %s. Thêm dòng `- **Nền tảng:** web · app` "
|
|
267
|
+
"ở Tổng quan (mặc định cho cả PRD), hoặc trong UC chạy khác mặc định." % ", ".join(no_platform))
|
|
247
268
|
guard_refined(path, block)
|
|
269
|
+
if ftype == "bdd":
|
|
270
|
+
guard_bdd(path, block)
|
|
248
271
|
if ftype == "prd-refine" and undecided_findings(body):
|
|
249
272
|
fail("không đặt được status=done: còn finding critical/major chưa có quyết định: %s"
|
|
250
273
|
% ", ".join(undecided_findings(body)))
|
|
251
274
|
|
|
252
275
|
|
|
276
|
+
def header_span(path, text):
|
|
277
|
+
"""(đầu, cuối, chỗ bắt đầu thân file, tiền tố dòng) của khối frontmatter.
|
|
278
|
+
File .feature không có `---` (Gherkin không đọc được), header là các dòng `# key: value` ở đầu file."""
|
|
279
|
+
if path.endswith(".feature"):
|
|
280
|
+
m = FEATURE_HEADER.match(text)
|
|
281
|
+
if not m:
|
|
282
|
+
fail("file .feature không có header (các dòng `# key: value` ở đầu file, theo khuôn bdd.feature)")
|
|
283
|
+
return 0, m.end(), m.end(), "# "
|
|
284
|
+
m = split_frontmatter(text)
|
|
285
|
+
return m.start(1), m.end(1), m.end(), ""
|
|
286
|
+
|
|
287
|
+
|
|
253
288
|
def cmd_set(path, pairs):
|
|
254
289
|
if not pairs:
|
|
255
290
|
fail("cần ít nhất một cặp key=value", 2)
|
|
256
291
|
text = read(path)
|
|
257
|
-
|
|
258
|
-
block =
|
|
292
|
+
start, end, body_at, prefix = header_span(path, text)
|
|
293
|
+
block = "\n".join(l[len(prefix):] if l.startswith(prefix) else l for l in text[start:end].split("\n"))
|
|
259
294
|
tm = re.search(r"^type:\s*(\S+)", block, re.M)
|
|
260
295
|
ftype = tm.group(1) if tm else None
|
|
261
296
|
|
|
@@ -271,8 +306,10 @@ def cmd_set(path, pairs):
|
|
|
271
306
|
# Giữ nguyên comment đi kèm ở cuối dòng, chỉ đổi giá trị.
|
|
272
307
|
block = line.sub(lambda mm: mm.group(1) + value + (mm.group(3) or ""), block, count=1)
|
|
273
308
|
|
|
274
|
-
guard_done(ftype, block, text[
|
|
275
|
-
|
|
309
|
+
guard_done(ftype, block, text[body_at:], path)
|
|
310
|
+
if prefix:
|
|
311
|
+
block = "\n".join(prefix + l if l else l for l in block.split("\n"))
|
|
312
|
+
write(path, text[:start] + block + text[end:])
|
|
276
313
|
print("đã đặt %s trong %s" % (", ".join(pairs), path))
|
|
277
314
|
|
|
278
315
|
|
|
@@ -300,7 +337,7 @@ def sec_keys(text):
|
|
|
300
337
|
|
|
301
338
|
|
|
302
339
|
def file_type(text):
|
|
303
|
-
m = re.search(r"^type:\s*(\S+)", text, re.M)
|
|
340
|
+
m = re.search(r"^(?:# )?type:\s*(\S+)", text, re.M)
|
|
304
341
|
return m.group(1) if m else None
|
|
305
342
|
|
|
306
343
|
|
|
@@ -448,6 +485,120 @@ def cmd_upgrade(path):
|
|
|
448
485
|
+ " · ".join("%s (sec:%s)" % (orig, k) for k, orig in missing))
|
|
449
486
|
|
|
450
487
|
|
|
488
|
+
# ── Theo mã: sửa / thêm một dòng BR, AC; đọc một UC, một finding; ghi quyết định ──────────
|
|
489
|
+
# Dòng có mã ở đầu: "| ✅ UC-003-BR10 | …" (bảng BR) · "- ✅ UC-003-AC01. …" (AC) · có thể có ~~ khi đã bỏ.
|
|
490
|
+
def item_line_re(code):
|
|
491
|
+
return re.compile(r"^(?:\|\s*|-\s+)(?:[\U0001F916\u2705]\s*)?(?:~~)?%s(?![\w-])" % re.escape(code))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def find_item(lines, code):
|
|
495
|
+
pat = item_line_re(code)
|
|
496
|
+
return [i for i, l in enumerate(lines) if pat.match(l)]
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def cmd_item(path):
|
|
500
|
+
"""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ì."""
|
|
501
|
+
try:
|
|
502
|
+
ops = json.loads(stdin_text())
|
|
503
|
+
except json.JSONDecodeError as e:
|
|
504
|
+
fail("stdin không phải JSON hợp lệ: " + str(e), 2)
|
|
505
|
+
if not isinstance(ops, list) or not ops:
|
|
506
|
+
fail('stdin phải là danh sách [{"id": …, "new": …}] hoặc [{"after": …, "new": …}]', 2)
|
|
507
|
+
text = read(path)
|
|
508
|
+
before = sec_keys(text)
|
|
509
|
+
lines = text.split("\n")
|
|
510
|
+
for n, op in enumerate(ops, 1):
|
|
511
|
+
new = op.get("new")
|
|
512
|
+
if not isinstance(new, str) or not new.strip():
|
|
513
|
+
fail("thao tác #%d thiếu `new`" % n, 2)
|
|
514
|
+
code = op.get("id") or op.get("after")
|
|
515
|
+
if not code or ("id" in op) == ("after" in op):
|
|
516
|
+
fail("thao tác #%d phải có đúng một trong `id` (sửa) hoặc `after` (thêm sau)" % n, 2)
|
|
517
|
+
hits = find_item(lines, code)
|
|
518
|
+
if len(hits) != 1:
|
|
519
|
+
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)))
|
|
520
|
+
if "id" in op:
|
|
521
|
+
if "\n" in new:
|
|
522
|
+
fail("thao tác #%d: sửa theo `id` chỉ nhận một dòng" % n, 2)
|
|
523
|
+
if not item_line_re(code).match(new):
|
|
524
|
+
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))
|
|
525
|
+
lines[hits[0]] = new
|
|
526
|
+
else:
|
|
527
|
+
for nl in new.split("\n"):
|
|
528
|
+
m = re.match(r"^(?:\|\s*|-\s+)(?:[\U0001F916\u2705]\s*)?(?:~~)?(UC-\d{3}-(?:BR|AC)\d+)", nl)
|
|
529
|
+
if m and find_item(lines, m.group(1)):
|
|
530
|
+
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)))
|
|
531
|
+
lines[hits[0] + 1:hits[0] + 1] = new.split("\n")
|
|
532
|
+
out = "\n".join(lines)
|
|
533
|
+
check_keys(file_type(out), before, sec_keys(out), creating=False)
|
|
534
|
+
check_sources(file_type(out), out)
|
|
535
|
+
write(path, out)
|
|
536
|
+
print("đã áp %d thao tác theo mã trong %s" % (len(ops), path))
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def print_blocks(path, codes, start_re, kind):
|
|
540
|
+
lines = read(path).split("\n")
|
|
541
|
+
for code in codes:
|
|
542
|
+
pat = start_re(code)
|
|
543
|
+
hits = [i for i, l in enumerate(lines) if pat.match(l)]
|
|
544
|
+
if not hits:
|
|
545
|
+
fail("không có %s %s trong %s" % (kind, code, path))
|
|
546
|
+
i = hits[0]
|
|
547
|
+
end = next((j for j in range(i + 1, len(lines)) if re.match(r"^#{2,3}\s", lines[j])), len(lines))
|
|
548
|
+
print("\n".join(lines[i:end]).rstrip())
|
|
549
|
+
print()
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def cmd_uc(path, codes):
|
|
553
|
+
if not codes:
|
|
554
|
+
fail("cách dùng: uc <file> UC-003 [UC-005 …]", 2)
|
|
555
|
+
print_blocks(path, codes, lambda c: re.compile(r"^###\s+(?:~~)?%s(?![\d-])" % re.escape(c)), "UC")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def cmd_finding(path, codes):
|
|
559
|
+
if not codes:
|
|
560
|
+
fail("cách dùng: finding <file refine> F17 [F18 …]", 2)
|
|
561
|
+
print_blocks(path, codes, lambda c: re.compile(r"^###\s+%s\s·" % re.escape(c)), "finding")
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
DECISION_VALUE = re.compile(r"^(nhận|hoãn|chờ|sửa:\s*\S.*|bác:\s*\S.*)$")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def cmd_decide(path):
|
|
568
|
+
"""stdin = JSON [{"f": "F17", "decision": "nhận" | "sửa: …" | "bác: lý do" | "hoãn"}]. Ghi ngay sau mỗi đợt PO quyết."""
|
|
569
|
+
try:
|
|
570
|
+
ops = json.loads(stdin_text())
|
|
571
|
+
except json.JSONDecodeError as e:
|
|
572
|
+
fail("stdin không phải JSON hợp lệ: " + str(e), 2)
|
|
573
|
+
if not isinstance(ops, list) or not ops:
|
|
574
|
+
fail('stdin phải là danh sách [{"f": "F17", "decision": "…"}]', 2)
|
|
575
|
+
lines = read(path).split("\n")
|
|
576
|
+
for n, op in enumerate(ops, 1):
|
|
577
|
+
code, dec = op.get("f"), (op.get("decision") or "").strip()
|
|
578
|
+
if not DECISION_VALUE.match(dec):
|
|
579
|
+
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))
|
|
580
|
+
head = [i for i, l in enumerate(lines) if re.match(r"^###\s+%s\s·" % re.escape(code or "?"), l)]
|
|
581
|
+
if len(head) != 1:
|
|
582
|
+
fail("quyết định #%d: không tìm thấy đúng một finding %s" % (n, code))
|
|
583
|
+
end = next((j for j in range(head[0] + 1, len(lines)) if re.match(r"^#{2,3}\s", lines[j])), len(lines))
|
|
584
|
+
dl = [j for j in range(head[0], end) if lines[j].startswith("- Quyết định:")]
|
|
585
|
+
if len(dl) != 1:
|
|
586
|
+
fail("finding %s không có đúng một dòng `- Quyết định:`" % code)
|
|
587
|
+
lines[dl[0]] = "- Quyết định: " + dec
|
|
588
|
+
write(path, "\n".join(lines))
|
|
589
|
+
print("đã ghi %d quyết định vào %s" % (len(ops), path))
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
PRD_SIZE_WARN = 60 * 1024
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def size_warning(path):
|
|
596
|
+
size = os.path.getsize(path)
|
|
597
|
+
if size > PRD_SIZE_WARN:
|
|
598
|
+
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."
|
|
599
|
+
% (size // 1024, PRD_SIZE_WARN // 1024))
|
|
600
|
+
|
|
601
|
+
|
|
451
602
|
def cmd_next_uc(specs_dir):
|
|
452
603
|
if not os.path.isdir(specs_dir):
|
|
453
604
|
fail("không có thư mục " + specs_dir)
|
|
@@ -481,6 +632,423 @@ def cmd_coverage(epic_path, prd_path):
|
|
|
481
632
|
if problems:
|
|
482
633
|
fail("\n - ".join([""] + problems))
|
|
483
634
|
print("đủ — mọi BR/AC của epic đều có trong PRD")
|
|
635
|
+
size_warning(prd_path)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# ── BDD: một file .feature cho mỗi UC, tag Gherkin thật (xem docs/design/bdd.md) ──────────
|
|
639
|
+
FEATURE_HEADER = re.compile(r"(?:# [a-z_]+:[^\n]*\n)+")
|
|
640
|
+
ITEM_CODE = re.compile(r"UC-\d{3}-(?:BR|AC)\d+")
|
|
641
|
+
ITEM_LINE = re.compile("^(?:\\|\\s*|-\\s+)(?:[\U0001F916\u2705]\\s*)?(~~)?(UC-\\d{3}-(?:BR|AC)\\d+)")
|
|
642
|
+
SC_TAG = re.compile(r"@(UC-\d{3}-SC\d+)(?![\w-])")
|
|
643
|
+
SC_ANY = re.compile(r"(UC-(\d{3})-SC(\d+))(?![\w-])")
|
|
644
|
+
SC_TYPES = ("happy", "alternative", "edge", "negative")
|
|
645
|
+
SCENARIO_LINE = re.compile(r"^\s*(Scenario Outline|Scenario Template|Scenario|Example):\s*(.*)$")
|
|
646
|
+
STEP_LINE = re.compile(r"^\s*(Given|When|Then|And|But|\*)\s")
|
|
647
|
+
REMOVED_SC = re.compile(r"^\s*#\s*Đã bỏ \(v[\d.]+\)\s*@(UC-\d{3}-SC\d+)")
|
|
648
|
+
GROUP_LINE = re.compile(r"^\s*#\s*──")
|
|
649
|
+
MARK_COMMENT = re.compile("^\\s*#\\s*[\U0001F916\u2705]")
|
|
650
|
+
PLATFORM_LINE = re.compile("^\\s*-\\s*(?:[\U0001F916\u2705]\\s*)?\\*\\*Nền tảng:\\*\\*\\s*(.+)$")
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def prd_of(feature):
|
|
654
|
+
"""bdd/UC-001.feature nằm cạnh prd.md: {specs}/{domain}/{slug}/bdd/ → {specs}/{domain}/{slug}/prd.md"""
|
|
655
|
+
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(feature))), "prd.md")
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def uc_of(feature):
|
|
659
|
+
m = re.match(r"^(UC-\d{3})\.feature$", os.path.basename(feature))
|
|
660
|
+
if not m:
|
|
661
|
+
fail("tên file BDD phải là <UC-NNN>.feature, ví dụ UC-001.feature (đang là %s)" % os.path.basename(feature))
|
|
662
|
+
return m.group(1)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def uc_span(lines, uc):
|
|
666
|
+
"""(đầu, cuối, đã bỏ?) của khối UC trong PRD, hoặc None."""
|
|
667
|
+
pat = re.compile(r"^###\s+(~~)?%s(?![\d-])" % re.escape(uc))
|
|
668
|
+
for i, l in enumerate(lines):
|
|
669
|
+
m = pat.match(l)
|
|
670
|
+
if m:
|
|
671
|
+
end = next((j for j in range(i + 1, len(lines)) if re.match(r"^#{2,3}\s", lines[j])), len(lines))
|
|
672
|
+
return i, end, bool(m.group(1)) or "Đã bỏ (v" in l
|
|
673
|
+
return None
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def prd_items(text):
|
|
677
|
+
"""{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ỏ."""
|
|
678
|
+
lines = text.split("\n")
|
|
679
|
+
removed_ucs = set(re.findall(r"^###\s+~~(UC-\d{3})", text, re.M))
|
|
680
|
+
out = {}
|
|
681
|
+
for l in lines:
|
|
682
|
+
m = ITEM_LINE.match(l)
|
|
683
|
+
if m:
|
|
684
|
+
out[m.group(2)] = not m.group(1) and m.group(2)[:6] not in removed_ucs
|
|
685
|
+
return out
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def platform_values(value):
|
|
689
|
+
value = re.split(r"\(| — ", value)[0]
|
|
690
|
+
return [v for v in re.findall(r"[a-z][a-z0-9-]*", value.lower())]
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def uc_platforms(text, uc):
|
|
694
|
+
"""Nền tảng của UC: dòng `Nền tảng` trong UC, không có thì lấy ở Tổng quan. [] = PRD chưa khai."""
|
|
695
|
+
lines = text.split("\n")
|
|
696
|
+
span = uc_span(lines, uc)
|
|
697
|
+
if span:
|
|
698
|
+
for l in lines[span[0]:span[1]]:
|
|
699
|
+
m = PLATFORM_LINE.match(l)
|
|
700
|
+
if m:
|
|
701
|
+
return platform_values(m.group(1))
|
|
702
|
+
overview = section_text(text, "overview") or ""
|
|
703
|
+
for l in overview.split("\n"):
|
|
704
|
+
m = PLATFORM_LINE.match(l)
|
|
705
|
+
if m:
|
|
706
|
+
return platform_values(m.group(1))
|
|
707
|
+
return []
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def prd_version(text):
|
|
711
|
+
m = re.search(r"^version:\s*(\S+)", text, re.M)
|
|
712
|
+
return m.group(1) if m else None
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def feature_header(text):
|
|
716
|
+
m = FEATURE_HEADER.match(text)
|
|
717
|
+
if not m:
|
|
718
|
+
return {}
|
|
719
|
+
out = {}
|
|
720
|
+
for l in m.group(0).split("\n"):
|
|
721
|
+
k = re.match(r"^# ([a-z_]+):\s*([^#\n]*?)\s*(?:#.*)?$", l)
|
|
722
|
+
if k:
|
|
723
|
+
out[k.group(1)] = k.group(2)
|
|
724
|
+
return out
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def parse_feature(text):
|
|
728
|
+
"""Danh sách kịch bản: {line, kind, title, tags, steps, examples} + các mã đã bỏ."""
|
|
729
|
+
scenarios, removed, tags, cur, in_examples = [], [], [], None, False
|
|
730
|
+
for i, l in enumerate(text.split("\n")):
|
|
731
|
+
s = l.strip()
|
|
732
|
+
rm = REMOVED_SC.match(l)
|
|
733
|
+
if rm:
|
|
734
|
+
removed.append(rm.group(1))
|
|
735
|
+
continue
|
|
736
|
+
if s.startswith("#") or not s:
|
|
737
|
+
continue
|
|
738
|
+
if s.startswith("@"):
|
|
739
|
+
tags += re.findall(r"@([^\s@]+)", s)
|
|
740
|
+
continue
|
|
741
|
+
if re.match(r"^(Feature|Background|Rule):", s):
|
|
742
|
+
tags, cur = [], None
|
|
743
|
+
continue
|
|
744
|
+
m = SCENARIO_LINE.match(l)
|
|
745
|
+
if m:
|
|
746
|
+
cur = {"line": i + 1, "outline": m.group(1) in ("Scenario Outline", "Scenario Template"),
|
|
747
|
+
"title": m.group(2).strip(), "tags": tags, "steps": [], "examples": -1}
|
|
748
|
+
scenarios.append(cur)
|
|
749
|
+
tags, in_examples = [], False
|
|
750
|
+
continue
|
|
751
|
+
if cur is None:
|
|
752
|
+
continue
|
|
753
|
+
if re.match(r"^(Examples|Scenarios):", s):
|
|
754
|
+
in_examples, cur["examples"] = True, max(cur["examples"], 0) - 1
|
|
755
|
+
continue
|
|
756
|
+
if in_examples and s.startswith("|"):
|
|
757
|
+
cur["examples"] += 1 # dòng đầu là tiêu đề cột
|
|
758
|
+
continue
|
|
759
|
+
st = STEP_LINE.match(l)
|
|
760
|
+
if st:
|
|
761
|
+
cur["steps"].append(st.group(1))
|
|
762
|
+
for sc in scenarios:
|
|
763
|
+
sc["ids"] = [t for t in sc["tags"] if SC_TAG.match("@" + t)]
|
|
764
|
+
return scenarios, removed
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def bdd_report(feature, prd=None):
|
|
768
|
+
"""(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."""
|
|
769
|
+
prd = prd or prd_of(feature)
|
|
770
|
+
uc = uc_of(feature)
|
|
771
|
+
text, ptext = read(feature), read(prd)
|
|
772
|
+
errors, warns = [], []
|
|
773
|
+
head = feature_header(text)
|
|
774
|
+
if head.get("type") != "bdd":
|
|
775
|
+
errors.append("header thiếu `# type: bdd` ở đầu file")
|
|
776
|
+
if head.get("uc") != uc:
|
|
777
|
+
errors.append("header `# uc:` phải là %s (theo tên file), đang là %s" % (uc, head.get("uc") or "trống"))
|
|
778
|
+
for key in ("prd_version", "version"):
|
|
779
|
+
if not VERSION.match(head.get(key, "")):
|
|
780
|
+
errors.append("header `# %s:` phải dạng số.số, ví dụ 1.0" % key)
|
|
781
|
+
if head.get("status") not in FRONTMATTER["bdd"]["status"]:
|
|
782
|
+
errors.append("header `# status:` chỉ nhận draft | approved")
|
|
783
|
+
plines = ptext.split("\n")
|
|
784
|
+
span = uc_span(plines, uc)
|
|
785
|
+
if not span:
|
|
786
|
+
errors.append("PRD %s không có %s" % (prd, uc))
|
|
787
|
+
return errors, warns, {}
|
|
788
|
+
if span[2]:
|
|
789
|
+
errors.append("%s đã bỏ trong PRD — không viết BDD cho UC đã bỏ" % uc)
|
|
790
|
+
items = prd_items(ptext)
|
|
791
|
+
own = [c for c, alive in items.items() if alive and c.startswith(uc + "-")]
|
|
792
|
+
platforms = uc_platforms(ptext, uc)
|
|
793
|
+
if not platforms:
|
|
794
|
+
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). "
|
|
795
|
+
"Chạy `/prd <EPIC-ID> change khai nền tảng …` trước." % uc)
|
|
796
|
+
scenarios, removed = parse_feature(text)
|
|
797
|
+
if not scenarios:
|
|
798
|
+
errors.append("file chưa có kịch bản nào")
|
|
799
|
+
seen = list(removed)
|
|
800
|
+
per_platform = {p: 0 for p in platforms}
|
|
801
|
+
for sc in scenarios:
|
|
802
|
+
where = "dòng %d (%s)" % (sc["line"], sc["title"][:60])
|
|
803
|
+
if len(sc["ids"]) != 1:
|
|
804
|
+
errors.append("%s: cần đúng 1 mã @%s-SCnn, đang có %d" % (where, uc, len(sc["ids"])))
|
|
805
|
+
for sid in sc["ids"]:
|
|
806
|
+
if not sid.startswith(uc + "-"):
|
|
807
|
+
errors.append("%s: mã %s không thuộc %s" % (where, sid, uc))
|
|
808
|
+
if sid in seen:
|
|
809
|
+
errors.append("%s: mã %s đã dùng (kể cả mã đã bỏ) — lấy `next-sc`" % (where, sid))
|
|
810
|
+
seen.append(sid)
|
|
811
|
+
refs = [t for t in sc["tags"] if ITEM_CODE.fullmatch(t)]
|
|
812
|
+
others = [t for t in sc["tags"] if t not in sc["ids"] and t not in refs]
|
|
813
|
+
kinds = [t for t in others if t in SC_TYPES]
|
|
814
|
+
plats = [t for t in others if t in platforms]
|
|
815
|
+
unknown = [t for t in others if t not in SC_TYPES and t not in platforms]
|
|
816
|
+
if len(kinds) != 1:
|
|
817
|
+
errors.append("%s: cần đúng 1 loại (@%s), đang có %d" % (where, " @".join(SC_TYPES), len(kinds)))
|
|
818
|
+
# 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.
|
|
819
|
+
if platforms and not plats:
|
|
820
|
+
errors.append("%s: thiếu tag nền tảng (UC khai: %s)" % (where, " · ".join(platforms)))
|
|
821
|
+
if platforms and unknown:
|
|
822
|
+
errors.append("%s: tag lạ %s — không phải nền tảng UC khai (%s), loại, hay mã AC/BR"
|
|
823
|
+
% (where, " ".join("@" + t for t in unknown), " · ".join(platforms)))
|
|
824
|
+
for p in plats:
|
|
825
|
+
per_platform[p] += 1
|
|
826
|
+
if not refs:
|
|
827
|
+
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)
|
|
828
|
+
for r in refs:
|
|
829
|
+
if r not in items:
|
|
830
|
+
errors.append("%s: @%s không có trong PRD" % (where, r))
|
|
831
|
+
elif not items[r]:
|
|
832
|
+
errors.append("%s: @%s đã bỏ trong PRD — sửa hoặc bỏ kịch bản này" % (where, r))
|
|
833
|
+
if "Then" not in sc["steps"]:
|
|
834
|
+
errors.append("%s: thiếu bước Then (kết quả quan sát được)" % where)
|
|
835
|
+
if sc["outline"] and sc["examples"] < 1:
|
|
836
|
+
errors.append("%s: Scenario Outline cần Examples có ít nhất 1 dòng dữ liệu" % where)
|
|
837
|
+
if not sc["outline"] and sc["examples"] >= 0:
|
|
838
|
+
errors.append("%s: có Examples thì phải là Scenario Outline" % where)
|
|
839
|
+
covered = set()
|
|
840
|
+
for f in glob.glob(os.path.join(os.path.dirname(os.path.abspath(feature)), "*.feature")):
|
|
841
|
+
for sc in parse_feature(read(f))[0]:
|
|
842
|
+
covered.update(t for t in sc["tags"] if ITEM_CODE.fullmatch(t))
|
|
843
|
+
missing = [c for c in own if c not in covered]
|
|
844
|
+
if missing:
|
|
845
|
+
errors.append("AC/BR của %s chưa có kịch bản nào: %s" % (uc, ", ".join(missing)))
|
|
846
|
+
pv = prd_version(ptext)
|
|
847
|
+
if pv and head.get("prd_version") and pv != head.get("prd_version"):
|
|
848
|
+
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ù"
|
|
849
|
+
% (pv, head.get("prd_version"), uc))
|
|
850
|
+
acs = [c for c in own if "-AC" in c]
|
|
851
|
+
stats = {"scenarios": len(scenarios), "removed": len(removed),
|
|
852
|
+
"ac": (len([c for c in acs if c in covered]), len(acs)),
|
|
853
|
+
"br": (len([c for c in own if "-BR" in c and c in covered]), len(own) - len(acs)),
|
|
854
|
+
"platforms": per_platform}
|
|
855
|
+
return errors, warns, stats
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def cmd_bddcheck(feature, prd=None):
|
|
859
|
+
errors, warns, st = bdd_report(feature, prd)
|
|
860
|
+
if st:
|
|
861
|
+
print("%s: %d kịch bản (%d đã bỏ) · AC %d/%d · BR %d/%d · %s" % (
|
|
862
|
+
uc_of(feature), st["scenarios"], st["removed"], st["ac"][0], st["ac"][1], st["br"][0], st["br"][1],
|
|
863
|
+
" · ".join("%s %d" % kv for kv in st["platforms"].items()) or "chưa khai nền tảng"), flush=True)
|
|
864
|
+
ptext = read(prd or prd_of(feature))
|
|
865
|
+
todo = [u for u in re.findall(r"^###\s+(UC-\d{3})(?![\d-])", ptext, re.M)
|
|
866
|
+
if not os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(feature)), u + ".feature"))]
|
|
867
|
+
print("UC chưa có BDD trong PRD này: " + (", ".join(todo) or "không còn"), flush=True)
|
|
868
|
+
for w in warns:
|
|
869
|
+
print("⚠ " + w, flush=True)
|
|
870
|
+
if errors:
|
|
871
|
+
fail("\n - ".join([""] + errors))
|
|
872
|
+
print("đủ — khuôn, tag và độ phủ AC/BR đều đạt")
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def guard_bdd(path, block):
|
|
876
|
+
"""BDD chỉ được duyệt khi bddcheck sạch, PRD đang approved và BDD viết đúng version PRD hiện tại."""
|
|
877
|
+
errors, _, _ = bdd_report(path)
|
|
878
|
+
if errors:
|
|
879
|
+
fail("không đặt được status=approved: `bddcheck` còn lỗi:\n - " + "\n - ".join(errors))
|
|
880
|
+
ptext = read(prd_of(path))
|
|
881
|
+
ps = re.search(r"^status:\s*(\S+)", ptext, re.M)
|
|
882
|
+
if not ps or ps.group(1) != "approved":
|
|
883
|
+
fail("không đặt được status=approved: PRD chưa approved. Duyệt PRD trước.")
|
|
884
|
+
bv = re.search(r"^prd_version:\s*(\S+)", block, re.M)
|
|
885
|
+
if not bv or bv.group(1) != prd_version(ptext):
|
|
886
|
+
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."
|
|
887
|
+
% (bv.group(1) if bv else "?", prd_version(ptext)))
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def cmd_where(specs_dir, ucs):
|
|
891
|
+
if len(ucs) != 1:
|
|
892
|
+
fail("cách dùng: where <thư mục specs> UC-001", 2)
|
|
893
|
+
pat = re.compile(r"^###\s+(?:~~)?%s(?![\d-])" % re.escape(ucs[0]), re.M)
|
|
894
|
+
hits = [p for p in glob.glob(os.path.join(specs_dir, "**", "prd.md"), recursive=True) if pat.search(read(p))]
|
|
895
|
+
if len(hits) != 1:
|
|
896
|
+
fail("%s có trong %d PRD (cần đúng 1)%s" % (ucs[0], len(hits), (": " + ", ".join(hits)) if hits else ""))
|
|
897
|
+
print(hits[0].replace("\\", "/"))
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def cmd_context(path, ucs):
|
|
901
|
+
"""Phần PRD cần để viết BDD của một UC — thay cho đọc cả PRD."""
|
|
902
|
+
if len(ucs) != 1:
|
|
903
|
+
fail("cách dùng: context <prd> UC-001", 2)
|
|
904
|
+
uc = ucs[0]
|
|
905
|
+
text = read(path)
|
|
906
|
+
lines = text.split("\n")
|
|
907
|
+
span = uc_span(lines, uc)
|
|
908
|
+
if not span:
|
|
909
|
+
fail("không có %s trong %s" % (uc, path))
|
|
910
|
+
mention = re.compile(r"%s(?![\d-])" % re.escape(uc))
|
|
911
|
+
out = ["# PRD %s — v%s · status %s" % (path, prd_version(text), (re.search(r"^status:\s*(\S+)", text, re.M) or [None, "?"])[1])]
|
|
912
|
+
out.append(section_text(text, "overview") or "(PRD chưa có mục Tổng quan)")
|
|
913
|
+
flow = (section_text(text, "userflow") or "").split("\n")
|
|
914
|
+
rows = [l for l in flow if l.startswith("| J") and mention.search(l)]
|
|
915
|
+
if rows:
|
|
916
|
+
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
|
|
917
|
+
blocks = [i for i, l in enumerate(flow) if re.match(r"^### J\d+", l)]
|
|
918
|
+
for n, i in enumerate(blocks):
|
|
919
|
+
end = blocks[n + 1] if n + 1 < len(blocks) else len(flow)
|
|
920
|
+
if mention.search("\n".join(flow[i:end])):
|
|
921
|
+
out += [""] + [l for l in flow[i:end]]
|
|
922
|
+
screens = (section_text(text, "screens") or "").split("\n")
|
|
923
|
+
srows = [l for l in screens if l.startswith("|") and mention.search(l)]
|
|
924
|
+
if srows:
|
|
925
|
+
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
|
|
926
|
+
block = lines[span[0]:span[1]]
|
|
927
|
+
out += [""] + block
|
|
928
|
+
body = "\n".join(block)
|
|
929
|
+
foreign = sorted({c for c in ITEM_CODE.findall(body) if not c.startswith(uc + "-")})
|
|
930
|
+
ucs_ref = sorted({u for u in re.findall(r"UC-\d{3}(?![\d-])", body) if u != uc})
|
|
931
|
+
if foreign or ucs_ref:
|
|
932
|
+
out += ["", "## Mục của UC khác mà %s trỏ tới" % uc]
|
|
933
|
+
for u in ucs_ref:
|
|
934
|
+
s = uc_span(lines, u)
|
|
935
|
+
if s:
|
|
936
|
+
out.append(lines[s[0]])
|
|
937
|
+
for c in foreign:
|
|
938
|
+
hits = find_item(lines, c)
|
|
939
|
+
out.append(lines[hits[0]] if hits else "(không tìm thấy %s trong PRD)" % c)
|
|
940
|
+
print("\n".join(out).rstrip())
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def used_sc(text, uc):
|
|
944
|
+
return sorted({int(m.group(3)) for m in SC_ANY.finditer(text) if m.group(2) == uc[3:]})
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def cmd_next_sc(path):
|
|
948
|
+
uc = uc_of(path)
|
|
949
|
+
nums = used_sc(read(path), uc) if os.path.isfile(path) else []
|
|
950
|
+
print("%s-SC%02d" % (uc, max(nums, default=0) + 1))
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def scenario_blocks(lines):
|
|
954
|
+
"""[(mã, đầu, cuối)] — khối gồm tag, comment 🤖/✅ ngay trên, dòng Scenario, các bước, Examples."""
|
|
955
|
+
starts = []
|
|
956
|
+
for i, l in enumerate(lines):
|
|
957
|
+
if SCENARIO_LINE.match(l):
|
|
958
|
+
s = i
|
|
959
|
+
while s > 0 and (lines[s - 1].strip().startswith("@") or MARK_COMMENT.match(lines[s - 1])):
|
|
960
|
+
s -= 1
|
|
961
|
+
ids = SC_TAG.findall("\n".join(lines[s:i]))
|
|
962
|
+
starts.append((ids[0] if len(ids) == 1 else None, s))
|
|
963
|
+
out = []
|
|
964
|
+
for n, (sid, s) in enumerate(starts):
|
|
965
|
+
limit = starts[n + 1][1] if n + 1 < len(starts) else len(lines)
|
|
966
|
+
e = next((j for j in range(s + 1, limit) if GROUP_LINE.match(lines[j]) or REMOVED_SC.match(lines[j])), limit)
|
|
967
|
+
while e > s and not lines[e - 1].strip():
|
|
968
|
+
e -= 1
|
|
969
|
+
out.append((sid, s, e))
|
|
970
|
+
return out
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def find_block(lines, sid):
|
|
974
|
+
hits = [b for b in scenario_blocks(lines) if b[0] == sid]
|
|
975
|
+
if len(hits) != 1:
|
|
976
|
+
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))
|
|
977
|
+
return hits[0]
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def cmd_scenario(path, codes):
|
|
981
|
+
"""Mã kịch bản → đúng khối đó. Mã AC/BR → mọi kịch bản gắn tag mã đó."""
|
|
982
|
+
if not codes:
|
|
983
|
+
fail("cách dùng: scenario <feature> UC-001-SC03 | UC-001-BR04 […]", 2)
|
|
984
|
+
lines = read(path).split("\n")
|
|
985
|
+
shown = set()
|
|
986
|
+
for code in codes:
|
|
987
|
+
if ITEM_CODE.fullmatch(code):
|
|
988
|
+
tag = re.compile(r"@%s(?![\w-])" % re.escape(code))
|
|
989
|
+
blocks = [b for b in scenario_blocks(lines) if tag.search("\n".join(lines[b[1]:b[2]]))]
|
|
990
|
+
if not blocks:
|
|
991
|
+
print("(không có kịch bản nào gắn @%s)\n" % code)
|
|
992
|
+
else:
|
|
993
|
+
blocks = [find_block(lines, code)]
|
|
994
|
+
for _, s, e in blocks:
|
|
995
|
+
if s not in shown:
|
|
996
|
+
shown.add(s)
|
|
997
|
+
print("\n".join(lines[s:e]))
|
|
998
|
+
print()
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def cmd_sc(path):
|
|
1002
|
+
"""stdin = JSON: {"id", "new"} thay cả khối · {"after": mã | "end", "new"} thêm · {"remove", "version"} bỏ."""
|
|
1003
|
+
try:
|
|
1004
|
+
ops = json.loads(stdin_text())
|
|
1005
|
+
except json.JSONDecodeError as e:
|
|
1006
|
+
fail("stdin không phải JSON hợp lệ: " + str(e), 2)
|
|
1007
|
+
if not isinstance(ops, list) or not ops:
|
|
1008
|
+
fail('stdin phải là danh sách [{"id"|"after"|"remove": …}]', 2)
|
|
1009
|
+
uc = uc_of(path)
|
|
1010
|
+
lines = read(path).split("\n")
|
|
1011
|
+
for n, op in enumerate(ops, 1):
|
|
1012
|
+
keys = [k for k in ("id", "after", "remove") if k in op]
|
|
1013
|
+
if len(keys) != 1:
|
|
1014
|
+
fail("thao tác #%d phải có đúng một trong `id` (thay), `after` (thêm sau), `remove` (bỏ)" % n, 2)
|
|
1015
|
+
if "remove" in op:
|
|
1016
|
+
if not VERSION.match(str(op.get("version", ""))):
|
|
1017
|
+
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)
|
|
1018
|
+
sid = op["remove"]
|
|
1019
|
+
_, s, e = find_block(lines, sid)
|
|
1020
|
+
title = next((SCENARIO_LINE.match(l).group(2) for l in lines[s:e] if SCENARIO_LINE.match(l)), "")
|
|
1021
|
+
lines[s:e] = [" # Đã bỏ (v%s) @%s — %s" % (op["version"], sid, title.strip())]
|
|
1022
|
+
continue
|
|
1023
|
+
new = op.get("new")
|
|
1024
|
+
if not isinstance(new, str) or not new.strip():
|
|
1025
|
+
fail("thao tác #%d thiếu `new`" % n, 2)
|
|
1026
|
+
new_lines = new.rstrip("\n").split("\n")
|
|
1027
|
+
ids = SC_TAG.findall(new)
|
|
1028
|
+
if sum(1 for l in new_lines if SCENARIO_LINE.match(l)) != 1 or len(ids) != 1:
|
|
1029
|
+
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)
|
|
1030
|
+
if "id" in op:
|
|
1031
|
+
if ids[0] != op["id"]:
|
|
1032
|
+
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"]))
|
|
1033
|
+
_, s, e = find_block(lines, op["id"])
|
|
1034
|
+
lines[s:e] = new_lines
|
|
1035
|
+
else:
|
|
1036
|
+
if not ids[0].startswith(uc + "-"):
|
|
1037
|
+
fail("thao tác #%d: mã %s không thuộc %s" % (n, ids[0], uc))
|
|
1038
|
+
if int(SC_ANY.match(ids[0]).group(3)) in used_sc("\n".join(lines), uc):
|
|
1039
|
+
fail("thao tác #%d: mã %s đã dùng (kể cả mã đã bỏ) — lấy `next-sc`" % (n, ids[0]))
|
|
1040
|
+
if op["after"] == "end":
|
|
1041
|
+
at = len(lines)
|
|
1042
|
+
while at > 0 and not lines[at - 1].strip():
|
|
1043
|
+
at -= 1
|
|
1044
|
+
else:
|
|
1045
|
+
at = find_block(lines, op["after"])[2]
|
|
1046
|
+
lines[at:at] = [""] + new_lines
|
|
1047
|
+
out = "\n".join(lines)
|
|
1048
|
+
if not out.endswith("\n"):
|
|
1049
|
+
out += "\n"
|
|
1050
|
+
write(path, out)
|
|
1051
|
+
print("đã áp %d thao tác theo mã kịch bản trong %s" % (len(ops), path))
|
|
484
1052
|
|
|
485
1053
|
|
|
486
1054
|
PENDING, CONFIRMED = "\U0001F916", "✅"
|
|
@@ -528,7 +1096,8 @@ def main(argv):
|
|
|
528
1096
|
if argv[:1] == ["--check"]:
|
|
529
1097
|
print("ok — Python %s" % sys.version.split()[0])
|
|
530
1098
|
return
|
|
531
|
-
if len(argv) < 2 or argv[0] not in ("create", "edit", "set", "pending", "confirm", "next-uc", "coverage", "section", "upgrade", "flowcheck"
|
|
1099
|
+
if len(argv) < 2 or argv[0] not in ("create", "edit", "set", "pending", "confirm", "next-uc", "coverage", "section", "upgrade", "flowcheck", "item", "uc", "finding", "decide",
|
|
1100
|
+
"where", "context", "next-sc", "scenario", "sc", "bddcheck"):
|
|
532
1101
|
fail(__doc__.split("Cách dùng")[1], 2)
|
|
533
1102
|
cmd, path, rest = argv[0], argv[1], argv[2:]
|
|
534
1103
|
if cmd == "create":
|
|
@@ -552,8 +1121,30 @@ def main(argv):
|
|
|
552
1121
|
cmd_upgrade(path)
|
|
553
1122
|
elif cmd == "flowcheck":
|
|
554
1123
|
cmd_flowcheck(path)
|
|
1124
|
+
elif cmd == "item":
|
|
1125
|
+
cmd_item(path)
|
|
1126
|
+
elif cmd == "uc":
|
|
1127
|
+
cmd_uc(path, rest)
|
|
1128
|
+
elif cmd == "finding":
|
|
1129
|
+
cmd_finding(path, rest)
|
|
1130
|
+
elif cmd == "decide":
|
|
1131
|
+
cmd_decide(path)
|
|
555
1132
|
elif cmd == "next-uc":
|
|
556
1133
|
cmd_next_uc(path)
|
|
1134
|
+
elif cmd == "where":
|
|
1135
|
+
cmd_where(path, rest)
|
|
1136
|
+
elif cmd == "context":
|
|
1137
|
+
cmd_context(path, rest)
|
|
1138
|
+
elif cmd == "next-sc":
|
|
1139
|
+
cmd_next_sc(path)
|
|
1140
|
+
elif cmd == "scenario":
|
|
1141
|
+
cmd_scenario(path, rest)
|
|
1142
|
+
elif cmd == "sc":
|
|
1143
|
+
cmd_sc(path)
|
|
1144
|
+
elif cmd == "bddcheck":
|
|
1145
|
+
if len(rest) > 1:
|
|
1146
|
+
fail("cách dùng: bddcheck <feature> [prd]", 2)
|
|
1147
|
+
cmd_bddcheck(path, rest[0] if rest else None)
|
|
557
1148
|
elif cmd == "coverage":
|
|
558
1149
|
if len(rest) != 1:
|
|
559
1150
|
fail("cách dùng: coverage <epic> <prd>", 2)
|