aw-spec-cli 0.1.20 → 0.1.21
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/assets/.appweaver/ORCH-DIALECT.md +8 -3
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +22 -1
- package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +6 -6
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +107 -48
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +17 -9
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +265 -20
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +89 -15
- package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +354 -52
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +1 -1
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +651 -76
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +600 -59
- package/assets/.appweaver/scripts/python/aw-crstate.py +194 -0
- package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +3 -0
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +423 -0
- package/assets/.appweaver/scripts/python/aw-stack.py +251 -28
- package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +1 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +1 -0
- package/assets/.appweaver/stack-profiles/node-express.json +1 -0
- package/assets/.appweaver/stack-profiles/python-fastapi.json +1 -0
- package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
- package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
- package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
- package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
- package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
- package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
- package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
- package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
- package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +70 -0
- package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
- package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
- package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
- package/package.json +1 -1
- package/assets/.appweaver/orchestrator/.state/smoke.state.json +0 -32
|
@@ -426,7 +426,19 @@ def cmd_list(args):
|
|
|
426
426
|
suggested_root = detected.get("root") or detected.get("profile_root") or ""
|
|
427
427
|
evidence = ", ".join(detected.get("evidence", [])[:4])
|
|
428
428
|
|
|
429
|
-
|
|
429
|
+
# One readable line for prompts and outputs, so a caller never glues the profile
|
|
430
|
+
# and root together itself and prints " at " when the scan found nothing.
|
|
431
|
+
if not args.suggest:
|
|
432
|
+
detected_summary = "not scanned"
|
|
433
|
+
elif not suggested_profile:
|
|
434
|
+
detected_summary = "nothing detected"
|
|
435
|
+
else:
|
|
436
|
+
where = norm_root(suggested_root)
|
|
437
|
+
detected_summary = "%s at %s" % (
|
|
438
|
+
suggested_profile,
|
|
439
|
+
"the repo root" if where in ("", REPO_ROOT) else where)
|
|
440
|
+
|
|
441
|
+
ordered =sorted(profiles, key=lambda p: p["id"] != suggested_profile) \
|
|
430
442
|
if suggested_profile else profiles
|
|
431
443
|
options = [option_label(p) for p in ordered]
|
|
432
444
|
if args.include_none:
|
|
@@ -435,11 +447,25 @@ def cmd_list(args):
|
|
|
435
447
|
preselect = resolve_preselect(args.preselect, profiles, args.include_none, args.kind)
|
|
436
448
|
preselect_source = "argument" if preselect else ""
|
|
437
449
|
|
|
438
|
-
# Candidate SOURCE PATHS for the follow-up question, best first
|
|
439
|
-
# evidence actually sits, then each profile's own default. Labelled with why
|
|
450
|
+
# Candidate SOURCE PATHS for the follow-up question, best first. Labelled with why
|
|
440
451
|
# each is offered, because the operator picking from this list is choosing the
|
|
441
452
|
# subtree a generator will be allowed to write into. Free text is always
|
|
442
453
|
# accepted — a repo that matches no profile still has a source path.
|
|
454
|
+
#
|
|
455
|
+
# WITH --profile the stack is already chosen, and the list answers for it alone:
|
|
456
|
+
# where THAT stack's evidence sits, then its own default, then where another
|
|
457
|
+
# stack's evidence sits (existing code of this kind, even if it was not what was
|
|
458
|
+
# picked). Other profiles' defaults are not offered — they describe stacks the
|
|
459
|
+
# operator did not choose.
|
|
460
|
+
#
|
|
461
|
+
# WITHOUT --profile the list is built before any choice, and a default root is
|
|
462
|
+
# usually shared (six frontend profiles default to web/), so its label names every
|
|
463
|
+
# profile that proposes it rather than whichever one sorts first.
|
|
464
|
+
chosen = None
|
|
465
|
+
if args.profile:
|
|
466
|
+
pid = profile_id_from_label(args.profile)
|
|
467
|
+
chosen = next((p for p in profiles if p["id"] == pid), None)
|
|
468
|
+
|
|
443
469
|
root_options, seen = [], set()
|
|
444
470
|
|
|
445
471
|
def offer(root, why):
|
|
@@ -449,12 +475,29 @@ def cmd_list(args):
|
|
|
449
475
|
seen.add(r)
|
|
450
476
|
root_options.append(ROOT_LABEL_SEP.join([r, why]))
|
|
451
477
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
478
|
+
def match_root(m):
|
|
479
|
+
return m.get("root") or m.get("profile_root")
|
|
480
|
+
|
|
481
|
+
if chosen:
|
|
482
|
+
for m in matches:
|
|
483
|
+
if m["id"] == chosen["id"]:
|
|
484
|
+
offer(match_root(m), "detected here (%s)" % m["id"])
|
|
485
|
+
offer(chosen.get("root"), "%s default" % chosen["id"])
|
|
486
|
+
for m in matches:
|
|
487
|
+
offer(match_root(m), "detected here (%s)" % m["id"])
|
|
488
|
+
else:
|
|
489
|
+
if suggested_root:
|
|
490
|
+
offer(suggested_root, "detected here (%s)" % (suggested_profile or "evidence"))
|
|
491
|
+
for m in matches[1:]:
|
|
492
|
+
offer(match_root(m), "detected here (%s)" % m["id"])
|
|
493
|
+
by_root = {}
|
|
494
|
+
for p in profiles:
|
|
495
|
+
r = norm_root(p.get("root"))
|
|
496
|
+
if r:
|
|
497
|
+
by_root.setdefault(r, []).append(p["id"])
|
|
498
|
+
for r, ids in by_root.items():
|
|
499
|
+
offer(r, "%s default" % ids[0] if len(ids) == 1
|
|
500
|
+
else "default for %s" % ", ".join(ids))
|
|
458
501
|
|
|
459
502
|
emit({
|
|
460
503
|
"kind": args.kind or "all",
|
|
@@ -466,6 +509,7 @@ def cmd_list(args):
|
|
|
466
509
|
"preselect_source": preselect_source,
|
|
467
510
|
"detected_profile": suggested_profile,
|
|
468
511
|
"detected_root": suggested_root,
|
|
512
|
+
"detected_summary": detected_summary,
|
|
469
513
|
"detected_evidence": evidence,
|
|
470
514
|
"detected_count": len(matches),
|
|
471
515
|
"root_options": root_options,
|
|
@@ -604,6 +648,56 @@ def roots_overlap(a, b):
|
|
|
604
648
|
return a == b or a.startswith(b) or b.startswith(a)
|
|
605
649
|
|
|
606
650
|
|
|
651
|
+
def lane_test_path(repo, lane, prof=None):
|
|
652
|
+
"""Where a lane's paired tests go — the second subtree the lane owns.
|
|
653
|
+
|
|
654
|
+
Most backend stacks keep tests BESIDE the source rather than in it: .NET's `tests/`
|
|
655
|
+
next to `src/`, Maven's `src/test/java/` next to `src/main/java/`, pytest's `tests/`
|
|
656
|
+
next to `app/`. A lane that owned only its source path could not hold its own tests.
|
|
657
|
+
|
|
658
|
+
Answered at READ time, like `ecosystem`, so a lane declared before this existed has
|
|
659
|
+
one without being re-declared:
|
|
660
|
+
- a `test_root` written on the lane wins — the way to record a layout the profile
|
|
661
|
+
does not describe;
|
|
662
|
+
- else the profile's `test_root`, which pairs with the profile's default `root`. A
|
|
663
|
+
lane at that root takes it as is; a lane whose root ends with it takes it under
|
|
664
|
+
the same prefix (`backend/src/` ⇒ `backend/tests/`);
|
|
665
|
+
- else the tests live inside the source path. That is right for every frontend
|
|
666
|
+
profile, for Go's colocated `_test.go` files, and for a Maven lane rooted at the
|
|
667
|
+
directory holding its pom.xml.
|
|
668
|
+
"""
|
|
669
|
+
if not lane:
|
|
670
|
+
return ""
|
|
671
|
+
root = norm_root(lane.get("root"))
|
|
672
|
+
if not root:
|
|
673
|
+
return ""
|
|
674
|
+
own = norm_root(lane.get("test_root"))
|
|
675
|
+
if own:
|
|
676
|
+
return own
|
|
677
|
+
prof = profile_of(repo, lane) if prof is None else prof
|
|
678
|
+
default_root = norm_root(prof.get("root"))
|
|
679
|
+
default_tests = norm_root(prof.get("test_root"))
|
|
680
|
+
if not default_root or not default_tests or REPO_ROOT in (default_root, default_tests):
|
|
681
|
+
return root
|
|
682
|
+
if root == default_root:
|
|
683
|
+
return default_tests
|
|
684
|
+
if root.endswith("/" + default_root):
|
|
685
|
+
return root[: -len(default_root)] + default_tests
|
|
686
|
+
return root
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def lane_owned_paths(repo, lane, prof=None):
|
|
690
|
+
"""Every subtree a lane owns, labelled: its source path, and its test path when that
|
|
691
|
+
is a different directory. Ownership checks compare these, so a test path can never
|
|
692
|
+
land inside another lane's subtree."""
|
|
693
|
+
root = norm_root(lane.get("root"))
|
|
694
|
+
tests = lane_test_path(repo, lane, prof)
|
|
695
|
+
owned = [("source path", root)] if root else []
|
|
696
|
+
if tests and tests != root:
|
|
697
|
+
owned.append(("test path", tests))
|
|
698
|
+
return owned
|
|
699
|
+
|
|
700
|
+
|
|
607
701
|
def cmd_declare(args):
|
|
608
702
|
repo = args.repo
|
|
609
703
|
data = read_stacks(repo) or {"lanes": []}
|
|
@@ -632,6 +726,7 @@ def cmd_declare(args):
|
|
|
632
726
|
"profile": "",
|
|
633
727
|
"root": "",
|
|
634
728
|
"source_path": "",
|
|
729
|
+
"test_path": "",
|
|
635
730
|
"root_exists": False,
|
|
636
731
|
"replaced": False,
|
|
637
732
|
"removed": [l.get("id") for l in removed],
|
|
@@ -718,14 +813,19 @@ def cmd_declare(args):
|
|
|
718
813
|
if args.origin and args.origin != "declared":
|
|
719
814
|
lane["origin"] = args.origin
|
|
720
815
|
|
|
721
|
-
# root IS ownership: two generators must never be able to
|
|
816
|
+
# root IS ownership, and so is the test path: two generators must never be able to
|
|
817
|
+
# write one file.
|
|
818
|
+
test_path = lane_test_path(repo, lane, prof or {})
|
|
722
819
|
for other in lanes:
|
|
723
820
|
if other.get("id") == lane_id:
|
|
724
821
|
continue
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
822
|
+
for mine_label, mine in lane_owned_paths(repo, lane, prof or {}):
|
|
823
|
+
for theirs_label, theirs in lane_owned_paths(repo, other):
|
|
824
|
+
if roots_overlap(mine, theirs):
|
|
825
|
+
fail("lane-declare: %s '%s' for lane '%s' overlaps lane '%s''s %s '%s'. "
|
|
826
|
+
"A lane owns its source path and its test path, and no two lanes "
|
|
827
|
+
"may share a subtree."
|
|
828
|
+
% (mine_label, mine, lane_id, other.get("id"), theirs_label, theirs))
|
|
729
829
|
|
|
730
830
|
replaced = False
|
|
731
831
|
for i, other in enumerate(lanes):
|
|
@@ -759,6 +859,7 @@ def cmd_declare(args):
|
|
|
759
859
|
"origin": lane.get("origin", "declared"),
|
|
760
860
|
"root": root,
|
|
761
861
|
"source_path": root,
|
|
862
|
+
"test_path": test_path,
|
|
762
863
|
"root_exists": exists,
|
|
763
864
|
"replaced": replaced,
|
|
764
865
|
"removed": [],
|
|
@@ -801,6 +902,7 @@ def kind_block(repo, lanes, kind):
|
|
|
801
902
|
lane = next((l for l in lanes if l.get("kind") == kind), {})
|
|
802
903
|
root = lane.get("root", "")
|
|
803
904
|
prof = profile_of(repo, lane)
|
|
905
|
+
test_path = lane_test_path(repo, lane, prof)
|
|
804
906
|
return {
|
|
805
907
|
kind + "_declared": bool(lane),
|
|
806
908
|
kind + "_lane_id": lane.get("id", ""),
|
|
@@ -818,6 +920,10 @@ def kind_block(repo, lanes, kind):
|
|
|
818
920
|
kind + "_root": root,
|
|
819
921
|
kind + "_source_path": root,
|
|
820
922
|
kind + "_root_exists": root_on_disk(repo, root),
|
|
923
|
+
# Where the lane's paired tests go. Equal to the source path when the stack keeps
|
|
924
|
+
# its tests inside it; see lane_test_path.
|
|
925
|
+
kind + "_test_path": test_path,
|
|
926
|
+
kind + "_test_path_exists": root_on_disk(repo, test_path),
|
|
821
927
|
kind + "_build": lane_command(lane, "build"),
|
|
822
928
|
kind + "_test": lane_command(lane, "test"),
|
|
823
929
|
kind + "_run": lane_command(lane, "run"),
|
|
@@ -825,6 +931,33 @@ def kind_block(repo, lanes, kind):
|
|
|
825
931
|
}
|
|
826
932
|
|
|
827
933
|
|
|
934
|
+
def lane_options(repo, lanes):
|
|
935
|
+
"""One selectable line per declared lane: its id, then what it is and where it lives.
|
|
936
|
+
|
|
937
|
+
The same "<value> — <why it is offered>" shape as the source-path options, so a
|
|
938
|
+
caller hands the selection straight back as `--id` and `resolve` strips the label
|
|
939
|
+
off it. A phase that has to ask WHICH LANE — `foundation` scaffolds into exactly
|
|
940
|
+
one — then turns the answer into a path with no second lookup and no parsing of
|
|
941
|
+
its own.
|
|
942
|
+
"""
|
|
943
|
+
out = []
|
|
944
|
+
for lane in lanes:
|
|
945
|
+
lid = lane.get("id", "")
|
|
946
|
+
if not lid:
|
|
947
|
+
continue
|
|
948
|
+
prof = profile_of(repo, lane)
|
|
949
|
+
title = (prof.get("title", "") or lane.get("stack", "")
|
|
950
|
+
or lane.get("profile", "") or "no stack declared")
|
|
951
|
+
root = norm_root(lane.get("root"))
|
|
952
|
+
where = "the repo root" if root == REPO_ROOT else (root or "no source path")
|
|
953
|
+
label = "%s %s at %s" % (lane.get("kind", "lane"), title, where)
|
|
954
|
+
tests = lane_test_path(repo, lane, prof)
|
|
955
|
+
if tests and tests != root:
|
|
956
|
+
label += ", tests in %s" % tests
|
|
957
|
+
out.append(ROOT_LABEL_SEP.join([lid, label]))
|
|
958
|
+
return out
|
|
959
|
+
|
|
960
|
+
|
|
828
961
|
def cmd_resolve(args):
|
|
829
962
|
data = read_stacks(args.repo)
|
|
830
963
|
|
|
@@ -834,8 +967,9 @@ def cmd_resolve(args):
|
|
|
834
967
|
if data is None:
|
|
835
968
|
payload = {
|
|
836
969
|
"declared": False, "exists": False, "lanes": [], "lane_count": 0,
|
|
970
|
+
"lane_options": [],
|
|
837
971
|
"found": False, "lane": {}, "kinds": [], "prototype": {},
|
|
838
|
-
"lane_id": "", "root": "", "source_path": "", "root_exists": False,
|
|
972
|
+
"lane_id": "", "root": "", "source_path": "", "test_path": "", "root_exists": False,
|
|
839
973
|
"build": "", "test": "", "run": "", "migrate": "",
|
|
840
974
|
"language": "", "ecosystem": "",
|
|
841
975
|
"path": STACKS_REL,
|
|
@@ -847,8 +981,11 @@ def cmd_resolve(args):
|
|
|
847
981
|
|
|
848
982
|
lanes = data.get("lanes") or []
|
|
849
983
|
match = {}
|
|
850
|
-
|
|
851
|
-
|
|
984
|
+
# The id may arrive as a whole option line — `user_select` hands back the string it
|
|
985
|
+
# displayed, label and all — so strip it the way a source path is stripped.
|
|
986
|
+
wanted = strip_root_label(args.id) if args.id else ""
|
|
987
|
+
if wanted:
|
|
988
|
+
match = next((l for l in lanes if l.get("id") == wanted), {})
|
|
852
989
|
elif args.kind:
|
|
853
990
|
match = next((l for l in lanes if l.get("kind") == args.kind), {})
|
|
854
991
|
|
|
@@ -859,12 +996,14 @@ def cmd_resolve(args):
|
|
|
859
996
|
"exists": True,
|
|
860
997
|
"lanes": lanes,
|
|
861
998
|
"lane_count": len(lanes),
|
|
999
|
+
"lane_options": lane_options(args.repo, lanes),
|
|
862
1000
|
"kinds": sorted({l.get("kind", "") for l in lanes if l.get("kind")}),
|
|
863
1001
|
"found": bool(match),
|
|
864
1002
|
"lane": match,
|
|
865
1003
|
"lane_id": match.get("id", ""),
|
|
866
1004
|
"root": match.get("root", ""),
|
|
867
1005
|
"source_path": match.get("root", ""),
|
|
1006
|
+
"test_path": lane_test_path(args.repo, match),
|
|
868
1007
|
"root_exists": root_on_disk(args.repo, match.get("root", "")),
|
|
869
1008
|
"build": lane_command(match, "build"),
|
|
870
1009
|
"test": lane_command(match, "test"),
|
|
@@ -956,17 +1095,22 @@ def cmd_verify(args):
|
|
|
956
1095
|
# `declare` refuses an overlap, so one can only arrive here through a
|
|
957
1096
|
# hand-edited file — a supported way to fill this in, and the one path that
|
|
958
1097
|
# does not pass the writer. The invariant is worth a second check anywhere:
|
|
959
|
-
# two lanes sharing a subtree means two generators can write one file.
|
|
1098
|
+
# two lanes sharing a subtree means two generators can write one file. A lane
|
|
1099
|
+
# owns its test path as well as its source path, so both are compared.
|
|
960
1100
|
for i, first in enumerate(lanes):
|
|
961
1101
|
for second in lanes[i + 1:]:
|
|
962
|
-
|
|
1102
|
+
clash = next(((fl, fp, sl, sp)
|
|
1103
|
+
for fl, fp in lane_owned_paths(repo, first)
|
|
1104
|
+
for sl, sp in lane_owned_paths(repo, second)
|
|
1105
|
+
if roots_overlap(fp, sp)), None)
|
|
1106
|
+
if clash:
|
|
963
1107
|
issues.append(
|
|
964
|
-
"lanes '%s' and '%s' claim overlapping
|
|
965
|
-
"
|
|
966
|
-
"
|
|
967
|
-
"outside the other's."
|
|
1108
|
+
"lanes '%s' and '%s' claim overlapping subtrees: %s '%s' and %s '%s'. "
|
|
1109
|
+
"A lane owns its source path and its test path, and they must be "
|
|
1110
|
+
"disjoint from every other lane's, or two generators can write the "
|
|
1111
|
+
"same file. Give each lane paths outside the other's."
|
|
968
1112
|
% (first.get("id", "?"), second.get("id", "?"),
|
|
969
|
-
|
|
1113
|
+
clash[0], clash[1], clash[2], clash[3]))
|
|
970
1114
|
|
|
971
1115
|
payload = {
|
|
972
1116
|
"ok": not issues,
|
|
@@ -981,7 +1125,10 @@ def cmd_verify(args):
|
|
|
981
1125
|
# No lanes always carries an issue now, so a clean run always has lanes to name.
|
|
982
1126
|
"message": " ".join(issues) if issues else
|
|
983
1127
|
("lane model ok: " + ", ".join(
|
|
984
|
-
"%s (%s) at %s" % (
|
|
1128
|
+
"%s (%s) at %s%s" % (
|
|
1129
|
+
l.get("id", "?"), l.get("profile", "?"), l.get("root", "?"),
|
|
1130
|
+
"".join(", tests at " + p for label, p in lane_owned_paths(repo, l)
|
|
1131
|
+
if label == "test path"))
|
|
985
1132
|
for l in lanes)),
|
|
986
1133
|
"path": STACKS_REL,
|
|
987
1134
|
}
|
|
@@ -1009,6 +1156,38 @@ def cmd_verify(args):
|
|
|
1009
1156
|
# command, and a lane the project never wired up should not fail a build.
|
|
1010
1157
|
|
|
1011
1158
|
LOG_TAIL = 1400
|
|
1159
|
+
# Per FAILING lane, for both the tail and the excerpt. Budgeted per lane rather than over
|
|
1160
|
+
# the joined string: cutting the join to a global tail drops the FIRST failing lane's
|
|
1161
|
+
# output entirely, which is how a fix agent came to be shown a green .NET restore while
|
|
1162
|
+
# the web lane was the one that failed.
|
|
1163
|
+
LANE_TAIL = 900
|
|
1164
|
+
LANE_EXCERPT = 1100
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _testlog():
|
|
1168
|
+
"""aw-testlog.py, loaded by path because its filename is not an importable module
|
|
1169
|
+
name. Returns None when it is unavailable, and every caller degrades."""
|
|
1170
|
+
try:
|
|
1171
|
+
import importlib.util
|
|
1172
|
+
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "aw-testlog.py")
|
|
1173
|
+
spec = importlib.util.spec_from_file_location("aw_testlog", path)
|
|
1174
|
+
mod = importlib.util.module_from_spec(spec)
|
|
1175
|
+
spec.loader.exec_module(mod)
|
|
1176
|
+
return mod
|
|
1177
|
+
except Exception:
|
|
1178
|
+
return None
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _failing_excerpt(text, budget, label, mod=None):
|
|
1182
|
+
"""The failing lines of one lane's output, or a plain tail if the helper is gone."""
|
|
1183
|
+
mod = mod or _testlog()
|
|
1184
|
+
if mod:
|
|
1185
|
+
try:
|
|
1186
|
+
return mod.excerpt(text, budget, label)
|
|
1187
|
+
except Exception:
|
|
1188
|
+
pass
|
|
1189
|
+
tail = (text or "")[-budget:].strip()
|
|
1190
|
+
return ("[%s]\n%s" % (label, tail)) if tail else ""
|
|
1012
1191
|
|
|
1013
1192
|
|
|
1014
1193
|
def cmd_run_phase(args):
|
|
@@ -1021,7 +1200,12 @@ def cmd_run_phase(args):
|
|
|
1021
1200
|
if args.kind:
|
|
1022
1201
|
lanes = [l for l in lanes if l.get("kind") == args.kind]
|
|
1023
1202
|
|
|
1024
|
-
results, tails, cmds = [], [], []
|
|
1203
|
+
results, tails, cmds, excerpts, failing = [], [], [], [], []
|
|
1204
|
+
# A lane whose command never RAN — no test script, no runner, no test project — is not
|
|
1205
|
+
# a red suite: there is no failing test to repair. Tracked apart from `failing` so a
|
|
1206
|
+
# caller can warn about the setup instead of sending a fix loop after nothing.
|
|
1207
|
+
unwired, suite_ran = [], False
|
|
1208
|
+
testlog = _testlog()
|
|
1025
1209
|
ok, ran = True, False
|
|
1026
1210
|
for lane in lanes:
|
|
1027
1211
|
cmd = lane_command(lane, args.phase)
|
|
@@ -1038,6 +1222,9 @@ def cmd_run_phase(args):
|
|
|
1038
1222
|
results.append({"lane": lane_id, "root": root, "cmd": cmd, "ran": False, "ok": False,
|
|
1039
1223
|
"error": "source path '%s' is not a directory" % root})
|
|
1040
1224
|
tails.append("[%s] source path '%s' is not a directory" % (lane_id, root))
|
|
1225
|
+
excerpts.append("[%s] source path '%s' is not a directory — nothing ran"
|
|
1226
|
+
% (lane_id, root))
|
|
1227
|
+
failing.append(lane_id)
|
|
1041
1228
|
continue
|
|
1042
1229
|
ran = True
|
|
1043
1230
|
cmds.append(cmd)
|
|
@@ -1051,7 +1238,29 @@ def cmd_run_phase(args):
|
|
|
1051
1238
|
"ok": lane_ok, "exit_code": proc.returncode,
|
|
1052
1239
|
"cwd": os.path.relpath(workdir, repo).replace("\\", "/")})
|
|
1053
1240
|
if not lane_ok or args.phase == "run":
|
|
1054
|
-
tails.append("[%s] %s" % (lane_id, out[-
|
|
1241
|
+
tails.append("[%s] %s" % (lane_id, out[-LANE_TAIL:]))
|
|
1242
|
+
why = ""
|
|
1243
|
+
if not lane_ok and testlog:
|
|
1244
|
+
try:
|
|
1245
|
+
_unwired, why = testlog.no_setup(out, proc.returncode)
|
|
1246
|
+
except Exception:
|
|
1247
|
+
why = ""
|
|
1248
|
+
if lane_ok or not why:
|
|
1249
|
+
suite_ran = True # a suite that passed, or one that genuinely failed
|
|
1250
|
+
if not lane_ok:
|
|
1251
|
+
failing.append(lane_id)
|
|
1252
|
+
if why:
|
|
1253
|
+
unwired.append({"lane": lane_id, "reason": why})
|
|
1254
|
+
results[-1]["no_setup"] = why
|
|
1255
|
+
# The command is a project-supplied string and can be any length, so the label
|
|
1256
|
+
# carries a readable head of it, on one line.
|
|
1257
|
+
shown = " ".join(cmd.split())
|
|
1258
|
+
if len(shown) > 80:
|
|
1259
|
+
shown = shown[:77] + "…"
|
|
1260
|
+
label = "%s · %s · exit %d" % (lane_id, shown, proc.returncode)
|
|
1261
|
+
if why:
|
|
1262
|
+
label += " · NOT WIRED UP"
|
|
1263
|
+
excerpts.append(_failing_excerpt(out, LANE_EXCERPT, label, testlog))
|
|
1055
1264
|
|
|
1056
1265
|
emit({
|
|
1057
1266
|
"ok": ok,
|
|
@@ -1065,7 +1274,18 @@ def cmd_run_phase(args):
|
|
|
1065
1274
|
"lanes_skipped": [r["lane"] for r in results if not r["ran"]],
|
|
1066
1275
|
"results": results,
|
|
1067
1276
|
"cmd": " && ".join(cmds),
|
|
1068
|
-
|
|
1277
|
+
# Every failing lane is represented: each contributes its own budgeted share, and
|
|
1278
|
+
# the join is not cut again.
|
|
1279
|
+
"log_tail": "\n".join(tails),
|
|
1280
|
+
"failing_lanes": failing,
|
|
1281
|
+
"failing_excerpt": "\n\n".join(e for e in excerpts if e),
|
|
1282
|
+
# `no_setup` is the whole-run verdict: NOTHING ran a suite, and at least one lane
|
|
1283
|
+
# said why. With one lane genuinely failing beside one that is not wired up, this
|
|
1284
|
+
# stays false — there IS a failure to fix — and `setup_missing` still names the
|
|
1285
|
+
# other lane so it is not lost.
|
|
1286
|
+
"no_setup": bool(unwired) and not suite_ran,
|
|
1287
|
+
"setup_missing": unwired,
|
|
1288
|
+
"setup_reason": "; ".join("%s: %s" % (u["lane"], u["reason"]) for u in unwired),
|
|
1069
1289
|
})
|
|
1070
1290
|
|
|
1071
1291
|
|
|
@@ -1169,6 +1389,9 @@ def main():
|
|
|
1169
1389
|
help="also run detection over this repo, so the menu opens on the "
|
|
1170
1390
|
"stack the files point at and root_options leads with the "
|
|
1171
1391
|
"directory the evidence sits in")
|
|
1392
|
+
p.add_argument("--profile", default="",
|
|
1393
|
+
help="the stack already chosen (id or select_options label); root_options "
|
|
1394
|
+
"is then built for that stack alone")
|
|
1172
1395
|
p.set_defaults(func=cmd_list)
|
|
1173
1396
|
|
|
1174
1397
|
p = sub.add_parser("detect", help="match the repo's files against the installed profiles")
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
aw-testlog — the FAILING LINES of a test log, not the last lines of it.
|
|
4
|
+
|
|
5
|
+
Backs the `failing_excerpt` field of `test-run` in both shells, and is imported by
|
|
6
|
+
aw-stack.py's run-phase for the multi-lane path, so there is ONE implementation of what
|
|
7
|
+
counts as a failure line.
|
|
8
|
+
|
|
9
|
+
WHY IT EXISTS. The fix loop used to be handed `log_tail`: the last 1400 characters,
|
|
10
|
+
whatever they were. A .NET run prints its restore banner first and a test runner prints
|
|
11
|
+
its summary last, and on a two-lane run the lanes' tails were joined and then cut again —
|
|
12
|
+
so the fix agent could be shown a successful restore while the failure it was asked to
|
|
13
|
+
repair was the other lane's, thousands of characters earlier. Asking a model to fix a
|
|
14
|
+
failure from a log that shows nothing wrong invites invention.
|
|
15
|
+
|
|
16
|
+
WHAT IT KEEPS. Lines that NAME a failure (error, failed, assertion, exception, traceback,
|
|
17
|
+
a runner's ✕/✗, a `not ok` TAP line, a stack frame) plus one line before and two after, in
|
|
18
|
+
file order, first occurrences first — a cascade is usually explained by the first failure,
|
|
19
|
+
so a budget is better spent at the start than at the end. Anything dropped is stated.
|
|
20
|
+
|
|
21
|
+
IT ALSO ANSWERS WHETHER THE SUITE RAN AT ALL. A lane whose test command is not wired up —
|
|
22
|
+
`npm test` on a scaffold that defines no `test` script, a runner that is not installed, a
|
|
23
|
+
solution with no test project — fails like a red suite and is nothing of the kind: there is
|
|
24
|
+
no failing test to repair, and a fix loop spends its attempts rediscovering that. The
|
|
25
|
+
callers use `no_setup` to tell the two apart, and it reports the reason in the lane's own
|
|
26
|
+
words rather than "tests failed".
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
aw-testlog.py [--file LOG | -] [--budget N] [--label TEXT] [--exit N]
|
|
30
|
+
-> {"excerpt": "...", "lines": N, "no_setup": bool, "reason": "..."}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
# Deliberately broad: it feeds a prompt, and a false positive costs a few lines of context
|
|
39
|
+
# while a false negative costs the fix agent the only line that mattered.
|
|
40
|
+
FAIL_RX = re.compile(
|
|
41
|
+
r"(?i)(?:^|[^a-z])("
|
|
42
|
+
r"error|errors|failed|failure|failures|failing|fails"
|
|
43
|
+
r"|assert\w*|exception|panic|traceback|fatal|segfault"
|
|
44
|
+
r"|not ok|✕|✗|×|\[FAIL\]|FAIL:|FAILED|E/|❌"
|
|
45
|
+
r")(?:[^a-z]|$)")
|
|
46
|
+
# Stack frames and file:line references carry the location and rarely match the words above.
|
|
47
|
+
FRAME_RX = re.compile(r"^\s*(at\s+\S+|File \"|\S+\.(cs|ts|tsx|js|jsx|py|java|kt|go|rb|php):\d+)")
|
|
48
|
+
BEFORE, AFTER = 1, 2
|
|
49
|
+
DEFAULT_BUDGET = 1600
|
|
50
|
+
|
|
51
|
+
# "The command never ran", in the words each toolchain uses for it. Each entry is
|
|
52
|
+
# (pattern, what to tell the operator). Deliberately narrow: every one of these means
|
|
53
|
+
# there was no suite, not that a suite failed — the wrong call in this direction would
|
|
54
|
+
# downgrade a real red run to a warning.
|
|
55
|
+
NO_SETUP = [
|
|
56
|
+
(r"(?im)^\s*npm error Missing script:\s*\"?(\w+)",
|
|
57
|
+
"the lane's package.json defines no \"%s\" script — npm had nothing to run"),
|
|
58
|
+
(r"(?im)^\s*npm ERR! missing script:\s*(\w+)",
|
|
59
|
+
"the lane's package.json defines no \"%s\" script — npm had nothing to run"),
|
|
60
|
+
(r"(?i)\bno test (?:source )?files were specified\b",
|
|
61
|
+
"the test command found no test project to run"),
|
|
62
|
+
(r"(?i)\bMSB1003\b|\bno projects? (?:were )?found\b",
|
|
63
|
+
"the test command found no project to run"),
|
|
64
|
+
(r"(?i)\b(?:command not found|is not recognized as an internal or external command)\b",
|
|
65
|
+
"the test command is not installed on this machine"),
|
|
66
|
+
(r"(?i)\bno module named\s+(\w+)",
|
|
67
|
+
"the test runner (%s) is not installed in this environment"),
|
|
68
|
+
(r"(?i)\bno tests ran\b|\bno tests found\b|\bcollected 0 items\b",
|
|
69
|
+
"the runner ran but found no tests"),
|
|
70
|
+
]
|
|
71
|
+
# Every shell answers a missing executable this way, whatever the stack.
|
|
72
|
+
EXIT_NOT_FOUND = (126, 127)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def no_setup(text, exit_code=None):
|
|
76
|
+
"""(True, reason) when the log says the suite never ran; (False, "") otherwise."""
|
|
77
|
+
for rx, why in NO_SETUP:
|
|
78
|
+
m = re.search(rx, text or "")
|
|
79
|
+
if m:
|
|
80
|
+
return True, (why % m.group(1)) if "%s" in why and m.groups() else why
|
|
81
|
+
if exit_code in EXIT_NOT_FOUND:
|
|
82
|
+
return True, "the test command could not be executed (exit %d)" % exit_code
|
|
83
|
+
return False, ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def excerpt(text, budget=DEFAULT_BUDGET, label=""):
|
|
87
|
+
"""The failing lines of one log, with context, within `budget` characters."""
|
|
88
|
+
if not text:
|
|
89
|
+
return ""
|
|
90
|
+
lines = text.replace("\r\n", "\n").split("\n")
|
|
91
|
+
keep = set()
|
|
92
|
+
for i, line in enumerate(lines):
|
|
93
|
+
if FAIL_RX.search(line) or FRAME_RX.match(line):
|
|
94
|
+
for j in range(max(0, i - BEFORE), min(len(lines), i + AFTER + 1)):
|
|
95
|
+
keep.add(j)
|
|
96
|
+
if not keep:
|
|
97
|
+
# Nothing names a failure — the tail is the best available answer, and saying so
|
|
98
|
+
# is better than an empty excerpt the reader would take for "no failure".
|
|
99
|
+
tail = text[-budget:].strip()
|
|
100
|
+
return (("[%s] " % label if label else "") +
|
|
101
|
+
"no line names a failure; last output:\n" + tail) if tail else ""
|
|
102
|
+
|
|
103
|
+
out, used, gap, dropped, last = [], 0, False, 0, None
|
|
104
|
+
for i in sorted(keep):
|
|
105
|
+
if last is not None and i > last + 1:
|
|
106
|
+
gap = True
|
|
107
|
+
line = lines[i]
|
|
108
|
+
if used + len(line) + 1 > budget:
|
|
109
|
+
dropped = len([k for k in sorted(keep) if k >= i])
|
|
110
|
+
break
|
|
111
|
+
if gap:
|
|
112
|
+
out.append(" …")
|
|
113
|
+
used += 5
|
|
114
|
+
gap = False
|
|
115
|
+
out.append(line)
|
|
116
|
+
used += len(line) + 1
|
|
117
|
+
last = i
|
|
118
|
+
body = "\n".join(out).strip()
|
|
119
|
+
if dropped:
|
|
120
|
+
body += "\n … %d more matching line(s) not shown" % dropped
|
|
121
|
+
return ("[%s]\n" % label if label else "") + body
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main():
|
|
125
|
+
ap = argparse.ArgumentParser(description="extract the failing lines of a test log")
|
|
126
|
+
ap.add_argument("--file", default="-", help="log file, or - for stdin")
|
|
127
|
+
ap.add_argument("--budget", type=int, default=DEFAULT_BUDGET)
|
|
128
|
+
ap.add_argument("--label", default="", help="lane or tool name to prefix the excerpt with")
|
|
129
|
+
ap.add_argument("--exit", dest="exit_code", type=int, default=None,
|
|
130
|
+
help="the command's exit code, so 126/127 read as 'not executable'")
|
|
131
|
+
args = ap.parse_args()
|
|
132
|
+
if args.file in ("", "-"):
|
|
133
|
+
text = sys.stdin.read()
|
|
134
|
+
else:
|
|
135
|
+
try:
|
|
136
|
+
text = open(args.file, encoding="utf-8", errors="replace").read()
|
|
137
|
+
except OSError:
|
|
138
|
+
text = ""
|
|
139
|
+
body = excerpt(text, args.budget, args.label)
|
|
140
|
+
unwired, reason = no_setup(text, args.exit_code)
|
|
141
|
+
sys.stdout.write(json.dumps({"excerpt": body,
|
|
142
|
+
"lines": len(body.split("\n")) if body else 0,
|
|
143
|
+
"no_setup": unwired, "reason": reason},
|
|
144
|
+
separators=(",", ":")) + "\n")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
main()
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
project reverse-engineered by `onboard` that map points at the project's OWN generated
|
|
8
8
|
domain rule files and its own charter, both derived from the code — same partition, a
|
|
9
9
|
different corpus. It carries ONLY domain-specific material:
|
|
10
|
-
the domain principles, domain identifiers/terminology, regulatory
|
|
11
|
-
interoperability surfaces, and domain governance overrides.
|
|
10
|
+
the project identity, the domain principles, domain identifiers/terminology, regulatory
|
|
11
|
+
regime, interoperability surfaces, and domain governance overrides.
|
|
12
12
|
|
|
13
13
|
══ THIS FILE IS SHAPE. THE MAPPED CORPUS IS CONTENT. ═════════════════════════════
|
|
14
14
|
This template is part of the kit and governs projects in any industry. It therefore
|
|
@@ -35,6 +35,21 @@
|
|
|
35
35
|
the detail. That keeps the authority/detail overlap to one line.
|
|
36
36
|
-->
|
|
37
37
|
|
|
38
|
+
## Project Identity
|
|
39
|
+
|
|
40
|
+
[PROJECT_IDENTITY_CONTENT]
|
|
41
|
+
<!--
|
|
42
|
+
Form: what this project IS in its domain — the regulated role it plays, the bounded
|
|
43
|
+
contexts it is organised into, and who consumes it and through what surface — each as a
|
|
44
|
+
short labelled line. Take them from the mapped charter's own identity section. Where the
|
|
45
|
+
project's domain-requirements states a different value (a different context list, a
|
|
46
|
+
narrower role), the project value is a DEVIATION: ratify it here, naming the charter line
|
|
47
|
+
it departs from and who approved it.
|
|
48
|
+
|
|
49
|
+
This section exists because specify loads THIS file and not the charter, so it is the
|
|
50
|
+
only way the project's contexts and consumers reach specify.
|
|
51
|
+
-->
|
|
52
|
+
|
|
38
53
|
## Adopted Modelling Standard
|
|
39
54
|
|
|
40
55
|
[MODELLING_STANDARD_STATEMENT]
|