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.
- package/README.md +76 -81
- package/assets/.appweaver/DESIGN-FORMAT.md +56 -17
- package/assets/.appweaver/STACK-PROFILES.md +95 -60
- package/assets/.appweaver/orchestrator/LAUNCHER.md +1 -1
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +173 -32
- package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +29 -1
- package/assets/.appweaver/orchestrator/modules/foundation-pack.orch.yaml +7 -4
- package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +49 -60
- package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +58 -0
- package/assets/.appweaver/orchestrator/orchestrator.py +26 -8
- package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +20 -16
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +30 -6
- package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +9 -7
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +5 -5
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +341 -36
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +254 -43
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +248 -68
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +251 -20
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +316 -10
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +6 -47
- package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +62 -16
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +132 -9
- package/assets/.appweaver/orchestrator/workflows/aw-spec-fix-test-coverage.orch.yaml +965 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +222 -169
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +28 -12
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +453 -116
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-lane.orch.yaml +7 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +83 -6
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +45 -2
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +324 -74
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +275 -44
- package/assets/.appweaver/scripts/python/aw-foundation.py +16 -13
- package/assets/.appweaver/scripts/python/aw-gaps.py +11 -3
- package/assets/.appweaver/scripts/python/aw-model-guard.py +295 -0
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +412 -3
- package/assets/.appweaver/scripts/python/aw-plangate.py +23 -16
- package/assets/.appweaver/scripts/python/aw-stack.py +1794 -265
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +0 -2
- package/assets/.appweaver/stack-profiles/java-spring.json +0 -2
- package/assets/.appweaver/templates/api-plan-template.md +107 -0
- package/assets/.appweaver/templates/delta-api-plan-template.md +87 -0
- package/assets/.appweaver/templates/delta-design-template.md +25 -23
- package/assets/.appweaver/templates/delta-spec-template.md +3 -3
- package/assets/.appweaver/templates/design-template.md +20 -42
- package/assets/.appweaver/templates/domain-model-template.md +3 -0
- package/assets/.appweaver/templates/ui-delta-design-template.md +11 -8
- package/assets/.appweaver/templates/ui-design-template.md +4 -4
- package/assets/foundations/foundation.spec.template.txt +25 -18
- package/assets/guardrails/README.md +61 -25
- package/assets/guardrails/methodology/ux/accessibility-standards.md +38 -0
- package/assets/guardrails/methodology/ux/content-design.md +34 -0
- package/assets/guardrails/methodology/ux/interaction-states.md +45 -0
- package/assets/guardrails/methodology/ux/journey-mapping.md +41 -0
- package/assets/guardrails/project-specific/domain/domain-requirements.md +25 -38
- package/assets/guardrails/project-specific/domain-rule-bindings.md +33 -69
- package/assets/guardrails/project-specific/frontend/ui-requirements.md +2 -2
- package/assets/guardrails/project-specific/service/technical-requirements.md +2 -2
- package/assets/guardrails/project-specific/tech-rule-bindings.md +16 -13
- package/assets/guardrails/sample-healthcare/domain/domain-requirements.md +58 -0
- package/assets/guardrails/sample-healthcare/domain-rule-bindings.md +138 -0
- package/assets/guardrails/sample-healthcare/frontend/ui-requirements.md +43 -0
- package/assets/guardrails/sample-healthcare/service/technical-requirements.md +40 -0
- package/assets/guardrails/sample-healthcare/tech-rule-bindings.md +303 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +216 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/utils/config.js +1 -1
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/foundation.d.ts +36 -0
- package/dist/utils/foundation.d.ts.map +1 -0
- package/dist/utils/foundation.js +109 -0
- package/dist/utils/foundation.js.map +1 -0
- package/dist/utils/paths.js +1 -1
- package/dist/utils/paths.js.map +1 -1
- 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]...
|
|
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
|
-
|
|
376
|
-
|
|
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
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
backend plan
|
|
35
|
-
layers leaves the frontend receipt fresh. A receipt written before the
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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)"
|