aw-spec-cli 0.1.20 → 0.1.22

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 (62) hide show
  1. package/assets/.appweaver/ORCH-DIALECT.md +8 -3
  2. package/assets/.appweaver/STACK-PROFILES.md +87 -8
  3. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +30 -0
  4. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +22 -1
  5. package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
  6. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +6 -6
  7. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +144 -52
  8. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +78 -10
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +283 -25
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +202 -40
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +557 -57
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +3 -3
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +39 -0
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +28 -7
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +101 -1
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +141 -0
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +386 -0
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +135 -0
  22. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +772 -216
  23. package/assets/.appweaver/scripts/bash/aw-xref.sh +4 -1
  24. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +708 -209
  25. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +4 -1
  26. package/assets/.appweaver/scripts/python/aw-crstate.py +319 -0
  27. package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
  28. package/assets/.appweaver/scripts/python/aw-gaps.py +119 -0
  29. package/assets/.appweaver/scripts/python/aw-payload-parity.py +19 -0
  30. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +503 -0
  31. package/assets/.appweaver/scripts/python/aw-plangate.py +216 -0
  32. package/assets/.appweaver/scripts/python/aw-principles.py +108 -0
  33. package/assets/.appweaver/scripts/python/aw-stack.py +257 -28
  34. package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
  35. package/assets/.appweaver/scripts/python/aw-xref.py +141 -4
  36. package/assets/.appweaver/stack-profiles/angular.json +26 -0
  37. package/assets/.appweaver/stack-profiles/blazor-wasm.json +38 -0
  38. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +39 -0
  39. package/assets/.appweaver/stack-profiles/java-spring.json +20 -0
  40. package/assets/.appweaver/stack-profiles/nextjs.json +26 -0
  41. package/assets/.appweaver/stack-profiles/node-express.json +27 -0
  42. package/assets/.appweaver/stack-profiles/python-fastapi.json +16 -0
  43. package/assets/.appweaver/stack-profiles/react-vite.json +26 -0
  44. package/assets/.appweaver/stack-profiles/sveltekit.json +26 -0
  45. package/assets/.appweaver/stack-profiles/vue-vite.json +26 -0
  46. package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
  47. package/assets/.appweaver/templates/ui-model-template.md +67 -0
  48. package/assets/.appweaver/templates/workflow-model-template.md +62 -0
  49. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
  50. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
  51. package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
  52. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
  53. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
  54. package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
  55. package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
  56. package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
  57. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +114 -0
  58. package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
  59. package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
  60. package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
  61. package/package.json +1 -1
  62. 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
- ordered = sorted(profiles, key=lambda p: p["id"] != suggested_profile) \
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: where the
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
- if suggested_root:
453
- offer(suggested_root, "detected here (%s)" % (suggested_profile or "evidence"))
454
- for m in matches[1:]:
455
- offer(m.get("root") or m.get("profile_root"), "detected here (%s)" % m["id"])
456
- for p in profiles:
457
- offer(p.get("root"), "%s default" % p["id"])
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 write one file.
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
- if roots_overlap(root, other.get("root")):
726
- fail("lane-declare: root '%s' for lane '%s' overlaps lane '%s' at '%s'. "
727
- "Lane roots own their subtree and must be disjoint."
728
- % (root, lane_id, other.get("id"), other.get("root")))
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", ""),
@@ -815,9 +917,19 @@ def kind_block(repo, lanes, kind):
815
917
  kind + "_language": prof.get("language", ""),
816
918
  kind + "_ecosystem": prof.get("ecosystem", ""),
817
919
  kind + "_title": prof.get("title", "") or lane.get("stack", ""),
920
+ # Names this stack's AMBIENT scope already defines — C#'s implicit usings, the
921
+ # browser and TS globals, java.lang, typing. A domain noun that matches one is
922
+ # not a naming mistake (the plan's names are the ubiquitous language) but it does
923
+ # have to be written qualified, or the compiler cannot tell the two apart.
924
+ kind + "_reserved_names": prof.get("reserved_names", []),
925
+ kind + "_reserved_csv": ", ".join(prof.get("reserved_names", [])),
818
926
  kind + "_root": root,
819
927
  kind + "_source_path": root,
820
928
  kind + "_root_exists": root_on_disk(repo, root),
929
+ # Where the lane's paired tests go. Equal to the source path when the stack keeps
930
+ # its tests inside it; see lane_test_path.
931
+ kind + "_test_path": test_path,
932
+ kind + "_test_path_exists": root_on_disk(repo, test_path),
821
933
  kind + "_build": lane_command(lane, "build"),
822
934
  kind + "_test": lane_command(lane, "test"),
823
935
  kind + "_run": lane_command(lane, "run"),
@@ -825,6 +937,33 @@ def kind_block(repo, lanes, kind):
825
937
  }
826
938
 
827
939
 
940
+ def lane_options(repo, lanes):
941
+ """One selectable line per declared lane: its id, then what it is and where it lives.
942
+
943
+ The same "<value> — <why it is offered>" shape as the source-path options, so a
944
+ caller hands the selection straight back as `--id` and `resolve` strips the label
945
+ off it. A phase that has to ask WHICH LANE — `foundation` scaffolds into exactly
946
+ one — then turns the answer into a path with no second lookup and no parsing of
947
+ its own.
948
+ """
949
+ out = []
950
+ for lane in lanes:
951
+ lid = lane.get("id", "")
952
+ if not lid:
953
+ continue
954
+ prof = profile_of(repo, lane)
955
+ title = (prof.get("title", "") or lane.get("stack", "")
956
+ or lane.get("profile", "") or "no stack declared")
957
+ root = norm_root(lane.get("root"))
958
+ where = "the repo root" if root == REPO_ROOT else (root or "no source path")
959
+ label = "%s %s at %s" % (lane.get("kind", "lane"), title, where)
960
+ tests = lane_test_path(repo, lane, prof)
961
+ if tests and tests != root:
962
+ label += ", tests in %s" % tests
963
+ out.append(ROOT_LABEL_SEP.join([lid, label]))
964
+ return out
965
+
966
+
828
967
  def cmd_resolve(args):
829
968
  data = read_stacks(args.repo)
830
969
 
@@ -834,8 +973,9 @@ def cmd_resolve(args):
834
973
  if data is None:
835
974
  payload = {
836
975
  "declared": False, "exists": False, "lanes": [], "lane_count": 0,
976
+ "lane_options": [],
837
977
  "found": False, "lane": {}, "kinds": [], "prototype": {},
838
- "lane_id": "", "root": "", "source_path": "", "root_exists": False,
978
+ "lane_id": "", "root": "", "source_path": "", "test_path": "", "root_exists": False,
839
979
  "build": "", "test": "", "run": "", "migrate": "",
840
980
  "language": "", "ecosystem": "",
841
981
  "path": STACKS_REL,
@@ -847,8 +987,11 @@ def cmd_resolve(args):
847
987
 
848
988
  lanes = data.get("lanes") or []
849
989
  match = {}
850
- if args.id:
851
- match = next((l for l in lanes if l.get("id") == args.id), {})
990
+ # The id may arrive as a whole option line — `user_select` hands back the string it
991
+ # displayed, label and all — so strip it the way a source path is stripped.
992
+ wanted = strip_root_label(args.id) if args.id else ""
993
+ if wanted:
994
+ match = next((l for l in lanes if l.get("id") == wanted), {})
852
995
  elif args.kind:
853
996
  match = next((l for l in lanes if l.get("kind") == args.kind), {})
854
997
 
@@ -859,12 +1002,14 @@ def cmd_resolve(args):
859
1002
  "exists": True,
860
1003
  "lanes": lanes,
861
1004
  "lane_count": len(lanes),
1005
+ "lane_options": lane_options(args.repo, lanes),
862
1006
  "kinds": sorted({l.get("kind", "") for l in lanes if l.get("kind")}),
863
1007
  "found": bool(match),
864
1008
  "lane": match,
865
1009
  "lane_id": match.get("id", ""),
866
1010
  "root": match.get("root", ""),
867
1011
  "source_path": match.get("root", ""),
1012
+ "test_path": lane_test_path(args.repo, match),
868
1013
  "root_exists": root_on_disk(args.repo, match.get("root", "")),
869
1014
  "build": lane_command(match, "build"),
870
1015
  "test": lane_command(match, "test"),
@@ -956,17 +1101,22 @@ def cmd_verify(args):
956
1101
  # `declare` refuses an overlap, so one can only arrive here through a
957
1102
  # hand-edited file — a supported way to fill this in, and the one path that
958
1103
  # 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.
1104
+ # two lanes sharing a subtree means two generators can write one file. A lane
1105
+ # owns its test path as well as its source path, so both are compared.
960
1106
  for i, first in enumerate(lanes):
961
1107
  for second in lanes[i + 1:]:
962
- if roots_overlap(first.get("root"), second.get("root")):
1108
+ clash = next(((fl, fp, sl, sp)
1109
+ for fl, fp in lane_owned_paths(repo, first)
1110
+ for sl, sp in lane_owned_paths(repo, second)
1111
+ if roots_overlap(fp, sp)), None)
1112
+ if clash:
963
1113
  issues.append(
964
- "lanes '%s' and '%s' claim overlapping source paths '%s' and '%s'. "
965
- "Lane roots own their subtree and must be disjoint, or two "
966
- "generators can write the same file. Give each lane a source path "
967
- "outside the other's."
1114
+ "lanes '%s' and '%s' claim overlapping subtrees: %s '%s' and %s '%s'. "
1115
+ "A lane owns its source path and its test path, and they must be "
1116
+ "disjoint from every other lane's, or two generators can write the "
1117
+ "same file. Give each lane paths outside the other's."
968
1118
  % (first.get("id", "?"), second.get("id", "?"),
969
- first.get("root", ""), second.get("root", "")))
1119
+ clash[0], clash[1], clash[2], clash[3]))
970
1120
 
971
1121
  payload = {
972
1122
  "ok": not issues,
@@ -981,7 +1131,10 @@ def cmd_verify(args):
981
1131
  # No lanes always carries an issue now, so a clean run always has lanes to name.
982
1132
  "message": " ".join(issues) if issues else
983
1133
  ("lane model ok: " + ", ".join(
984
- "%s (%s) at %s" % (l.get("id", "?"), l.get("profile", "?"), l.get("root", "?"))
1134
+ "%s (%s) at %s%s" % (
1135
+ l.get("id", "?"), l.get("profile", "?"), l.get("root", "?"),
1136
+ "".join(", tests at " + p for label, p in lane_owned_paths(repo, l)
1137
+ if label == "test path"))
985
1138
  for l in lanes)),
986
1139
  "path": STACKS_REL,
987
1140
  }
@@ -1009,6 +1162,38 @@ def cmd_verify(args):
1009
1162
  # command, and a lane the project never wired up should not fail a build.
1010
1163
 
1011
1164
  LOG_TAIL = 1400
1165
+ # Per FAILING lane, for both the tail and the excerpt. Budgeted per lane rather than over
1166
+ # the joined string: cutting the join to a global tail drops the FIRST failing lane's
1167
+ # output entirely, which is how a fix agent came to be shown a green .NET restore while
1168
+ # the web lane was the one that failed.
1169
+ LANE_TAIL = 900
1170
+ LANE_EXCERPT = 1100
1171
+
1172
+
1173
+ def _testlog():
1174
+ """aw-testlog.py, loaded by path because its filename is not an importable module
1175
+ name. Returns None when it is unavailable, and every caller degrades."""
1176
+ try:
1177
+ import importlib.util
1178
+ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "aw-testlog.py")
1179
+ spec = importlib.util.spec_from_file_location("aw_testlog", path)
1180
+ mod = importlib.util.module_from_spec(spec)
1181
+ spec.loader.exec_module(mod)
1182
+ return mod
1183
+ except Exception:
1184
+ return None
1185
+
1186
+
1187
+ def _failing_excerpt(text, budget, label, mod=None):
1188
+ """The failing lines of one lane's output, or a plain tail if the helper is gone."""
1189
+ mod = mod or _testlog()
1190
+ if mod:
1191
+ try:
1192
+ return mod.excerpt(text, budget, label)
1193
+ except Exception:
1194
+ pass
1195
+ tail = (text or "")[-budget:].strip()
1196
+ return ("[%s]\n%s" % (label, tail)) if tail else ""
1012
1197
 
1013
1198
 
1014
1199
  def cmd_run_phase(args):
@@ -1021,7 +1206,12 @@ def cmd_run_phase(args):
1021
1206
  if args.kind:
1022
1207
  lanes = [l for l in lanes if l.get("kind") == args.kind]
1023
1208
 
1024
- results, tails, cmds = [], [], []
1209
+ results, tails, cmds, excerpts, failing = [], [], [], [], []
1210
+ # A lane whose command never RAN — no test script, no runner, no test project — is not
1211
+ # a red suite: there is no failing test to repair. Tracked apart from `failing` so a
1212
+ # caller can warn about the setup instead of sending a fix loop after nothing.
1213
+ unwired, suite_ran = [], False
1214
+ testlog = _testlog()
1025
1215
  ok, ran = True, False
1026
1216
  for lane in lanes:
1027
1217
  cmd = lane_command(lane, args.phase)
@@ -1038,6 +1228,9 @@ def cmd_run_phase(args):
1038
1228
  results.append({"lane": lane_id, "root": root, "cmd": cmd, "ran": False, "ok": False,
1039
1229
  "error": "source path '%s' is not a directory" % root})
1040
1230
  tails.append("[%s] source path '%s' is not a directory" % (lane_id, root))
1231
+ excerpts.append("[%s] source path '%s' is not a directory — nothing ran"
1232
+ % (lane_id, root))
1233
+ failing.append(lane_id)
1041
1234
  continue
1042
1235
  ran = True
1043
1236
  cmds.append(cmd)
@@ -1051,7 +1244,29 @@ def cmd_run_phase(args):
1051
1244
  "ok": lane_ok, "exit_code": proc.returncode,
1052
1245
  "cwd": os.path.relpath(workdir, repo).replace("\\", "/")})
1053
1246
  if not lane_ok or args.phase == "run":
1054
- tails.append("[%s] %s" % (lane_id, out[-LOG_TAIL:]))
1247
+ tails.append("[%s] %s" % (lane_id, out[-LANE_TAIL:]))
1248
+ why = ""
1249
+ if not lane_ok and testlog:
1250
+ try:
1251
+ _unwired, why = testlog.no_setup(out, proc.returncode)
1252
+ except Exception:
1253
+ why = ""
1254
+ if lane_ok or not why:
1255
+ suite_ran = True # a suite that passed, or one that genuinely failed
1256
+ if not lane_ok:
1257
+ failing.append(lane_id)
1258
+ if why:
1259
+ unwired.append({"lane": lane_id, "reason": why})
1260
+ results[-1]["no_setup"] = why
1261
+ # The command is a project-supplied string and can be any length, so the label
1262
+ # carries a readable head of it, on one line.
1263
+ shown = " ".join(cmd.split())
1264
+ if len(shown) > 80:
1265
+ shown = shown[:77] + "…"
1266
+ label = "%s · %s · exit %d" % (lane_id, shown, proc.returncode)
1267
+ if why:
1268
+ label += " · NOT WIRED UP"
1269
+ excerpts.append(_failing_excerpt(out, LANE_EXCERPT, label, testlog))
1055
1270
 
1056
1271
  emit({
1057
1272
  "ok": ok,
@@ -1065,7 +1280,18 @@ def cmd_run_phase(args):
1065
1280
  "lanes_skipped": [r["lane"] for r in results if not r["ran"]],
1066
1281
  "results": results,
1067
1282
  "cmd": " && ".join(cmds),
1068
- "log_tail": "\n".join(tails)[-LOG_TAIL:],
1283
+ # Every failing lane is represented: each contributes its own budgeted share, and
1284
+ # the join is not cut again.
1285
+ "log_tail": "\n".join(tails),
1286
+ "failing_lanes": failing,
1287
+ "failing_excerpt": "\n\n".join(e for e in excerpts if e),
1288
+ # `no_setup` is the whole-run verdict: NOTHING ran a suite, and at least one lane
1289
+ # said why. With one lane genuinely failing beside one that is not wired up, this
1290
+ # stays false — there IS a failure to fix — and `setup_missing` still names the
1291
+ # other lane so it is not lost.
1292
+ "no_setup": bool(unwired) and not suite_ran,
1293
+ "setup_missing": unwired,
1294
+ "setup_reason": "; ".join("%s: %s" % (u["lane"], u["reason"]) for u in unwired),
1069
1295
  })
1070
1296
 
1071
1297
 
@@ -1169,6 +1395,9 @@ def main():
1169
1395
  help="also run detection over this repo, so the menu opens on the "
1170
1396
  "stack the files point at and root_options leads with the "
1171
1397
  "directory the evidence sits in")
1398
+ p.add_argument("--profile", default="",
1399
+ help="the stack already chosen (id or select_options label); root_options "
1400
+ "is then built for that stack alone")
1172
1401
  p.set_defaults(func=cmd_list)
1173
1402
 
1174
1403
  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()