@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.
@@ -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
- def cmd_edit(path):
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 == "refined" and value != "—" and not VERSION.match(value):
152
- fail("refined=%s không hợp lệ. Là version PRD đã refine xong (vd 1.1), hoặc —" % value)
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
- m = split_frontmatter(text)
258
- block = m.group(1)
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[m.end():], path)
275
- write(path, text[: m.start(1)] + block + text[m.end(1):])
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
- cmd_edit(path)
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)