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.
Files changed (41) hide show
  1. package/assets/.appweaver/ORCH-DIALECT.md +8 -3
  2. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +22 -1
  3. package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
  4. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +6 -6
  5. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +107 -48
  6. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +17 -9
  7. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +265 -20
  8. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +89 -15
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +354 -52
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +3 -3
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +1 -1
  15. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +651 -76
  16. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +600 -59
  17. package/assets/.appweaver/scripts/python/aw-crstate.py +194 -0
  18. package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
  19. package/assets/.appweaver/scripts/python/aw-payload-parity.py +3 -0
  20. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +423 -0
  21. package/assets/.appweaver/scripts/python/aw-stack.py +251 -28
  22. package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
  23. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +1 -0
  24. package/assets/.appweaver/stack-profiles/java-spring.json +1 -0
  25. package/assets/.appweaver/stack-profiles/node-express.json +1 -0
  26. package/assets/.appweaver/stack-profiles/python-fastapi.json +1 -0
  27. package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
  28. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
  29. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
  30. package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
  31. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
  32. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
  33. package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
  34. package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
  35. package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
  36. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +70 -0
  37. package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
  38. package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
  39. package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
  40. package/package.json +1 -1
  41. 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", ""),
@@ -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
- if args.id:
851
- match = next((l for l in lanes if l.get("id") == args.id), {})
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
- if roots_overlap(first.get("root"), second.get("root")):
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 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."
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
- first.get("root", ""), second.get("root", "")))
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" % (l.get("id", "?"), l.get("profile", "?"), l.get("root", "?"))
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[-LOG_TAIL:]))
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
- "log_tail": "\n".join(tails)[-LOG_TAIL:],
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()
@@ -5,6 +5,7 @@
5
5
  "language": "C#",
6
6
  "ecosystem": "dotnet",
7
7
  "root": "src/",
8
+ "test_root": "tests/",
8
9
  "build": "dotnet build",
9
10
  "test": "dotnet test",
10
11
  "run": "dotnet run --project {{root}}",
@@ -5,6 +5,7 @@
5
5
  "language": "Java",
6
6
  "ecosystem": "java",
7
7
  "root": "src/main/java/",
8
+ "test_root": "src/test/java/",
8
9
  "run_from": "root",
9
10
  "build": "./mvn clean install",
10
11
  "test": "./mvn test",
@@ -5,6 +5,7 @@
5
5
  "language": "Node.js",
6
6
  "ecosystem": "node",
7
7
  "root": "src/",
8
+ "test_root": "tests/",
8
9
  "build": "npm run build",
9
10
  "test": "npm test",
10
11
  "run": "npm run dev",
@@ -5,6 +5,7 @@
5
5
  "language": "Python",
6
6
  "ecosystem": "python",
7
7
  "root": "app/",
8
+ "test_root": "tests/",
8
9
  "build": "python -m compileall {{root}}",
9
10
  "test": "pytest",
10
11
  "run": "uvicorn app.main:app --reload",
@@ -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 regime,
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]