aw-spec-cli 0.1.32 → 0.1.34

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.
Files changed (75) hide show
  1. package/README.md +76 -81
  2. package/assets/.appweaver/DESIGN-FORMAT.md +56 -17
  3. package/assets/.appweaver/STACK-PROFILES.md +95 -60
  4. package/assets/.appweaver/orchestrator/LAUNCHER.md +1 -1
  5. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +173 -32
  6. package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +29 -1
  7. package/assets/.appweaver/orchestrator/modules/foundation-pack.orch.yaml +7 -4
  8. package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +49 -60
  9. package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +58 -0
  10. package/assets/.appweaver/orchestrator/orchestrator.py +26 -8
  11. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +20 -16
  12. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +30 -6
  13. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +9 -7
  14. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +5 -5
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +341 -36
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +254 -43
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +248 -68
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +251 -20
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +316 -10
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +6 -47
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +62 -16
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +132 -9
  23. package/assets/.appweaver/orchestrator/workflows/aw-spec-fix-test-coverage.orch.yaml +965 -0
  24. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +222 -169
  25. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +28 -12
  26. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +453 -116
  27. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-lane.orch.yaml +7 -4
  28. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +83 -6
  29. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +45 -2
  30. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +324 -74
  31. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +275 -44
  32. package/assets/.appweaver/scripts/python/aw-foundation.py +16 -13
  33. package/assets/.appweaver/scripts/python/aw-gaps.py +11 -3
  34. package/assets/.appweaver/scripts/python/aw-model-guard.py +295 -0
  35. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +412 -3
  36. package/assets/.appweaver/scripts/python/aw-plangate.py +23 -16
  37. package/assets/.appweaver/scripts/python/aw-stack.py +1794 -265
  38. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +0 -2
  39. package/assets/.appweaver/stack-profiles/java-spring.json +0 -2
  40. package/assets/.appweaver/templates/api-plan-template.md +107 -0
  41. package/assets/.appweaver/templates/delta-api-plan-template.md +87 -0
  42. package/assets/.appweaver/templates/delta-design-template.md +25 -23
  43. package/assets/.appweaver/templates/delta-spec-template.md +3 -3
  44. package/assets/.appweaver/templates/design-template.md +20 -42
  45. package/assets/.appweaver/templates/domain-model-template.md +3 -0
  46. package/assets/.appweaver/templates/ui-delta-design-template.md +11 -8
  47. package/assets/.appweaver/templates/ui-design-template.md +4 -4
  48. package/assets/foundations/foundation.spec.template.txt +25 -18
  49. package/assets/guardrails/README.md +61 -25
  50. package/assets/guardrails/methodology/ux/accessibility-standards.md +38 -0
  51. package/assets/guardrails/methodology/ux/content-design.md +34 -0
  52. package/assets/guardrails/methodology/ux/interaction-states.md +45 -0
  53. package/assets/guardrails/methodology/ux/journey-mapping.md +41 -0
  54. package/assets/guardrails/project-specific/domain/domain-requirements.md +25 -38
  55. package/assets/guardrails/project-specific/domain-rule-bindings.md +33 -69
  56. package/assets/guardrails/project-specific/frontend/ui-requirements.md +2 -2
  57. package/assets/guardrails/project-specific/service/technical-requirements.md +2 -2
  58. package/assets/guardrails/project-specific/tech-rule-bindings.md +16 -13
  59. package/assets/guardrails/sample-healthcare/domain/domain-requirements.md +58 -0
  60. package/assets/guardrails/sample-healthcare/domain-rule-bindings.md +138 -0
  61. package/assets/guardrails/sample-healthcare/frontend/ui-requirements.md +43 -0
  62. package/assets/guardrails/sample-healthcare/service/technical-requirements.md +40 -0
  63. package/assets/guardrails/sample-healthcare/tech-rule-bindings.md +303 -0
  64. package/dist/commands/init.d.ts.map +1 -1
  65. package/dist/commands/init.js +216 -7
  66. package/dist/commands/init.js.map +1 -1
  67. package/dist/utils/config.js +1 -1
  68. package/dist/utils/config.js.map +1 -1
  69. package/dist/utils/foundation.d.ts +36 -0
  70. package/dist/utils/foundation.d.ts.map +1 -0
  71. package/dist/utils/foundation.js +109 -0
  72. package/dist/utils/foundation.js.map +1 -0
  73. package/dist/utils/paths.js +1 -1
  74. package/dist/utils/paths.js.map +1 -1
  75. package/package.json +1 -1
@@ -29,6 +29,13 @@ TWO KINDS OF ELEMENT.
29
29
  An element is a GAP when it is checked and not found, or when the model's report does not
30
30
  say it was done — including when the report does not mention it at all.
31
31
 
32
+ THE API PLAN. A backend plan realises the endpoints of the API plan beside it and does not
33
+ restate them, so `--api-plan` names that document — the feature's api-plan.md on the master
34
+ path, a change's delta-api-plan.md on the delta path — and each endpoint block in it (a
35
+ `### `VERB /route`` heading under "## Endpoints" or "## Endpoints added or changed") is a
36
+ report-only element of the backend lane. A plan written before API plans existed keeps its
37
+ endpoints in its own API section and is read as it always was; a name in both is listed once.
38
+
32
39
  THE REPORT. `--reported` takes the `elements` list a generation pass returned —
33
40
  [{"element": "<name as listed>", "status": "done|blocked", "reason": "…"}] — and may be
34
41
  given more than once; a later report overrides an earlier one for the same element, which
@@ -36,11 +43,44 @@ is how a completion pass's answers replace the first pass's.
36
43
 
37
44
  `--list-only` returns the element names without scanning, for the generation prompt.
38
45
 
46
+ TEST CITATIONS (`--citations`, the `test-citations` subcommand). The same plan read from the
47
+ other side: which of the requirements the plan traces does NO test of the slice name.
48
+ `implement` tells every paired test to cite the spec id it covers, and a requirement whose
49
+ test was never written leaves nothing red — the suite is green and the gap is invisible.
50
+ required FR- and BR- ids — the testable behaviour and the invariants. From a master
51
+ plan, the ids in its Traceability section (located by heading text, so §6,
52
+ §4 and §7 all count); from a delta plan (`--mode delta`), the ids its ADDED,
53
+ MODIFIED, model-change and interaction-state rows serve, removed rows aside.
54
+ reported every other id class (US-, UX-, CN-, DC-, IC-, SC-) no test names — listed,
55
+ never required, since a retention period or a success criterion rarely has
56
+ a unit test of its own.
57
+ a citation the id anywhere in a test file — its name, a comment, an attribute, a tag —
58
+ as `FR-004`, `FR_004` or `FR004`, in any case, leading zeros ignored, and
59
+ not glued to a word before it (`XBR-1` is not `BR-1`; `TestFR004` is
60
+ `FR-004`, because a lower-case letter followed by the upper-case id is a
61
+ word boundary in camel case).
62
+ the tests the slice's own test files: the `--paths` given (the OKF concept's list, plus
63
+ what a fix loop wrote) that are tests, inside this lane's folders. FR numbers
64
+ restart in every feature, so another feature's FR-001 must not count. When
65
+ the list holds no test file, every test file under the lane's test path is
66
+ searched instead and `scoped` is false.
67
+ A master plan stamped `origin: reverse-engineered` is ADVISORY: an adopted slice's legacy
68
+ tests predate the spec ids, so its gaps are reported and `ok` stays true. An id the plan
69
+ still traces but spec.md / domain-model.md no longer defines (`--spec` / `--model`; a change
70
+ removed it and the master plan is not re-derived yet) is `stale`, not required. A plan the
71
+ mock transport wrote is skipped. Each uncited id is also returned as a finding routed to
72
+ `fix-test-coverage`, the workflow that writes the missing tests. What this proves is that every required id has a test that
73
+ NAMES it — not that the test asserts what the requirement says.
74
+
39
75
  Always exits 0 with a JSON object; the workflow decides what a gap means.
40
76
 
41
77
  Usage:
42
78
  aw-plan-coverage.py --repo R --plan P --kind backend|frontend [--mode bootstrap|delta]
43
- [--source-path S] [--test-path T] [--reported JSON]... [--list-only]
79
+ [--api-plan A] [--source-path S] [--test-path T] [--reported JSON]...
80
+ [--list-only]
81
+ aw-plan-coverage.py --repo R --plan P --kind backend|frontend --citations
82
+ [--mode bootstrap|delta] [--paths JSON]... [--source-path S]
83
+ [--test-path T] [--spec F] [--model F] [--known-gaps JSON]...
44
84
  """
45
85
 
46
86
  import argparse
@@ -223,6 +263,21 @@ def extract(lines, kind, mode):
223
263
  return found
224
264
 
225
265
 
266
+ def extract_api(lines):
267
+ """[(route, "endpoint", False)] — the endpoint blocks of an API plan, in order.
268
+
269
+ Every `### `VERB /route`` heading under a heading that starts "Endpoints": the master's
270
+ "## Endpoints" and a change's "## Endpoints added or changed" alike.
271
+ """
272
+ found = []
273
+ for h2, _h3, what, payload in walk_sections(lines):
274
+ if what == "h3" and h2.lower().startswith("endpoints"):
275
+ name = re.sub(r"\s+", " ", first_tick(payload))
276
+ if name:
277
+ found.append((name, "endpoint", False))
278
+ return found
279
+
280
+
226
281
  HASH_COMMENT_EXT = {".py", ".rb", ".ex", ".exs"}
227
282
  BLOCK_COMMENT = re.compile(r"/\*.*?\*/|<!--.*?-->|@\*.*?\*@", re.S)
228
283
  LINE_COMMENT = re.compile(r"(?<![:\"'])//[^\n]*")
@@ -351,12 +406,342 @@ def load_reports(raw_list):
351
406
  return out, readable
352
407
 
353
408
 
409
+ # ─────────────────────────────── test citations ───────────────────────────────
410
+
411
+ REQUIRED_CLASSES = ("FR", "BR")
412
+ PLAN_ID = re.compile(r"(?<![A-Za-z0-9])(FR|BR|US|UX|CN|DC|IC|SC)-(\d+)(?!\d)")
413
+ CITE = re.compile(r"(FR|BR|US|UX|CN|DC|IC|SC)[-_]?0*(\d+)(?!\d)", re.I)
414
+ DEFINED = re.compile(r"^\s*(?:[-*+]\s+|\|\s*|#{2,6}\s+)[*_`\s]*(FR|BR)-(\d+)(?!\d)")
415
+ REVERSED = re.compile(r"(?i)\borigin\b[^\n]{0,12}?reverse-engineered")
416
+ MOCK_MARK = "<!-- authored by aw-orchestrator MOCK transport -->"
417
+ # Test-only formats: a behaviour spec is a test whatever folder it sits in.
418
+ TEST_ONLY_EXT = {".feature", ".robot", ".story"}
419
+ # The kit's own trees, skipped only at the repo root: a project's test folder may be called
420
+ # `specs` too.
421
+ KIT_DIRS = {"specs", "guardrails", "knowledge", "foundations"}
422
+ DELTA_SECTIONS = ("ADDED", "MODIFIED", "MODEL CHANGES", "INTERACTION STATES")
423
+
424
+
425
+ def id_key(cls, num):
426
+ return (cls.upper(), int(num))
427
+
428
+
429
+ def traced_ids(lines):
430
+ """(section found, [(key, label)]) — the ids a master plan's Traceability section
431
+ traces. The FIRST cell of each row is the id column in every plan template; a section
432
+ whose rows carry none there is read whole instead."""
433
+ in2 = in3 = found = False
434
+ first, anywhere = [], []
435
+ for i, line in enumerate(lines):
436
+ s = line.strip()
437
+ if s.startswith("#"):
438
+ level = len(s) - len(s.lstrip("#"))
439
+ title = s[level:].strip().lower()
440
+ if level <= 2:
441
+ in2, in3 = "traceability" in title, False
442
+ elif level == 3:
443
+ in3 = "traceability" in title
444
+ found = found or in2 or in3
445
+ continue
446
+ if not (in2 or in3) or not s:
447
+ continue
448
+ if s.startswith("|"):
449
+ if is_separator(s):
450
+ continue
451
+ nxt = lines[i + 1].strip() if i + 1 < len(lines) else ""
452
+ if is_separator(nxt):
453
+ continue
454
+ cells = split_row(s)
455
+ first.extend(PLAN_ID.findall(cells[0] if cells else ""))
456
+ anywhere.extend(PLAN_ID.findall(s))
457
+ return found, [(id_key(c, n), "%s-%s" % (c, n)) for c, n in (first or anywhere)]
458
+
459
+
460
+ def delta_ids(lines):
461
+ """(a change section found, [(key, label)]) — the ids a delta plan's ADDED, MODIFIED,
462
+ model-change and interaction-state rows serve. A row marked removed serves nothing the
463
+ code still has to prove."""
464
+ sec = found = False
465
+ out = []
466
+ for i, line in enumerate(lines):
467
+ s = line.strip()
468
+ if s.startswith("#"):
469
+ level = len(s) - len(s.lstrip("#"))
470
+ if level <= 2:
471
+ title = s[level:].strip().upper()
472
+ sec = title.startswith(DELTA_SECTIONS)
473
+ found = found or title.startswith(("ADDED", "MODIFIED"))
474
+ continue
475
+ if not sec or not s:
476
+ continue
477
+ if s.startswith("|"):
478
+ if is_separator(s):
479
+ continue
480
+ nxt = lines[i + 1].strip() if i + 1 < len(lines) else ""
481
+ if is_separator(nxt):
482
+ continue
483
+ if any(c.strip("`* ").lower() == "removed" for c in split_row(s)):
484
+ continue
485
+ out.extend(PLAN_ID.findall(s))
486
+ return found, [(id_key(c, n), "%s-%s" % (c, n)) for c, n in out]
487
+
488
+
489
+ def defined_ids(path, repo):
490
+ """{key} an authority defines — a bullet, a table row or a heading that opens with the
491
+ id. Empty when the file is absent or defines none in that form, which turns the stale
492
+ filter off rather than declaring every traced id stale."""
493
+ if not path:
494
+ return set()
495
+ full = path if os.path.isabs(path) else os.path.join(repo, path)
496
+ if not os.path.isfile(full):
497
+ return set()
498
+ return {id_key(m.group(1), m.group(2))
499
+ for line in read_plan(full) for m in [DEFINED.match(line)] if m}
500
+
501
+
502
+ def parse_list(raw_list):
503
+ """Every path the caller passed, flattened: JSON lists, comma-separated strings, or the
504
+ engine's rendering of an unset variable."""
505
+ out = []
506
+ for raw in raw_list or []:
507
+ got = parse_generated(raw)
508
+ out.extend(got or [])
509
+ return out
510
+
511
+
512
+ def under(rel, prefixes):
513
+ return not prefixes or any(p == "" or (rel + "/").startswith(p) for p in prefixes)
514
+
515
+
516
+ def is_test(rel, test_prefix, source_prefix):
517
+ ext = os.path.splitext(rel)[1].lower()
518
+ if ext in TEST_ONLY_EXT:
519
+ return True
520
+ if ext not in CODE_EXT:
521
+ return False
522
+ if test_prefix and test_prefix != source_prefix and (rel + "/").startswith(test_prefix):
523
+ return True
524
+ return is_test_file(rel)
525
+
526
+
527
+ def lane_test_files(repo, base, test_prefix, source_prefix):
528
+ """Every test file under one lane folder — the fallback when the slice's own list
529
+ names none."""
530
+ out = []
531
+ top = os.path.join(repo, base.rstrip("/")) if base else repo
532
+ if not os.path.isdir(top):
533
+ return out
534
+ for dirpath, dirnames, filenames in os.walk(top):
535
+ at_root = os.path.abspath(dirpath) == os.path.abspath(repo)
536
+ dirnames[:] = [d for d in dirnames
537
+ if not (d in SKIP_DIRS - KIT_DIRS or d.startswith(".")
538
+ or (at_root and d in KIT_DIRS))]
539
+ for fn in filenames:
540
+ rel = os.path.relpath(os.path.join(dirpath, fn), repo).replace("\\", "/")
541
+ if is_test(rel, test_prefix, source_prefix):
542
+ out.append(rel)
543
+ if len(out) >= MAX_FILES:
544
+ return out
545
+ return out
546
+
547
+
548
+ def citations(repo, files):
549
+ """{key: first file citing it} over the given test files. A letter right before the id
550
+ rules a match out — that is what keeps `XBR-1` from reading as `BR-1` — except a
551
+ lower-case letter before an upper-case id, the camel-case word boundary of
552
+ `TestFR004`."""
553
+ found = {}
554
+ for rel in files:
555
+ full = os.path.join(repo, rel)
556
+ try:
557
+ if os.path.getsize(full) > MAX_BYTES:
558
+ continue
559
+ text = open(full, encoding="utf-8", errors="ignore").read()
560
+ except OSError:
561
+ continue
562
+ for m in CITE.finditer(text):
563
+ st = m.start()
564
+ prev = text[st - 1] if st else ""
565
+ if prev.isalpha() and not (prev.islower() and m.group(0)[0].isupper()):
566
+ continue
567
+ found.setdefault(id_key(m.group(1), m.group(2)), rel)
568
+ return found
569
+
570
+
571
+ def known_test_refs(raw_list, kind):
572
+ """{key} a gate's own findings already report as untested for this lane, so the same
573
+ gap is not reported twice."""
574
+ refs = set()
575
+ for raw in raw_list or []:
576
+ raw = (raw or "").strip()
577
+ if raw in ("", "[]", "None", "null"):
578
+ continue
579
+ try:
580
+ data = json.loads(raw)
581
+ except ValueError:
582
+ continue
583
+ if isinstance(data, dict):
584
+ data = data.get("gaps") or []
585
+ for g in data if isinstance(data, list) else []:
586
+ if not isinstance(g, dict) or str(g.get("check", "")).lower() != "test":
587
+ continue
588
+ if g.get("lane") and str(g.get("lane")).lower() != kind:
589
+ continue
590
+ m = PLAN_ID.search(str(g.get("ref", "")).upper())
591
+ if m:
592
+ refs.add(id_key(m.group(1), m.group(2)))
593
+ return refs
594
+
595
+
596
+ def cmd_citations(args, repo, plan):
597
+ kind, mode = args.kind, args.mode if args.mode in ("bootstrap", "delta") else "bootstrap"
598
+ lane = "%s lane" % kind
599
+ # Messages name the plan from the repo root, however the caller spelled it.
600
+ try:
601
+ shown = os.path.relpath(plan, repo).replace("\\", "/")
602
+ except ValueError: # another drive on Windows
603
+ shown = ".."
604
+ if shown.startswith(".."):
605
+ shown = args.plan
606
+ src, tst = norm_path(args.source_path), norm_path(args.test_path)
607
+ base = {"plan": args.plan, "kind": kind, "mode": mode, "source_path": args.source_path,
608
+ "test_path": args.test_path, "required": [], "required_count": 0, "cited": [],
609
+ "uncited": [], "uncited_count": 0, "uncited_csv": "", "uncited_text": "none",
610
+ "other_uncited": [], "other_uncited_csv": "", "stale": [], "findings": [],
611
+ "test_files": [], "tests_scanned": 0, "scoped": True, "advisory": False,
612
+ "origin": "", "checked": False, "readable": True}
613
+
614
+ def done(ok, clean, outcome, warning, message):
615
+ base.update({"ok": ok, "all_cited": not base["uncited"], "clean": clean,
616
+ "outcome": outcome, "warning": warning, "message": message})
617
+ emit(base)
618
+
619
+ if not os.path.isfile(plan):
620
+ done(True, False, "no-plan",
621
+ "WARNING — %s: the plan %s does not exist, so which requirements have a test "
622
+ "was not checked." % (lane, shown), "the plan %s does not exist" % shown)
623
+ return
624
+ raw = open(plan, encoding="utf-8", errors="ignore").read()
625
+ if MOCK_MARK in raw:
626
+ done(True, True, "mock", "", "the plan was written by the mock transport — not checked")
627
+ return
628
+ lines = read_plan(plan)
629
+ reversed_plan = mode == "bootstrap" and bool(REVERSED.search("\n".join(lines)))
630
+ base.update({"origin": "reverse-engineered" if reversed_plan else "",
631
+ "advisory": reversed_plan})
632
+ found, ids = (delta_ids if mode == "delta" else traced_ids)(lines)
633
+ where = ("the ADDED / MODIFIED rows" if mode == "delta" else "the Traceability section")
634
+ if not found or (mode == "bootstrap" and not ids):
635
+ base["readable"] = False
636
+ done(True, False, "unreadable",
637
+ "WARNING — %s: no requirement id could be read from %s of %s, so which "
638
+ "requirements have a test was not checked. %s" % (
639
+ lane, where, shown,
640
+ "A delta plan carries `## ADDED` / `## MODIFIED` tables whose rows cite the "
641
+ "ids they serve." if mode == "delta" else
642
+ "A master plan carries a `## … Traceability` heading over a table whose "
643
+ "first column holds the ids."),
644
+ "no requirement id readable from %s" % where)
645
+ return
646
+
647
+ labels, order = {}, []
648
+ for key, label in ids:
649
+ if key not in labels:
650
+ labels[key] = label
651
+ order.append(key)
652
+
653
+ stale = []
654
+ if mode == "bootstrap":
655
+ spec_ids = defined_ids(args.spec, repo)
656
+ model_ids = defined_ids(args.model, repo) | spec_ids
657
+ authority = {"FR": {k for k in spec_ids if k[0] == "FR"},
658
+ "BR": {k for k in model_ids if k[0] == "BR"}}
659
+ for key in order:
660
+ known = authority.get(key[0])
661
+ if known and key not in known:
662
+ stale.append(key)
663
+
664
+ files, lane_dirs = [], [x for x in (src, tst) if x]
665
+ for p in parse_list(args.paths):
666
+ try:
667
+ rel = (os.path.relpath(p, repo) if os.path.isabs(p) else p).replace("\\", "/")
668
+ except ValueError: # another drive on Windows: not in this repo
669
+ continue
670
+ while rel.startswith("./"):
671
+ rel = rel[2:]
672
+ if (rel not in files and under(rel, lane_dirs) and is_test(rel, tst, src)
673
+ and os.path.isfile(os.path.join(repo, rel))):
674
+ files.append(rel)
675
+ scoped = bool(files)
676
+ if not files:
677
+ folder = tst or src
678
+ files = lane_test_files(repo, folder, tst, src)
679
+ cited = citations(repo, files)
680
+
681
+ required = [k for k in order if k[0] in REQUIRED_CLASSES and k not in stale]
682
+ uncited = [k for k in required if k not in cited]
683
+ other = [k for k in order if k[0] not in REQUIRED_CLASSES and k not in cited]
684
+ skip = known_test_refs(args.known_gaps, kind)
685
+ advisory = base["advisory"]
686
+ findings = [] if advisory else [
687
+ {"severity": "high", "check": "test", "lane": kind, "ref": labels[k],
688
+ "where": "absent",
689
+ "gap": "no test of this slice names %s — a requirement whose test was never "
690
+ "written leaves the suite green" % labels[k],
691
+ "fix": "fix-test-coverage"}
692
+ for k in uncited if k not in skip]
693
+ show = lambda keys: [labels[k] for k in keys]
694
+ base.update({"checked": True, "required": show(required), "required_count": len(required),
695
+ "cited": show([k for k in required if k in cited]),
696
+ "uncited": show(uncited), "uncited_count": len(uncited),
697
+ "uncited_csv": ", ".join(show(uncited)),
698
+ "uncited_text": ", ".join(show(uncited)) or "none",
699
+ "other_uncited": show(other), "other_uncited_csv": ", ".join(show(other)),
700
+ "stale": show(stale), "findings": findings,
701
+ "test_files": files[:25], "tests_scanned": len(files), "scoped": scoped})
702
+
703
+ scope_note = "" if scoped else (
704
+ " This feature's file list names no test file, so every test file under %s was "
705
+ "searched — a test of another feature that names the same id counts too."
706
+ % ((tst or src) or "the repository"))
707
+ if not uncited:
708
+ done(True, True, "traced", "",
709
+ "every one of the %d required id(s) in %s is named by a test (%d test file(s) "
710
+ "read)%s" % (len(required), where, len(files), scope_note))
711
+ return
712
+ ids_text = ", ".join(show(uncited))
713
+ if advisory:
714
+ done(True, False, "advisory",
715
+ "NOTE — %s: %s, traced by the reverse-engineered plan %s, %s no test that names "
716
+ "%s. Reported, not blocking: an adopted slice's legacy tests predate the spec "
717
+ "ids. /appweaver fix-test-coverage writes the tests or names the ids in the "
718
+ "ones that cover them.%s" % (
719
+ lane, ids_text, shown, "has" if len(uncited) == 1 else "have",
720
+ "it" if len(uncited) == 1 else "them", scope_note),
721
+ "%d required id(s) uncited (advisory)" % len(uncited))
722
+ return
723
+ done(False, False, "uncited",
724
+ "WARNING — %s: %s, from %s of %s, %s no test that names %s. Write %s, or name the "
725
+ "id in the test that already exercises it — `%s`, `%s` or `%s` in its name, a "
726
+ "comment, an attribute or a tag; /appweaver fix-test-coverage%s does either.%s" % (
727
+ lane, ids_text, where, shown, "has" if len(uncited) == 1 else "have",
728
+ "it" if len(uncited) == 1 else "them",
729
+ "its test" if len(uncited) == 1 else "a test for each", labels[uncited[0]],
730
+ labels[uncited[0]].replace("-", "_"), labels[uncited[0]].replace("-", ""),
731
+ " <CR-id>" if mode == "delta" else "", scope_note),
732
+ "%d of %d required id(s) named by no test: %s" % (len(uncited), len(required), ids_text))
733
+
734
+
354
735
  def main():
355
736
  ap = argparse.ArgumentParser(description="plan element coverage of a lane's code")
356
737
  ap.add_argument("--repo", default=".")
357
738
  ap.add_argument("--plan", required=True)
358
739
  ap.add_argument("--kind", choices=["backend", "frontend"], required=True)
359
740
  ap.add_argument("--mode", default="", help="bootstrap | delta (default: read the plan)")
741
+ ap.add_argument("--api-plan", dest="api_plan", default="",
742
+ help="backend: the API plan the plan realises (api-plan.md, or a change's "
743
+ "delta-api-plan.md); its endpoint blocks are elements too. Ignored "
744
+ "when the file does not exist.")
360
745
  ap.add_argument("--source-path", default="")
361
746
  ap.add_argument("--test-path", default="")
362
747
  ap.add_argument("--reported", action="append", default=[])
@@ -368,12 +753,27 @@ def main():
368
753
  help="comma-separated names the lane's stack already defines in ambient "
369
754
  "scope. Element names matching one are reported as collisions.")
370
755
  ap.add_argument("--list-only", action="store_true")
756
+ ap.add_argument("--citations", action="store_true",
757
+ help="report the plan's FR-/BR- ids that no test of the slice names")
758
+ ap.add_argument("--paths", action="append", default=[],
759
+ help="--citations: the slice's files (a JSON list); the tests among them "
760
+ "inside this lane's folders are searched")
761
+ ap.add_argument("--spec", default="",
762
+ help="--citations: spec.md — an FR- it no longer defines is stale")
763
+ ap.add_argument("--model", default="",
764
+ help="--citations: domain-model.md — a BR- it no longer defines is stale")
765
+ ap.add_argument("--known-gaps", dest="known_gaps", action="append", default=[],
766
+ help="--citations: a gate's findings; an id already reported there as "
767
+ "untested is not reported again")
371
768
  args = ap.parse_args()
372
769
 
373
770
  repo = os.path.abspath(args.repo)
374
771
  plan = args.plan if os.path.isabs(args.plan) else os.path.join(repo, args.plan)
375
- base = {"plan": args.plan, "kind": args.kind, "source_path": args.source_path,
376
- "test_path": args.test_path}
772
+ if args.citations:
773
+ cmd_citations(args, repo, plan)
774
+ return
775
+ base = {"plan": args.plan, "kind": args.kind, "api_plan": args.api_plan,
776
+ "source_path": args.source_path, "test_path": args.test_path}
377
777
 
378
778
  if not os.path.isfile(plan):
379
779
  base.update({"ok": False, "outcome": "no-plan",
@@ -390,6 +790,15 @@ def main():
390
790
  mode = args.mode if args.mode in ("bootstrap", "delta") else (
391
791
  "delta" if any(l.strip().upper().startswith("## ADDED") for l in lines) else "bootstrap")
392
792
  elements = extract(lines, args.kind, mode)
793
+ api = args.api_plan
794
+ if api and not os.path.isabs(api):
795
+ api = os.path.join(repo, api)
796
+ if args.kind == "backend" and api and os.path.isfile(api):
797
+ seen = {n for n, _, _ in elements}
798
+ for element in extract_api(read_plan(api)):
799
+ if element[0] not in seen and not placeholder(element[0]):
800
+ seen.add(element[0])
801
+ elements.append(element)
393
802
  names = [n for n, _, _ in elements]
394
803
  # NAMES THE STACK ALREADY DEFINES. A plan element called `Route` or `Task` is not a
395
804
  # naming mistake — the plan carries the ubiquitous language and the generator spells it
@@ -17,7 +17,8 @@ and kept in step by eye. One module both shells invoke removes the question: the
17
17
  nothing left to keep in step. Same reason aw-crstate.py owns "has this change merged".
18
18
 
19
19
  ONE RECEIPT PER LANE. With --lane, the receipt is `.aw-plan-gate.<lane>.json` and it
20
- hashes the spec, the model and THAT lane's plan only, with that lane's verdict. A backend
20
+ hashes the spec, the model and THAT lane's documents only — the backend's are its plan and
21
+ the API plan beside it — with that lane's verdict. A backend
21
22
  developer and a frontend developer gate their own plans at different times, so a frontend
22
23
  plan edited, or left with gaps, never refuses the backend's implement, and the reverse.
23
24
  A feature gated before the per-lane receipts existed still has the shared
@@ -26,14 +27,14 @@ names in it, so the other lane's document moving does not read as this lane's dr
26
27
  Without --lane both commands keep the shared receipt.
27
28
 
28
29
  THE FRONTEND RECEIPT ALSO HASHES THE BACKEND'S PUBLISHED SURFACE. A frontend plan binds to
29
- the operations the backend plan's API section publishes, and the backend developer can
30
- change that section after the frontend was gated — rename an operation, change a field,
31
- answer a contract request. So the frontend receipt records `surface_sha`, the hash of that
32
- section (aw-stack's api_section; the whole backend plan when it has none, "" when there is
33
- no backend plan), and `check --lane frontend` reads a different surface as moved bytes. The
34
- backend plan's other sections are not in it: a change to the domain or infrastructure
35
- layers leaves the frontend receipt fresh. A receipt written before the field existed has no
36
- baseline for it and is not refused over it.
30
+ the operations the API plan publishes, and the backend developer can change it after the
31
+ frontend was gated — rename an operation, change a field, answer a contract request. So
32
+ the frontend receipt records `surface_sha`, the hash of the API plan (for a feature planned
33
+ before it existed, the backend plan's API section — aw-stack's api_section — or the whole
34
+ backend plan when it has none; "" when there is neither), and `check --lane frontend` reads
35
+ a different surface as moved bytes. The backend plan is not in it: a change to the domain
36
+ or infrastructure layers leaves the frontend receipt fresh. A receipt written before the
37
+ field existed has no baseline for it and is not refused over it.
37
38
 
38
39
  NO BASELINE IS NOT "NOTHING MOVED". `spec_moved` / `model_moved` report "" where there is
39
40
  nothing to compare against — no receipt, or a receipt that hashed nothing for that
@@ -73,11 +74,13 @@ VERDICTS = ("conformant", "routed-gaps", "gaps")
73
74
  # produces — is GATED on those documents. Hashing only the new names would leave such a
74
75
  # feature with an empty plan map: a receipt that looks tracked and fresh while the document
75
76
  # implement actually generates from is unwatched.
76
- PLAN_NAMES = ("backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md")
77
+ PLAN_NAMES = ("backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md", "api-plan.md")
77
78
 
78
- # Each lane's plan, under its current name and its pre-lane-split one.
79
+ # Each lane's documents: its plan, under its current name and its pre-lane-split one, and for
80
+ # the backend lane the API plan beside it.
81
+ API_PLAN = "api-plan.md"
79
82
  LANE_PLAN_NAMES = {
80
- "backend": ("backend-plan.md", "design.md"),
83
+ "backend": ("backend-plan.md", "design.md", API_PLAN),
81
84
  "frontend": ("frontend-plan.md", "design.ui.md"),
82
85
  }
83
86
 
@@ -115,9 +118,13 @@ def _api_section(text):
115
118
 
116
119
 
117
120
  def surface_sha(fd):
118
- """The hash of the backend plan's published surface — its API section, or the whole plan
119
- when it has none; "" when the feature has no backend plan."""
120
- for name in LANE_PLAN_NAMES["backend"]:
121
+ """The hash of the backend's published surface — the API plan; for a feature planned
122
+ before it existed, the backend plan's API section, or the whole plan when it has none;
123
+ "" when the feature has neither."""
124
+ api = sha(os.path.join(fd, API_PLAN))
125
+ if api:
126
+ return api
127
+ for name in ("backend-plan.md", "design.md"):
121
128
  path = os.path.join(fd, name)
122
129
  if os.path.isfile(path):
123
130
  with open(path, encoding="utf-8", errors="replace") as fh:
@@ -249,7 +256,7 @@ def check(fd, ctx, root, lane=""):
249
256
  if out["model_moved"]:
250
257
  bits.append("domain-model.md has changed")
251
258
  if out["surface_moved"]:
252
- bits.append("the backend plan's published surface (its API section) has changed — "
259
+ bits.append("the backend's published surface (its API plan) has changed — "
253
260
  "re-gate the frontend plan against it")
254
261
  if changed:
255
262
  bits.append("%s changed (a re-derive by `plan main`, or a hand edit)"