@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.
@@ -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
- m = split_frontmatter(text)
258
- block = m.group(1)
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[m.end():], path)
275
- write(path, text[: m.start(1)] + block + text[m.end(1):])
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)