aw-spec-cli 0.1.23 → 0.1.25

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 (38) hide show
  1. package/assets/.appweaver/ORCH-DIALECT.md +3 -3
  2. package/assets/.appweaver/RULE-PARTITION.md +41 -28
  3. package/assets/.appweaver/orchestrator/orchestrator.py +92 -14
  4. package/assets/.appweaver/orchestrator/rev-templates/ai-rules.reference.md +3 -3
  5. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +19 -12
  6. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +25 -17
  7. package/assets/.appweaver/orchestrator/rev-templates/domain-requirements.skeleton.md +2 -2
  8. package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +10 -10
  9. package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +28 -17
  10. package/assets/.appweaver/orchestrator/rev-templates/technical-constitution.skeleton.md +22 -24
  11. package/assets/.appweaver/orchestrator/rev-templates/technical-rule-file.skeleton.md +1 -1
  12. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +29 -19
  13. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +27 -20
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +184 -37
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +18 -0
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +19 -0
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +18 -0
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +55 -29
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +650 -205
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +19 -0
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +5 -5
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +2 -2
  23. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +13 -3
  24. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +40 -1
  25. package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +2 -1
  26. package/assets/.appweaver/scripts/bash/aw-rev-constitution.sh +1 -1
  27. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +69 -7
  28. package/assets/.appweaver/scripts/powershell/aw-rev-constitution.ps1 +1 -1
  29. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +58 -2
  30. package/assets/.appweaver/scripts/python/aw-inventory.py +1809 -156
  31. package/assets/.appweaver/scripts/python/aw-rev-constitution.py +106 -145
  32. package/assets/.appweaver/scripts/python/aw-stack.py +45 -1
  33. package/assets/.appweaver/scripts/python/aw-xref.py +73 -2
  34. package/assets/.appweaver/scripts/python/validate_okf.py +10 -5
  35. package/assets/.appweaver/templates/okf-concept-template.md +10 -2
  36. package/assets/.appweaver/templates/proposed-inventory-template.md +100 -48
  37. package/assets/.appweaver/templates/source-map-template.md +14 -6
  38. package/package.json +1 -1
@@ -57,6 +57,7 @@ Usage: aw-inventory.py <scan|concept|list-aggregates|validate> [options]
57
57
 
58
58
  import argparse
59
59
  import datetime
60
+ import fnmatch
60
61
  import json
61
62
  import os
62
63
  import re
@@ -112,6 +113,29 @@ CONFIGS_RE = re.compile(
112
113
  )
113
114
  ADR_RE = re.compile(r"(^|/)(adr|adrs|decisions?|rfcs?)/|(^|/)(ADR|RFC)[-_0-9]", re.IGNORECASE)
114
115
 
116
+ # --- the API surface and the data layer, as the scan's substrate lists them --
117
+ # ROUTES_RE and DB_FILES_RE above match a role word ANYWHERE in a name, which is what
118
+ # the flat-repo noun fallback wants and what a listing of the API surface cannot use:
119
+ # `Identity` contains "entit", `ReviewService` contains "view", and a domain value
120
+ # object called Route is not a route. These read a name by its WORDS (PascalCase,
121
+ # camelCase, snake_case, kebab-case and dotted names split alike) and fall back to
122
+ # what the file declares when the name says nothing.
123
+ SCAN_SOURCE_RE = re.compile(r"\.(py|js|ts|mjs|go|rb|java|cs|php|kt|scala)$", re.IGNORECASE)
124
+ NAME_WORD_RE = re.compile(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+")
125
+ ROUTE_ROLE_WORDS = frozenset(("controller", "controllers", "endpoint", "endpoints", "route",
126
+ "routes", "router", "routers", "routing", "urls", "views"))
127
+ DATA_DIR_RE = re.compile(
128
+ r"(^|/)(persistence|repositor(?:y|ies)|dal|data[-_]?access|db|database|migrations?|"
129
+ r"alembic)(/|$)", re.IGNORECASE)
130
+ DATA_NAME_RE = re.compile(r"(DbContext|DbContextFactory|Repository|Repositories|Repo|Dao|"
131
+ r"Migration)$", re.IGNORECASE)
132
+ # ORM configuration and mapped entities, across the common stacks.
133
+ DATA_DECL_RE = re.compile(
134
+ r"\bDbSet\s*<|\b(?:IEntityTypeConfiguration|EntityTypeConfiguration)\s*<|"
135
+ r"\.Entity\s*<\s*\w+\s*>\s*\(|@Entity\b|@Table\b|@Repository\b|\bmodels\.Model\b|"
136
+ r"<\s*ApplicationRecord\b|ActiveRecord::Base|\b(?:from|import)\s+sqlalchemy\b|"
137
+ r"\bnew\s+Schema\s*\(|mongoose\.Schema")
138
+
115
139
  # --- frontend / UI detection (the lane signal) ------------------------------
116
140
  # Deterministic evidence that this repo has a USER INTERFACE, and where it lives.
117
141
  # The CODE is the whole oracle here: a lane is recognised from files that exist,
@@ -430,24 +454,159 @@ def git(root, *args):
430
454
  return ""
431
455
 
432
456
 
457
+ def _git_ignored(root, rel):
458
+ """Whether git's own ignore rules exclude `rel` (a path under `root`)."""
459
+ try:
460
+ return subprocess.run(["git", "-C", root, "check-ignore", "-q", rel.rstrip("/")],
461
+ capture_output=True).returncode == 0
462
+ except Exception:
463
+ return False
464
+
465
+
433
466
  def is_excluded(path, config_excludes):
434
467
  if DEFAULT_EXCLUDE.search(path):
435
468
  return True
436
469
  return any(rx.match(path) for rx in config_excludes)
437
470
 
438
471
 
439
- def list_files(root, config_excludes):
440
- """Repo-relative-to-`root` file list, honoring .gitignore via git ls-files."""
472
+ # The agent integrations the kit installs beside a project, and the agent-instruction
473
+ # files at the repository root. Configuration for an assistant, never evidence of how
474
+ # the project's code is built.
475
+ KIT_AGENT_DIRS = (".claude", ".agents", ".kilocode")
476
+ KIT_AGENT_FILES = ("CLAUDE.md", "AGENTS.md")
477
+
478
+
479
+ def kit_excludes(root):
480
+ """Exclusions for the kit's own install footprint under `root`, as config_excludes
481
+ entries. The scan reads the repository as evidence of the PROJECT, and the kit
482
+ installs trees beside it — governance corpora, scaffold packs, agent integrations —
483
+ that otherwise read as the project's own folders, docs, endpoints and churn.
484
+
485
+ `guardrails/` and `foundations/` are ordinary names a project may use for its own
486
+ code, so each is excluded only when it has the kit's shape: `guardrails/` holding
487
+ one of the kit's rule tiers, `foundations/` holding a scaffold pack (a directory with
488
+ a `*.pack.txt`). The agent directories and root instruction files are excluded
489
+ whenever present."""
490
+ names = [d for d in KIT_AGENT_DIRS if os.path.isdir(os.path.join(root, d))]
491
+ g = os.path.join(root, "guardrails")
492
+ if any(os.path.isdir(os.path.join(g, t)) for t in ("projects", "technology", "domain")):
493
+ names.append("guardrails")
494
+ f = os.path.join(root, "foundations")
495
+ if os.path.isdir(f):
496
+ for dirpath, dirnames, filenames in os.walk(f):
497
+ if dirpath[len(f):].count(os.sep) >= 3:
498
+ dirnames[:] = []
499
+ if any(n.endswith(".pack.txt") for n in filenames):
500
+ names.append("foundations")
501
+ break
502
+ rxs = [re.compile("^" + re.escape(n) + "/") for n in names]
503
+ rxs += [re.compile("^" + re.escape(n) + "$") for n in KIT_AGENT_FILES]
504
+ return rxs
505
+
506
+
507
+ # Directories that hold build output, dependencies or tool state and never source.
508
+ # Pruned wherever the tree is read from disk rather than through git, because there
509
+ # is no .gitignore standing in for this list on that path.
510
+ WALK_PRUNE_DIRS = frozenset((
511
+ ".git", "node_modules", "bin", "obj", ".next", ".nuxt", ".svelte-kit", ".angular",
512
+ "dist", "target", "__pycache__", ".venv", "venv", ".vs", ".idea", ".gradle",
513
+ "coverage", "TestResults", ".turbo", ".parcel-cache",
514
+ ))
515
+
516
+
517
+ def _ignore_rules(dirpath, base):
518
+ """The patterns one .gitignore contributes, as (dir_rel, pattern, dir_only, anchored).
519
+
520
+ Best-effort by design — negation, character classes and escaped spaces are not
521
+ honoured. It exists for the one case git cannot answer: a lane root the HOST
522
+ repository ignores wholesale, whose own nested .gitignore files still describe
523
+ what inside it is build output."""
524
+ rules = []
525
+ try:
526
+ with open(os.path.join(dirpath, ".gitignore"), encoding="utf-8") as fh:
527
+ lines = fh.read().splitlines()
528
+ except OSError:
529
+ return rules
530
+ rel = os.path.relpath(dirpath, base).replace(os.sep, "/")
531
+ rel = "" if rel == "." else rel
532
+ for ln in lines:
533
+ p = ln.strip()
534
+ if not p or p.startswith(("#", "!")):
535
+ continue
536
+ dir_only = p.endswith("/")
537
+ p = p.rstrip("/")
538
+ anchored = p.startswith("/") or "/" in p
539
+ rules.append((rel, p.lstrip("/"), dir_only, anchored))
540
+ return rules
541
+
542
+
543
+ def _ignored_by(rules, rel_path, is_dir):
544
+ for d, pat, dir_only, anchored in rules:
545
+ if dir_only and not is_dir:
546
+ continue
547
+ if d and not (rel_path == d or rel_path.startswith(d + "/")):
548
+ continue
549
+ local = rel_path[len(d) + 1:] if d else rel_path
550
+ if anchored:
551
+ if fnmatch.fnmatch(local, pat) or fnmatch.fnmatch(local, pat + "/*"):
552
+ return True
553
+ elif any(fnmatch.fnmatch(seg, pat) for seg in local.split("/")):
554
+ return True
555
+ return False
556
+
557
+
558
+ def _walk_tree(base, prefix=""):
559
+ """Every file under `base`, as `prefix` + its path relative to `base`, with build
560
+ output pruned and every .gitignore found INSIDE `base` applied."""
561
+ out, rules = [], []
562
+ for dirpath, dirnames, filenames in os.walk(base):
563
+ rules = rules + _ignore_rules(dirpath, base)
564
+ here = os.path.relpath(dirpath, base).replace(os.sep, "/")
565
+ here = "" if here == "." else here
566
+ keep = []
567
+ for d in sorted(dirnames):
568
+ rel = f"{here}/{d}" if here else d
569
+ if d in WALK_PRUNE_DIRS or _ignored_by(rules, rel, True):
570
+ continue
571
+ keep.append(d)
572
+ dirnames[:] = keep
573
+ for fn in filenames:
574
+ rel = f"{here}/{fn}" if here else fn
575
+ if fn == ".gitignore" or _ignored_by(rules, rel, False):
576
+ continue
577
+ out.append(prefix + rel)
578
+ return out
579
+
580
+
581
+ def list_files(root, config_excludes, lane_roots=()):
582
+ """Repo-relative-to-`root` file list, honoring .gitignore via git ls-files.
583
+
584
+ TRACKED **AND** UNTRACKED. `git ls-files` alone lists only what is committed, and
585
+ the normal state of a brownfield target is source that has been copied or generated
586
+ into a workspace and not committed yet — for which the scan returned nothing, with
587
+ no way to tell 0% coverage caused by an empty listing from 0% caused by the code.
588
+ `--others` adds what is on disk but unstaged and `--exclude-standard` applies the
589
+ same ignore rules git itself would, including nested .gitignore files, so build
590
+ output (obj/, bin/, node_modules/) stays out exactly as before.
591
+
592
+ A DECLARED LANE ROOT IS READ EVEN WHEN THE HOST REPO IGNORES IT. A codebase under
593
+ assessment is often nested inside a repository that deliberately keeps it out of
594
+ version control — a test bed, a vendored drop, a legacy tree copied in for analysis.
595
+ git then answers "nothing here" for the one subtree select-stack said IS the code.
596
+ The declaration outranks the host's ignore rule: an ignored lane root is walked from
597
+ disk, with build output pruned and the lane's OWN nested .gitignore files applied.
598
+ """
441
599
  if git(root, "rev-parse", "--git-dir"):
442
- raw = git(root, "ls-files")
443
- files = raw.splitlines() if raw else []
600
+ tracked = git(root, "ls-files")
601
+ untracked = git(root, "ls-files", "--others", "--exclude-standard")
602
+ files = set((tracked or "").splitlines()) | set((untracked or "").splitlines())
603
+ for lr in lane_roots or ():
604
+ lr = _lane_prefix(lr)
605
+ if lr and os.path.isdir(os.path.join(root, lr)) and _git_ignored(root, lr):
606
+ files |= set(_walk_tree(os.path.join(root, lr), lr))
607
+ files = sorted(files)
444
608
  else:
445
- files = []
446
- for dirpath, dirnames, filenames in os.walk(root):
447
- dirnames[:] = [d for d in dirnames if d != ".git"]
448
- for fn in filenames:
449
- rel = os.path.relpath(os.path.join(dirpath, fn), root)
450
- files.append(rel.replace(os.sep, "/"))
609
+ files = _walk_tree(root)
451
610
  return [f for f in files if f.strip() and not is_excluded(f, config_excludes)]
452
611
 
453
612
 
@@ -654,6 +813,71 @@ def _dotted_module(seg):
654
813
  return (singularize(name) or name, kind) if name else (None, None)
655
814
 
656
815
 
816
+ def _child_dirs(files):
817
+ """directory -> the names of the directories directly under it."""
818
+ out = {}
819
+ for f in files:
820
+ parts = f.split("/")[:-1]
821
+ for i in range(len(parts)):
822
+ out.setdefault("/".join(parts[:i]), set()).add(parts[i])
823
+ return out
824
+
825
+
826
+ def _holds_layers(path, children):
827
+ """Whether a directory holds a domain layer plus at least one other layer folder."""
828
+ kinds = {_layer_of(c) for c in children.get(path, set())} - {None}
829
+ return "domain" in kinds and len(kinds) >= 2
830
+
831
+
832
+ def _is_module_dir(dirs, j, children):
833
+ """Whether dirs[:j+1] is a MODULE folder even when its name is a container word.
834
+
835
+ The container vocabulary is a prior about names, and a domain names its modules
836
+ freely — a project-tracking service has a Projects module beside Reports and Users.
837
+ Shape outranks the prior when the folder holds a domain layer plus another layer
838
+ AND either sits directly inside a project (a dotted assembly folder) or has a sibling
839
+ module of the same shape. A top-level src/ holding Domain/ and Features/ has
840
+ neither, which keeps a layer-major repo reading as one."""
841
+ path = "/".join(dirs[:j + 1])
842
+ if not _holds_layers(path, children):
843
+ return False
844
+ if not CONTAINER_DIR_RE.match(dirs[j]):
845
+ return True
846
+ if j and DOTTED_SEG_RE.match(dirs[j - 1]):
847
+ return True
848
+ parent = "/".join(dirs[:j])
849
+ return any(s != dirs[j] and not CONTAINER_DIR_RE.match(s)
850
+ and _holds_layers(f"{parent}/{s}" if parent else s, children)
851
+ for s in children.get(parent, set()))
852
+
853
+
854
+ def _is_module_layer(dirs, i, children):
855
+ """Whether the slice container at dirs[i] is one LAYER of the module that holds it,
856
+ rather than a folder of modules.
857
+
858
+ Two layouts spell a slice container the same way and mean opposite things:
859
+
860
+ src/Features/Orders/Domain/Order.cs Features holds MODULES — Orders is one
861
+ <Svc>/Care/Domain/Encounter.cs Features is Care's feature LAYER, a
862
+ <Svc>/Care/Features/SignEncounter/… sibling of its Domain layer; each child
863
+ is ONE OPERATION of the Care module
864
+
865
+ The second is the layout the kit's own folder-structure rule generates — one folder
866
+ per bounded context holding Domain / Features / Persistence / Shared — so reading it
867
+ the first way turns every command and query handler into a module. Two tells, both
868
+ required: the container sits beside another layer folder, and none of its children
869
+ carries layer folders of its own — a child that does is a module, not an operation."""
870
+ if i == 0 or (CONTAINER_DIR_RE.match(dirs[i - 1]) and not _is_module_dir(dirs, i - 1, children)):
871
+ return False
872
+ here = "/".join(dirs[:i + 1])
873
+ siblings = children.get("/".join(dirs[:i]), set())
874
+ if not any(_layer_of(s) and not SLICE_CONTAINER_RE.match(s) for s in siblings):
875
+ return False
876
+ return not any(_layer_of(g)
877
+ for c in children.get(here, set())
878
+ for g in children.get(f"{here}/{c}", set()))
879
+
880
+
657
881
  # How much a rule's naming is trusted when two rules land files in one group.
658
882
  AXIS_RANK = {"slice-major": 0, "project-major": 1, "module-major": 2,
659
883
  "layer-major": 3, "project-root": 4, "flat": 5}
@@ -680,6 +904,7 @@ def _structure_rows(files):
680
904
  without bounding modules — without it those files reach no candidate at all."""
681
905
  proj_roots = _project_roots(files)
682
906
  product = _product_prefix(files)
907
+ children = _child_dirs(files)
683
908
 
684
909
  for f in files:
685
910
  parts = f.split("/")
@@ -688,10 +913,18 @@ def _structure_rows(files):
688
913
  dirs = parts[:-1]
689
914
  hit = None
690
915
 
691
- # slice-major — a slice container names the module in its CHILD.
916
+ # slice-major — a slice container names the module in its CHILD, unless the
917
+ # container is itself one LAYER of a module (see _is_module_layer), in which
918
+ # case the module is its parent and the child is one operation of it.
692
919
  for i, seg in enumerate(dirs):
693
920
  if not SLICE_CONTAINER_RE.match(seg):
694
921
  continue
922
+ if _is_module_layer(dirs, i, children):
923
+ parent = dirs[i - 1]
924
+ name = (_clean_project_name(parent, product)
925
+ if DOTTED_SEG_RE.match(parent) else singularize(parent) or parent)
926
+ hit = ("/".join(dirs[:i]), name, "feature", "module-major")
927
+ break
695
928
  nxt = dirs[i + 1] if i + 1 < len(dirs) else ""
696
929
  if nxt and not _layer_of(nxt) and not SLICE_CONTAINER_RE.match(nxt):
697
930
  kind = next((_layer_of(p, soft=True) for p in dirs[i + 2:]
@@ -715,7 +948,7 @@ def _structure_rows(files):
715
948
  if not kind:
716
949
  continue
717
950
  parent = dirs[i - 1] if i else ""
718
- if i and not CONTAINER_DIR_RE.match(parent):
951
+ if i and (not CONTAINER_DIR_RE.match(parent) or _is_module_dir(dirs, i - 1, children)):
719
952
  name = (_clean_project_name(parent, product)
720
953
  if DOTTED_SEG_RE.match(parent) else singularize(parent) or parent)
721
954
  hit = ("/".join(dirs[:i]), name, kind, "module-major")
@@ -725,6 +958,22 @@ def _structure_rows(files):
725
958
  hit = ("/".join(dirs[:i] + [noun]), noun, kind, "layer-major")
726
959
  break
727
960
 
961
+ # A MODULE folder owns everything beneath it, whatever its sub-folders are called.
962
+ # The layer vocabulary names the folders that bound a module; a module's own
963
+ # Shared/, Common/ or Helpers/ names none of them, and without this rule its files
964
+ # fall through to the enclosing project and leave the module. The deepest folder of
965
+ # module SHAPE wins (see _is_module_dir), so a layer-major src/ is never one.
966
+ if not hit:
967
+ for j in range(len(dirs) - 1, -1, -1):
968
+ if _is_module_dir(dirs, j, children):
969
+ parent = dirs[j]
970
+ name = (_clean_project_name(parent, product)
971
+ if DOTTED_SEG_RE.match(parent) else singularize(parent) or parent)
972
+ kind = next((_layer_of(p, soft=True) for p in dirs[j + 1:]
973
+ if _layer_of(p, soft=True)), "other")
974
+ hit = ("/".join(dirs[:j + 1]), name, kind, "module-major")
975
+ break
976
+
728
977
  # A dotted project segment bounds a module even with no layer anywhere in
729
978
  # it and no manifest tracked beside it — which is how a test project that
730
979
  # mirrors its subject (Acme.Billing.Tests) reaches the same candidate.
@@ -1156,8 +1405,26 @@ def strategy_reference(root, files, churn_map, graph=None):
1156
1405
  EF_DBSET_RE = re.compile(r"DbSet\s*<\s*([A-Za-z_]\w*)\s*>")
1157
1406
  EF_TABLE_RE = re.compile(r"\[Table\s*\(\s*\"([^\"]+)\"")
1158
1407
  EF_COLLECTION_RE = re.compile(
1159
- r"(?:ICollection|IList|List|IEnumerable|HashSet|ISet)\s*<\s*([A-Za-z_]\w*)\s*>")
1408
+ r"(?:IReadOnlyCollection|IReadOnlyList|IReadOnlySet|ICollection|IList|List|IEnumerable|"
1409
+ r"HashSet|ISet)\s*<\s*([A-Za-z_]\w*)\s*>")
1160
1410
  EF_FK_RE = re.compile(r"^\s*public\s+[\w?<>]+\s+([A-Za-z_]\w*)Id\s*\{", re.M)
1411
+ # What EF is TOLD it persists — the configuration class (EF Core and EF6) and the model
1412
+ # builder call; DbSet<T> and [Table] are the other two.
1413
+ EF_CONFIG_RE = re.compile(r"\b(?:IEntityTypeConfiguration|EntityTypeConfiguration)\s*<\s*([A-Za-z_]\w*)\s*>")
1414
+ EF_MODEL_ENTITY_RE = re.compile(r"\.Entity\s*<\s*([A-Za-z_]\w*)\s*>\s*\(")
1415
+ # A collection MEMBER — access modifier first — so a method's local list is not one.
1416
+ EF_MEMBER_COLLECTION_RE = re.compile(
1417
+ r"\b(?:public|protected|internal|private)\s+(?:(?:readonly|virtual|static|required|"
1418
+ r"override|new)\s+)*(?:IReadOnlyCollection|IReadOnlyList|IReadOnlySet|ICollection|IList|"
1419
+ r"List|IEnumerable|HashSet|ISet)\s*<\s*([A-Za-z_]\w*)\s*>")
1420
+ CS_CLASS_ANY_RE = re.compile(r"\bclass\s+([A-Z]\w*)")
1421
+ TYPE_PARAM_RE = re.compile(r"^T(?:[A-Z]\w*)?$")
1422
+ # Names an application or host role, never a persisted entity: the request an endpoint
1423
+ # binds, the client that calls another service, the handler, the options class.
1424
+ APP_ROLE_NAME_RE = re.compile(
1425
+ r"(Endpoint|Controller|Handler|Request|Response|Command|Query|Dto|DTO|ViewModel|"
1426
+ r"Validator|Client|Service|Mapper|Profile|Extensions|Configuration|Factory|Module|"
1427
+ r"Options|Settings|Exception|Middleware|Filter|Attribute)$")
1161
1428
  CSHARP_CLASS_RE = re.compile(r"^\s*(?:public|internal)\s+(?:abstract\s+|sealed\s+|partial\s+)*class\s+([A-Za-z_]\w*)", re.M)
1162
1429
  JPA_ENTITY_RE = re.compile(r"@Entity\b")
1163
1430
  JPA_ONE_MANY_RE = re.compile(r"@OneToMany[\s\S]{0,200}?(?:ICollection|List|Set)\s*<\s*([A-Za-z_]\w*)\s*>")
@@ -1182,7 +1449,7 @@ def persistence_entities(root, files):
1182
1449
  Four dialects, one shape. `owns` is a lifecycle edge (a collection navigation,
1183
1450
  a one-to-many, a list relation, a foreign key pointing IN); `refs` is a
1184
1451
  by-id reference that crosses an aggregate boundary without owning anything."""
1185
- ents = {}
1452
+ ents, cs = {}, {}
1186
1453
 
1187
1454
  def ent(name):
1188
1455
  return ents.setdefault(_pascal_name(name),
@@ -1193,6 +1460,9 @@ def persistence_entities(root, files):
1193
1460
  lang = _lang_of(f)
1194
1461
  if lang not in ("cs", "java", "kt") and not low.endswith((".prisma", ".sql")):
1195
1462
  continue
1463
+ # A test app's or a sample's DbContext persists ITS entities, not the product's.
1464
+ if TEST_PATH_RE.search(f):
1465
+ continue
1196
1466
  body = _read_source(root, f)
1197
1467
  if not body:
1198
1468
  continue
@@ -1220,19 +1490,7 @@ def persistence_entities(root, files):
1220
1490
  ents[_pascal_name(tgt)]["owns"].add(_pascal_name(tables[0]))
1221
1491
  owner["refs"].add(tgt)
1222
1492
  elif lang == "cs":
1223
- for m in EF_DBSET_RE.finditer(body):
1224
- ent(m.group(1))["files"].add(f)
1225
- cls = CSHARP_CLASS_RE.search(body)
1226
- if cls and (EF_TABLE_RE.search(body) or EF_COLLECTION_RE.search(body)
1227
- or EF_FK_RE.search(body) or DOMAIN_DIR_RE.search(f)):
1228
- e = ent(cls.group(1))
1229
- e["files"].add(f)
1230
- for m in EF_COLLECTION_RE.finditer(body):
1231
- if m.group(1) != cls.group(1):
1232
- e["owns"].add(_pascal_name(m.group(1)))
1233
- for m in EF_FK_RE.finditer(body):
1234
- if m.group(1) != cls.group(1):
1235
- e["refs"].add(_pascal_name(m.group(1)))
1493
+ cs[f] = _code_only(f, body)
1236
1494
  elif lang in ("java", "kt") and JPA_ENTITY_RE.search(body):
1237
1495
  cls = JAVA_CLASS_RE.search(body)
1238
1496
  if cls:
@@ -1242,9 +1500,97 @@ def persistence_entities(root, files):
1242
1500
  e["owns"].add(_pascal_name(m.group(1)))
1243
1501
  for m in JPA_MANY_ONE_RE.finditer(body):
1244
1502
  e["refs"].add(_pascal_name(m.group(1)))
1503
+ if cs:
1504
+ _cs_entities(cs, ent)
1245
1505
  return ents
1246
1506
 
1247
1507
 
1508
+ def _cs_entities(cs, ent):
1509
+ """The C# persistence model, into `ent`.
1510
+
1511
+ DECLARED BEATS INFERRED, as it does for aggregate roots. Where the code tells EF
1512
+ which types it persists — a DbSet<T>, an IEntityTypeConfiguration<T> (or EF6's
1513
+ EntityTypeConfiguration<T>), a modelBuilder.Entity<T>(), a [Table] — those types are
1514
+ the entities, together with what they hold collections of, which is EF's own
1515
+ discovery rule. Nothing else is: an endpoint whose request carries an EncounterId is
1516
+ not a table.
1517
+
1518
+ Only a codebase that declares none of those is read by shape: a class with a
1519
+ collection member, a foreign-key property, a [Table], or a home in a domain folder.
1520
+ Even then an open generic (PagedResult<T>), a class named for an application role
1521
+ (…Endpoint, …Request, …Client) and a local variable are not evidence."""
1522
+ classes = {}
1523
+ for f, body in cs.items():
1524
+ for m in CS_CLASS_ANY_RE.finditer(body):
1525
+ classes.setdefault(m.group(1), f)
1526
+ declared, decl_files = set(), {}
1527
+ for f, body in cs.items():
1528
+ for rx in (EF_DBSET_RE, EF_CONFIG_RE, EF_MODEL_ENTITY_RE):
1529
+ for m in rx.finditer(body):
1530
+ # A generic base (BaseConfiguration<T> : IEntityTypeConfiguration<T>)
1531
+ # declares a type PARAMETER, not an entity.
1532
+ if m.group(1) not in classes and TYPE_PARAM_RE.match(m.group(1)):
1533
+ continue
1534
+ declared.add(m.group(1))
1535
+ decl_files.setdefault(m.group(1), set()).add(f)
1536
+ cls = CSHARP_CLASS_RE.search(body)
1537
+ if cls and EF_TABLE_RE.search(body):
1538
+ declared.add(cls.group(1))
1539
+
1540
+ if declared:
1541
+ seen, queue = set(), sorted(declared)
1542
+ while queue:
1543
+ n = queue.pop()
1544
+ if n in seen:
1545
+ continue
1546
+ seen.add(n)
1547
+ e = ent(n)
1548
+ e["files"].update(decl_files.get(n, ()))
1549
+ f = classes.get(n)
1550
+ if not f:
1551
+ continue
1552
+ e["files"].add(f)
1553
+ for m in EF_COLLECTION_RE.finditer(cs[f]):
1554
+ t = m.group(1)
1555
+ if t != n and t in classes and not APP_ROLE_NAME_RE.search(t):
1556
+ e["owns"].add(t)
1557
+ queue.append(t)
1558
+ for m in EF_FK_RE.finditer(cs[f]):
1559
+ if m.group(1) != n:
1560
+ e["refs"].add(_pascal_name(m.group(1)))
1561
+ return
1562
+
1563
+ for f, body in cs.items():
1564
+ cls = CSHARP_CLASS_RE.search(body)
1565
+ if not cls:
1566
+ continue
1567
+ name = cls.group(1)
1568
+ if body[cls.end():cls.end() + 20].lstrip().startswith("<") or APP_ROLE_NAME_RE.search(name):
1569
+ continue
1570
+ # A domain event, a specification or a declared value object has no lifecycle
1571
+ # of its own to persist, and counting one as an entity makes it a ROOT (nothing
1572
+ # owns it). Only the unambiguous cases are dropped — by name, folder or
1573
+ # declaration — so a legacy model of plain POCOs is read as before.
1574
+ if not EF_TABLE_RE.search(body):
1575
+ brace = body.find("{", cls.end())
1576
+ header = body[cls.end():brace if brace != -1 else cls.end() + 400][:400]
1577
+ if (EVENT_NAME_RE.search(name) or SPEC_NAME_RE.search(name)
1578
+ or re.search(r"/(events|specifications)/", "/" + f.lower())
1579
+ or re.search(r"\bValueObject\b|IEquatable\s*<\s*" + re.escape(name) + r"\s*>",
1580
+ header)):
1581
+ continue
1582
+ if (EF_TABLE_RE.search(body) or EF_MEMBER_COLLECTION_RE.search(body)
1583
+ or EF_FK_RE.search(body) or DOMAIN_DIR_RE.search(f)):
1584
+ e = ent(name)
1585
+ e["files"].add(f)
1586
+ for m in EF_MEMBER_COLLECTION_RE.finditer(body):
1587
+ if m.group(1) != name:
1588
+ e["owns"].add(_pascal_name(m.group(1)))
1589
+ for m in EF_FK_RE.finditer(body):
1590
+ if m.group(1) != name:
1591
+ e["refs"].add(_pascal_name(m.group(1)))
1592
+
1593
+
1248
1594
  def strategy_persistence(root, files, churn_map, graph=None):
1249
1595
  """S3 — derive roots from what the schema says owns what.
1250
1596
 
@@ -1295,21 +1641,549 @@ def strategy_persistence(root, files, churn_map, graph=None):
1295
1641
  return _finish_candidates(cands, churn_map, "persistence")
1296
1642
 
1297
1643
 
1644
+ # ── S0: domain — the aggregate roots the code DECLARES ─────────────────────
1645
+ # The greenfield sequence writes the model down in two places that agree by
1646
+ # construction: `## Aggregate: <Root>` in specs/<Context>/domain-model.md, and a type
1647
+ # deriving from an aggregate-root base in <Module>/Domain/ — where <Module> is the
1648
+ # bounded context (folder-structure: "one folder per bounded context"). This strategy
1649
+ # reads the second back. A declared root is not a guess about a boundary; it is the
1650
+ # boundary, stated by whoever wrote the code, so where one exists this strategy is
1651
+ # chosen ahead of every measured one (see choose_strategy).
1652
+ #
1653
+ # Three levels, never conflated — the same three the spec side keeps apart:
1654
+ # bounded context the module folder that holds a Domain layer
1655
+ # aggregate a declared root + everything only it reaches in that module, and
1656
+ # the application-layer files named after it or used by it alone
1657
+ # operation one slice under the module's Features layer (Command/Query +
1658
+ # Handler + Endpoint + Validator), or a handler class elsewhere in
1659
+ # the lane — never a feature, never an aggregate, and not a user
1660
+ # story either: a story usually spans several operations
1661
+ # Features are not read here at all: a feature is a capability the developer chose,
1662
+ # and its only trace in code is the UI lane's features/<Feature>/ folder (see
1663
+ # ui_features) — which is why features are proposed in Pass C, not found in Pass A.
1664
+
1665
+ AGG_ROOT_MARKER_RE = re.compile(r"\b(?:I?AggregateRoot\w*|AbstractAggregateRoot)\b")
1666
+ AGG_ROOT_ANNOTATION_RE = re.compile(r"@AggregateRoot\b")
1667
+ ENTITY_MARKER_RE = re.compile(r"\b(?:I?Entity(?:Base)?|IAggregateChild|BaseEntity)\b")
1668
+ # Upper-case names only: every supported language names its types that way, and prose
1669
+ # does not — "this record avoids…" in a doc comment would otherwise declare `avoids`.
1670
+ TYPE_DECL_RE = re.compile(r"\b(class|record|struct|interface|enum)\s+([A-Z]\w*)")
1671
+ C_COMMENT_RE = re.compile(r"/\*.*?\*/|//[^\n]*", re.S)
1672
+
1673
+
1674
+ def _code_only(path, body):
1675
+ """Source with comments removed, for the languages whose comments are C-style.
1676
+ A type named in a doc comment is documentation, not a reference."""
1677
+ return C_COMMENT_RE.sub("", body) if _lang_of(path) in ("cs", "java", "kt", "scala", "js",
1678
+ "go", "rs", "php") else body
1679
+ EVENT_NAME_RE = re.compile(r"(?:Event|Notification)$")
1680
+ SPEC_NAME_RE = re.compile(r"(?:Spec|Specification)$")
1681
+ # Code that exercises or demonstrates a model without being it: tests, samples, demos.
1682
+ # A sample app's Order aggregate is not the codebase's Order aggregate.
1683
+ TEST_PATH_RE = re.compile(
1684
+ r"(^|/)(tests?|__tests__|samples?|examples?|demos?|"
1685
+ r"[^/]*\.(?:Unit|Integration|Functional|Acceptance)?Tests?)(/|$)",
1686
+ re.IGNORECASE)
1687
+ HANDLER_CLASS_RE = re.compile(r"\bclass\s+([A-Za-z_]\w*?)(Command|Query)Handler\b")
1688
+ QUERY_NAME_RE = re.compile(r"^(Get|List|Find|Search|Fetch|Query|Read|Lookup|Count|Export|Browse)[A-Z]")
1689
+ CREATE_NAME_RE = re.compile(r"^(Create|Register|Record|Start|Open|New|Add|Submit|Place|Book|Schedule)")
1690
+ IDENT_RE = re.compile(r"[A-Za-z_]\w*")
1691
+
1692
+
1693
+ def _type_header(body, end):
1694
+ """The declaration text between a type's name and its body — its base list, with
1695
+ generic arguments and constraint clauses removed.
1696
+
1697
+ Stops at the body brace, a statement end, or Python's `):`. Not at a bare `:` —
1698
+ C# and Kotlin open the base list with one, often at the end of the line.
1699
+
1700
+ Generic arguments and `where` clauses go because a marker named THERE says what a
1701
+ type works WITH, not what it IS: `EfRepository<T> : IRepository<T> where T :
1702
+ IAggregateRoot` is a repository of roots, and `AggregateRootBase<EncounterId>` is a
1703
+ root whatever its id type."""
1704
+ stop = len(body)
1705
+ for tok in ("{", ";", "):", "=>"):
1706
+ k = body.find(tok, end)
1707
+ if k != -1:
1708
+ stop = min(stop, k)
1709
+ header = re.split(r"\bwhere\b", body[end:min(stop, end + 400)], maxsplit=1)[0]
1710
+ prev = None
1711
+ while prev != header:
1712
+ prev, header = header, re.sub(r"<[^<>]*>", "", header)
1713
+ return header
1714
+
1715
+
1716
+ def _type_role(kind, name, header, path, pre):
1717
+ """What a declared type is in the domain model: root, entity, value, event, spec,
1718
+ enum or contract. Read from its declaration, never from its folder alone."""
1719
+ if kind == "enum":
1720
+ return "enum"
1721
+ if kind == "interface":
1722
+ return "contract"
1723
+ if "AggregateRoot" not in name and (AGG_ROOT_MARKER_RE.search(header)
1724
+ or AGG_ROOT_ANNOTATION_RE.search(pre)):
1725
+ return "root"
1726
+ if EVENT_NAME_RE.search(name) or "/events/" in "/" + path.lower():
1727
+ return "event"
1728
+ if SPEC_NAME_RE.search(name) or "/specifications/" in "/" + path.lower():
1729
+ return "spec"
1730
+ if ENTITY_MARKER_RE.search(header):
1731
+ return "entity"
1732
+ return "value"
1733
+
1734
+
1735
+ def _feature_layer_op(dirs, children, file_stem):
1736
+ """(container index, operation name) when the file sits in a module's feature
1737
+ LAYER, else (None, None). A slice folder names its operation; a file placed
1738
+ directly in the layer names it by its stem, role suffix removed."""
1739
+ for i, seg in enumerate(dirs):
1740
+ if SLICE_CONTAINER_RE.match(seg) and _is_module_layer(dirs, i, children):
1741
+ if i + 1 < len(dirs):
1742
+ return i, dirs[i + 1]
1743
+ stem = file_stem
1744
+ prev = None
1745
+ while prev != stem:
1746
+ prev, stem = stem, ROLE_SUFFIX_RE.sub("", stem)
1747
+ return i, stem or file_stem
1748
+ return None, None
1749
+
1750
+
1751
+ def _spans_roots(referenced, types):
1752
+ """Whether a file names two aggregate roots or more — code that spans the model."""
1753
+ return sum(1 for tn in referenced if types[tn]["role"] == "root") > 1
1754
+
1755
+
1756
+ def _names_of(f, decl_of):
1757
+ """The names a file answers to: the types it declares, and its own stem."""
1758
+ return set(decl_of.get(f, ())) | {os.path.splitext(os.path.basename(f))[0]}
1759
+
1760
+
1761
+ def _named_after(names, members):
1762
+ """The owner of the member these names are named after, or None.
1763
+
1764
+ A name is named after a member when it IS it or starts with it at a word boundary:
1765
+ EncounterDto and EncounterMappingExtensions after Encounter, OrderLineDto after
1766
+ OrderLine and not after Order, Orderly after nothing. An interface's leading I is
1767
+ dropped first (IEncounterLookup). The longest member wins for each name, and names
1768
+ that point at different owners are no answer at all."""
1769
+ found = set()
1770
+ for n in names:
1771
+ if len(n) > 2 and n[0] == "I" and n[1].isupper() and n[2].islower():
1772
+ n = n[1:]
1773
+ best = ""
1774
+ for m in members:
1775
+ if len(m) > len(best) and n.startswith(m) and (
1776
+ len(n) == len(m) or not n[len(m)].islower()):
1777
+ best = m
1778
+ if best:
1779
+ found.add(members[best])
1780
+ return found.pop() if len(found) == 1 else None
1781
+
1782
+
1783
+ def _attribute_leftovers(modules, module_of, text, types, decl_of, refs, owner, shared, ops):
1784
+ """Give each file the earlier passes left unplaced to the aggregate it serves, where
1785
+ that is unambiguous, and record the rest of a module as its context's shared code.
1786
+
1787
+ Reach, references and operations place the model itself. What they leave is the
1788
+ application layer around it — a DTO, an error catalogue, a mapping, a port — which
1789
+ names no root, and so is reached by none, but is plainly one aggregate's: it is
1790
+ named after one, or only one uses it.
1791
+
1792
+ Inside a module that declares roots, three rules in order:
1793
+ 1. NAMED AFTER one aggregate's member — its root, an entity or a value it owns;
1794
+ 2. REFERENCES one aggregate's types more than any other's — repeated, so a DTO
1795
+ that nests a DTO placed a moment ago follows it;
1796
+ 3. USED BY one aggregate alone — a port only that aggregate's operations call.
1797
+ Outside those modules (the Application and Api projects of a solution split one
1798
+ project per layer) only the two strongest apply: named after a root or entity —
1799
+ a value object's name is too generic to claim a file across the lane — or naming
1800
+ exactly one root."""
1801
+ in_model = set(modules)
1802
+ op_files = {f for o in ops.values() for f in o["files"]}
1803
+ members, identities = {}, {}
1804
+ for tn, t in types.items():
1805
+ o = owner.get(t["file"])
1806
+ if t["module"] not in in_model or not o or t["file"] in op_files:
1807
+ continue
1808
+ if t["role"] in ("root", "entity", "value"):
1809
+ members.setdefault(t["module"], {})[tn] = o
1810
+ if t["role"] in ("root", "entity"):
1811
+ identities[tn] = o
1812
+ already_shared = {f for fs in shared.values() for f in fs}
1813
+
1814
+ for key, mod in modules.items():
1815
+ left = [f for f in mod["files"] if f in text and f not in owner and f not in already_shared]
1816
+ for f in left:
1817
+ o = _named_after(_names_of(f, decl_of), members.get(key, {}))
1818
+ if o:
1819
+ owner[f] = o
1820
+ changed = True
1821
+ while changed:
1822
+ changed = False
1823
+ for f in left:
1824
+ if f in owner or _spans_roots(refs.get(f, ()), types):
1825
+ continue
1826
+ votes = {}
1827
+ for tn in refs.get(f, ()):
1828
+ o = owner.get(types[tn]["file"])
1829
+ if o:
1830
+ votes[o] = votes.get(o, 0) + (3 if tn == o else 1)
1831
+ top = sorted(votes.items(), key=lambda kv: -kv[1])
1832
+ if top and (len(top) == 1 or top[0][1] > top[1][1]):
1833
+ owner[f] = top[0][0]
1834
+ changed = True
1835
+ if changed:
1836
+ continue
1837
+ users = {}
1838
+ for g in mod["files"]:
1839
+ if owner.get(g):
1840
+ for tn in refs.get(g, ()):
1841
+ users.setdefault(types[tn]["file"], set()).add(owner[g])
1842
+ for f in left:
1843
+ if f not in owner and len(users.get(f, ())) == 1 \
1844
+ and not _spans_roots(refs.get(f, ()), types):
1845
+ owner[f] = next(iter(users[f]))
1846
+ changed = True
1847
+ rest = [f for f in left if f not in owner]
1848
+ if rest:
1849
+ shared.setdefault(key, []).extend(rest)
1850
+
1851
+ roots = {tn for tn, t in types.items() if t["role"] == "root" and t["module"] in in_model}
1852
+ for f, body in text.items():
1853
+ if module_of[f] in in_model or f in owner:
1854
+ continue
1855
+ o = _named_after(_names_of(f, decl_of), identities)
1856
+ if not o:
1857
+ named = (set(IDENT_RE.findall(body)) - decl_of.get(f, set())) & roots
1858
+ o = next(iter(named)) if len(named) == 1 else None
1859
+ if o:
1860
+ owner[f] = o
1861
+
1862
+
1863
+ _DOMAIN_MEMO = {}
1864
+
1865
+
1866
+ def parse_domain(root, files):
1867
+ """The domain model as the code declares it, per module.
1868
+
1869
+ Returns {"modules": {key: {"context", "dir", "roots", "files"}},
1870
+ "types": {name: {"file", "role", "module"}},
1871
+ "owner": {file: root name}, every file an aggregate owns
1872
+ "shared": {key: [file, …]}, module files no single aggregate owns
1873
+ "operations": [op, …],
1874
+ "root_edges": {root: {root, …}}} A -> B: A's files reference B's types
1875
+
1876
+ Ownership is by REACH over type references inside one module: a root owns every
1877
+ type it reaches without passing through another root, and a file that references
1878
+ an owned type but is reached by nothing (a specification, a persistence
1879
+ configuration, a DTO mapping) belongs to the owner of what it references. A type
1880
+ two roots reach at the same distance, with neither's name as its prefix, is the
1881
+ module's shared kernel — PatientId in a context that only references Patient.
1882
+ Operations are read in the module and, for a solution split one project per layer,
1883
+ across the lane; what is left is placed by _attribute_leftovers."""
1884
+ if root is None:
1885
+ return {"modules": {}, "types": {}, "owner": {}, "shared": {},
1886
+ "operations": [], "root_edges": {}}
1887
+ memo_key = (root, hash(tuple(files)))
1888
+ if memo_key in _DOMAIN_MEMO:
1889
+ return _DOMAIN_MEMO[memo_key]
1890
+
1891
+ children = _child_dirs(files)
1892
+ module_of = {f: key for key, _n, _k, f, _a in _structure_rows(files)}
1893
+ text, types, decl_of = {}, {}, {}
1894
+ for f in files:
1895
+ if f not in module_of or not _lang_of(f) or TEST_PATH_RE.search(f):
1896
+ continue
1897
+ body = _code_only(f, _read_source(root, f))
1898
+ if not body:
1899
+ continue
1900
+ text[f] = body
1901
+ for m in TYPE_DECL_RE.finditer(body):
1902
+ name = m.group(2)
1903
+ if name in types:
1904
+ continue
1905
+ role = _type_role(m.group(1), name, _type_header(body, m.end()), f,
1906
+ body[max(0, m.start() - 200):m.start()])
1907
+ types[name] = {"file": f, "role": role, "module": module_of[f]}
1908
+ decl_of.setdefault(f, set()).add(name)
1909
+
1910
+ roots = {n: t for n, t in types.items() if t["role"] == "root"}
1911
+ product = _product_prefix(files)
1912
+ modules = {}
1913
+ for n, t in sorted(roots.items()):
1914
+ key = t["module"]
1915
+ seg = key.rsplit("/", 1)[-1]
1916
+ ctx = _clean_project_name(seg, product) if DOTTED_SEG_RE.match(seg) else seg
1917
+ mod = modules.setdefault(key, {"context": ctx, "dir": key, "roots": [], "files": []})
1918
+ mod["roots"].append(n)
1919
+ for f, key in module_of.items():
1920
+ if key in modules:
1921
+ modules[key]["files"].append(f)
1922
+
1923
+ types_in = {}
1924
+ for n, t in types.items():
1925
+ types_in.setdefault(t["module"], set()).add(n)
1926
+ refs = {}
1927
+ for f, body in text.items():
1928
+ if module_of.get(f) not in modules:
1929
+ continue
1930
+ refs[f] = (set(IDENT_RE.findall(body)) & types_in[module_of[f]]) - decl_of.get(f, set())
1931
+
1932
+ owner, shared, ops = {}, {}, {}
1933
+
1934
+ def _add_op(op, fs, in_scope, key=None):
1935
+ """Record one operation: the roots its files name (a root by name, or a
1936
+ specification of one, is strong; a type a root owns is weak), filed under the
1937
+ module `key`, or — with none given — under the module of the root it acts on,
1938
+ and dropped when it acts on no root at all."""
1939
+ strong, weak = {}, {}
1940
+ for f in fs:
1941
+ for tn in IDENT_RE.findall(text.get(f, "")):
1942
+ t = types.get(tn)
1943
+ if not t or not in_scope(t["module"]):
1944
+ continue
1945
+ o = tn if t["role"] == "root" else owner.get(t["file"])
1946
+ if not o:
1947
+ continue
1948
+ if tn == o or t["role"] == "spec":
1949
+ strong[o] = strong.get(o, 0) + 1
1950
+ else:
1951
+ weak[o] = weak.get(o, 0) + 1
1952
+ pick = strong or weak
1953
+ acts_on = sorted(pick, key=lambda r: (-pick[r], r)) if strong else \
1954
+ ([max(sorted(weak), key=weak.get)] if weak else [])
1955
+ if key is None:
1956
+ if not acts_on:
1957
+ return
1958
+ key = types[acts_on[0]]["module"]
1959
+ ctx = modules[key]["context"]
1960
+ routes = []
1961
+ for f in fs:
1962
+ routes.extend(_routes_in(text.get(f, "")))
1963
+ # An operation over two aggregates is FILED under the one its route names:
1964
+ # POST /access/appointments schedules an Appointment, whatever it loads.
1965
+ fams = {_ui_norm(_route_family(p, (ctx,))) for _v, p in routes}
1966
+ named = [r for r in acts_on if _ui_norm(r) in fams]
1967
+ if named:
1968
+ acts_on = named[:1] + [r for r in acts_on if r != named[0]]
1969
+ ops[op] = {
1970
+ "name": op, "context": ctx, "module": key,
1971
+ "files": sorted(set(fs)), "acts_on": acts_on,
1972
+ "kind": "query" if QUERY_NAME_RE.match(op) or any(
1973
+ re.search(r"Query\b", os.path.basename(f)) for f in fs) else "command",
1974
+ "routes": sorted(set(routes)),
1975
+ }
1976
+ for f in fs:
1977
+ if acts_on and f not in owner:
1978
+ owner[f] = acts_on[0]
1979
+
1980
+ for key, mod in modules.items():
1981
+ op_files, plain = {}, []
1982
+ for f in mod["files"]:
1983
+ parts = f.split("/")
1984
+ _i, op = _feature_layer_op(parts[:-1], children,
1985
+ os.path.splitext(parts[-1])[0])
1986
+ if op:
1987
+ op_files.setdefault(op, []).append(f)
1988
+ else:
1989
+ plain.append(f)
1990
+
1991
+ # Forward reach from each root over the non-operation files.
1992
+ best = {}
1993
+ for r in mod["roots"]:
1994
+ start = types[r]["file"]
1995
+ dist, frontier = {start: 0}, [start]
1996
+ while frontier:
1997
+ nxt = []
1998
+ for f in frontier:
1999
+ for tn in refs.get(f, ()):
2000
+ g = types[tn]["file"]
2001
+ if g in dist or g not in refs and g not in text:
2002
+ continue
2003
+ if types[tn]["role"] == "root" and tn != r:
2004
+ continue
2005
+ dist[g] = dist[f] + 1
2006
+ nxt.append(g)
2007
+ frontier = nxt
2008
+ for f, d in dist.items():
2009
+ best.setdefault(f, []).append((d, r))
2010
+ for f in plain:
2011
+ claims = sorted(best.get(f, []))
2012
+ if not claims:
2013
+ continue
2014
+ top = [r for d, r in claims if d == claims[0][0]]
2015
+ stem = os.path.splitext(os.path.basename(f))[0]
2016
+ named = [r for r in top if stem.startswith(r)]
2017
+ if len(top) == 1:
2018
+ owner[f] = top[0]
2019
+ elif named:
2020
+ owner[f] = max(named, key=len)
2021
+
2022
+ # Files nothing reaches uniquely take the owner of what they REFERENCE: a
2023
+ # specification, a persistence configuration and a DTO mapping each name the
2024
+ # type they serve. What still has no single answer is the module's shared
2025
+ # kernel — an id or value two aggregates hold and neither owns. A file that
2026
+ # names two roots or more (a DbContext, a unit of work, a seeder) spans the
2027
+ # model rather than serving one aggregate of it, and is never voted onto one.
2028
+ #
2029
+ # A NAME outranks a reference here, as it does in the reach tie-break above:
2030
+ # MedicationOrderErrors that mentions Encounter once is still MedicationOrder's.
2031
+ members = {tn: owner[types[tn]["file"]] for tn in types_in.get(key, ())
2032
+ if types[tn]["role"] in ("root", "entity", "value")
2033
+ and types[tn]["file"] in owner}
2034
+ for f in plain:
2035
+ if f in owner:
2036
+ continue
2037
+ named = _named_after(_names_of(f, decl_of), members)
2038
+ if named:
2039
+ owner[f] = named
2040
+ continue
2041
+ if _spans_roots(refs.get(f, ()), types):
2042
+ continue
2043
+ votes = {}
2044
+ for tn in refs.get(f, ()):
2045
+ o = owner.get(types[tn]["file"])
2046
+ if o:
2047
+ votes[o] = votes.get(o, 0) + (3 if tn == o else 1)
2048
+ top = sorted(votes.items(), key=lambda kv: -kv[1])
2049
+ if top and (len(top) == 1 or top[0][1] > top[1][1]):
2050
+ owner[f] = top[0][0]
2051
+ elif f in best:
2052
+ shared.setdefault(key, []).append(f)
2053
+
2054
+ # Slice folders, plus handler classes outside any feature layer.
2055
+ for f in plain:
2056
+ for m in HANDLER_CLASS_RE.finditer(text.get(f, "")):
2057
+ op_files.setdefault(m.group(1), []).append(f)
2058
+ for op, fs in sorted(op_files.items()):
2059
+ _add_op(op, fs, lambda m, key=key: m == key, key)
2060
+
2061
+ # Operations OUTSIDE every module that declares a root. A solution split into one
2062
+ # project per layer (Domain / Application / Infrastructure / Api) keeps its roots in
2063
+ # one project and its handlers in another, so a module-local search finds none —
2064
+ # and the model, which exists to be acted on, reads as having no operations. The
2065
+ # same two shapes are read here (a feature-layer slice, a CQRS handler class), and
2066
+ # an operation counts only when it acts on a declared root: a handler that touches
2067
+ # no aggregate of the model is not one of its operations.
2068
+ in_model = set(modules)
2069
+ outside = [f for f in text if module_of[f] not in in_model]
2070
+ out_files = {}
2071
+ for f in outside:
2072
+ parts = f.split("/")
2073
+ _i, op = _feature_layer_op(parts[:-1], children, os.path.splitext(parts[-1])[0])
2074
+ if op:
2075
+ out_files.setdefault(op, []).append(f)
2076
+ continue
2077
+ for m in HANDLER_CLASS_RE.finditer(text[f]):
2078
+ out_files.setdefault(m.group(1), []).append(f)
2079
+ out_files = {op: fs for op, fs in out_files.items() if op not in ops}
2080
+ # A layer-per-project solution files one operation across several projects — the
2081
+ # command, its validator and its endpoint beside the handler — and names each file
2082
+ # after the operation. The longest operation name a file is named after takes it,
2083
+ # wherever the file sits: an Api project can share a module key with the Domain
2084
+ # project it serves.
2085
+ taken = {f for fs in out_files.values() for f in fs} | {f for o in ops.values() for f in o["files"]}
2086
+ for f in text:
2087
+ if f in taken:
2088
+ continue
2089
+ op = _named_after(_names_of(f, decl_of), {n: n for n in out_files})
2090
+ if op:
2091
+ out_files[op].append(f)
2092
+ for op, fs in sorted(out_files.items()):
2093
+ _add_op(op, fs, lambda m: m in in_model)
2094
+
2095
+ # Everything above decides what orders the aggregates; what follows only decides
2096
+ # which aggregate a file is LISTED under. Ordering is kept to the evidence it always
2097
+ # had — reach, references and operations — so a DTO naming another aggregate's DTO
2098
+ # never makes one aggregate depend on another.
2099
+ edge_owner = dict(owner)
2100
+ _attribute_leftovers(modules, module_of, text, types, decl_of, refs, owner, shared, ops)
2101
+
2102
+ # A -> B when anything A owns names B or a type B owns — in the same context or in
2103
+ # another one. Across contexts that reference is by id by construction, and it is
2104
+ # exactly the dependency that orders the contexts' onboarding.
2105
+ #
2106
+ # READS DO NOT ORDER. A query operation or a specification that reads another
2107
+ # aggregate needs that data to exist in order to return rows, not in order to be
2108
+ # built; counting it turns every dashboard read into a dependency, and two contexts
2109
+ # that merely display each other's data into a cycle.
2110
+ #
2111
+ # AN OPERATION'S OWN TARGETS ARE NOT EDGES. ReconcileMedications acts on Encounter
2112
+ # AND MedicationOrder; that it names both says the operation spans them, not that
2113
+ # one depends on the other — reading it as an edge makes every many-to-many
2114
+ # operation a cycle. What such an operation needs is ordered at the feature level
2115
+ # (its feature follows each target's creator). An operation still contributes an
2116
+ # edge to an aggregate it merely LOOKS UP — another context's user it assigns to.
2117
+ owner_of_type = {tn: (tn if t["role"] == "root" else edge_owner.get(t["file"]))
2118
+ for tn, t in types.items()}
2119
+ read_files = {f for o in ops.values() if o["kind"] == "query" for f in o["files"]}
2120
+ read_files |= {t["file"] for t in types.values() if t["role"] == "spec"}
2121
+ targets_of = {f: set(o["acts_on"]) for o in ops.values() for f in o["files"]}
2122
+ root_edges = {r: set() for r in roots}
2123
+ for f, o in edge_owner.items():
2124
+ if f in read_files:
2125
+ continue
2126
+ for tn in set(IDENT_RE.findall(text.get(f, ""))) - decl_of.get(f, set()):
2127
+ b = owner_of_type.get(tn)
2128
+ if b and b != o and b in roots and b not in targets_of.get(f, ()):
2129
+ root_edges[o].add(b)
2130
+
2131
+ model = {"modules": modules, "types": types, "owner": owner, "shared": shared,
2132
+ "operations": sorted(ops.values(), key=lambda o: (o["context"], o["name"])),
2133
+ "root_edges": root_edges}
2134
+ _DOMAIN_MEMO[memo_key] = model
2135
+ return model
2136
+
2137
+
2138
+ def strategy_domain(root, files, churn_map, graph=None):
2139
+ """S0 — one candidate per DECLARED aggregate root, in the bounded context that
2140
+ holds it. Unavailable where the code declares no root."""
2141
+ model = parse_domain(root, files)
2142
+ if not model["modules"]:
2143
+ return []
2144
+ cands = {}
2145
+ for key, mod in model["modules"].items():
2146
+ for r in mod["roots"]:
2147
+ c = cands.setdefault(r.lower(), _blank_candidate(key, r))
2148
+ c["context"] = mod["context"]
2149
+ c["root_file"] = model["types"][r]["file"]
2150
+ c["confidence"] = "declared"
2151
+ c["entities"] = {n for n, t in model["types"].items()
2152
+ if t["module"] == key and t["role"] in ("root", "entity")
2153
+ and model["owner"].get(t["file"]) == r}
2154
+ c["operations"] = [o["name"] for o in model["operations"]
2155
+ if o["acts_on"][:1] == [r]]
2156
+ for f, r in model["owner"].items():
2157
+ c = cands.get(r.lower())
2158
+ if not c:
2159
+ continue
2160
+ kind = next((_layer_of(p, soft=True) for p in f.split("/")[:-1]
2161
+ if _layer_of(p, soft=True)), "domain")
2162
+ c[kind + "_files"].append(f)
2163
+ c["churn"] += churn_map.get(f, 0)
2164
+ return _finish_candidates(cands, churn_map, "domain")
2165
+
2166
+
1298
2167
  # ── Availability + the probe ───────────────────────────────────────────────
1299
2168
  # A strategy that would return nothing must never be offered: an empty pick is
1300
- # the zero-candidate hard stop at require_aggregates, arrived at by choice.
2169
+ # the zero-candidate hard stop at require_proposal, arrived at by choice.
1301
2170
 
1302
2171
  STRATEGIES = {
2172
+ "domain": strategy_domain,
1303
2173
  "structure": strategy_structure,
1304
2174
  "reference": strategy_reference,
1305
2175
  "persistence": strategy_persistence,
1306
2176
  }
1307
2177
 
1308
2178
 
1309
- def strategy_availability(root, files, graph_stats, ents_found):
2179
+ def strategy_availability(root, files, graph_stats, ents_found, roots_found=()):
1310
2180
  """(name -> {available, reason}) — measured, never assumed."""
1311
2181
  parseable = graph_stats.get("parseable_files", 0)
2182
+ shown = ", ".join(sorted(roots_found)[:4]) + (" …" if len(roots_found) > 4 else "")
1312
2183
  return {
2184
+ "domain": {"available": bool(roots_found),
2185
+ "reason": (f"{len(roots_found)} aggregate root(s) the code declares — {shown}"
2186
+ if roots_found else "no type declares itself an aggregate root")},
1313
2187
  "structure": {"available": True,
1314
2188
  "reason": "every repository has a layout"},
1315
2189
  "reference": {"available": parseable >= 10,
@@ -1327,7 +2201,9 @@ def run_probe(root, files, churn_map):
1327
2201
  on the chosen result."""
1328
2202
  graph, gstats = build_reference_graph(root, files)
1329
2203
  ents = persistence_entities(root, files)
1330
- avail = strategy_availability(root, files, gstats, len(ents))
2204
+ model = parse_domain(root, files)
2205
+ avail = strategy_availability(root, files, gstats, len(ents),
2206
+ [r for m in model["modules"].values() for r in m["roots"]])
1331
2207
  code_total = len([f for f in files if CODE_EXT_RE.search(f)])
1332
2208
 
1333
2209
  results = {}
@@ -1335,6 +2211,14 @@ def run_probe(root, files, churn_map):
1335
2211
  if not avail[name]["available"]:
1336
2212
  continue
1337
2213
  cands = fn(root, files, churn_map, graph)
2214
+ # Evidence that a strategy COULD apply is not a result: `structure` is available
2215
+ # in every repository, an empty one included. A run that found nothing is
2216
+ # reported as unavailable, so it is never offered and never chosen.
2217
+ if not cands:
2218
+ avail[name] = {"available": False,
2219
+ "reason": f"ran over {code_total} code file(s) and found "
2220
+ f"no candidate aggregates"}
2221
+ continue
1338
2222
  covered = {f for c in cands for f in c.get("covered_code", [])}
1339
2223
  results[name] = {
1340
2224
  "candidates": cands,
@@ -1343,22 +2227,38 @@ def run_probe(root, files, churn_map):
1343
2227
  "singletons": sum(1 for c in cands if c.get("raw_file_count", 0) <= 1),
1344
2228
  "available": True,
1345
2229
  "reason": avail[name]["reason"],
2230
+ "operations": sum(len(c.get("operations") or []) for c in cands),
1346
2231
  }
1347
- return results, avail, graph, gstats, ents
2232
+ return results, avail, graph, gstats, ents, model
1348
2233
 
1349
2234
 
1350
- # Preference when the measurements tie. Structure leads because it is the layer
1351
- # the other two are named by, so a tied structure result is the one an operator
1352
- # can predict and re-derive by eye from the folder tree.
1353
- STRATEGY_PREFERENCE = ("structure", "persistence", "reference")
2235
+ # Preference when the measurements tie. Structure leads the measured three because it
2236
+ # is the layer the other two are named by, so a tied structure result is the one an
2237
+ # operator can predict and re-derive by eye from the folder tree.
2238
+ STRATEGY_PREFERENCE = ("domain", "structure", "persistence", "reference")
1354
2239
 
1355
2240
 
1356
2241
  def choose_strategy(results):
1357
- """The non-interactive default: highest coverage, ties broken by fewest
1358
- singletons, then by the standing preference so a re-run of the same tree
1359
- picks the same strategy."""
2242
+ """The non-interactive default.
2243
+
2244
+ DECLARED BEATS MEASURED. Where the code declares aggregate roots AND operations act
2245
+ on them, `domain` is chosen whatever the others score: a declaration is the
2246
+ boundary itself, and coverage — the share of files some candidate claims — rewards
2247
+ a strategy for bucketing every file by folder, not for drawing the boundary right.
2248
+ On the kit's own greenfield output `structure` scores 100% by turning each command
2249
+ handler into an aggregate, while the declared roots are the two the model has.
2250
+
2251
+ The operations condition is the model's own: an aggregate exists to be acted on.
2252
+ A library that ships one aggregate type for its own storage and no operation over
2253
+ it is not a domain model, and one declared root there must not hide the rest of the
2254
+ code from the proposal — `domain` is still offered, it is just not the default.
2255
+
2256
+ Otherwise: highest coverage, ties broken by fewest singletons, then by the
2257
+ standing preference so a re-run of the same tree picks the same strategy."""
1360
2258
  if not results:
1361
2259
  return ""
2260
+ if results.get("domain", {}).get("operations", 0) > 0:
2261
+ return "domain"
1362
2262
  return sorted(results, key=lambda n: (-results[n]["coverage_pct"],
1363
2263
  results[n]["singletons"],
1364
2264
  STRATEGY_PREFERENCE.index(n)))[0]
@@ -1378,38 +2278,126 @@ def choose_strategy(results):
1378
2278
  # them.
1379
2279
  # ═══════════════════════════════════════════════════════════════════════════
1380
2280
 
1381
- ROUTE_ATTR_RE = re.compile(
1382
- r"""(?:\[Route\s*\(\s*["']([^"']+)|"""
1383
- r"""@(?:Request|Get|Post|Put|Delete|Patch)Mapping\s*\(\s*(?:value\s*=\s*)?["']([^"']+)|"""
1384
- r"""@(?:app|router)\.(?:get|post|put|delete|patch)\s*\(\s*["']([^"']+)|"""
1385
- r"""(?:app|router)\.(?:get|post|put|delete|patch)\s*\(\s*["']([^"']+)|"""
1386
- r"""Map(?:Get|Post|Put|Delete|Patch)\s*\(\s*["']([^"']+))""",
1387
- re.IGNORECASE)
2281
+ # (regex, verb group or None, path group). Each web framework declares a route its own
2282
+ # way; the verb is kept where the syntax carries it, because it is what tells the
2283
+ # operation that CREATES an aggregate (POST on the collection) from the ones that act
2284
+ # on an existing instance.
2285
+ ROUTE_DECL_RES = (
2286
+ # FastEndpoints — Get("/x") inside Configure(). Unqualified and rooted at "/", which
2287
+ # is what keeps a dictionary's Get("key") or a client's http.Get(...) out.
2288
+ (re.compile(r"""(?<![\w.])(Get|Post|Put|Patch|Delete)\s*\(\s*"(/[^"]*)\""""), 1, 2),
2289
+ (re.compile(r"""(?<![\w.])Routes\s*\(\s*"(/[^"]*)\""""), None, 1),
2290
+ # ASP.NET MVC / Web API attributes, minimal APIs.
2291
+ (re.compile(r"""\[Http(Get|Post|Put|Patch|Delete)\s*\(\s*"([^"]*)\""""), 1, 2),
2292
+ (re.compile(r"""\[Route\s*\(\s*["']([^"']+)"""), None, 1),
2293
+ (re.compile(r"""\bMap(Get|Post|Put|Patch|Delete)\s*\(\s*["']([^"']+)"""), 1, 2),
2294
+ # Spring.
2295
+ (re.compile(r"""@(Get|Post|Put|Patch|Delete)Mapping\s*\(\s*(?:value\s*=\s*|path\s*=\s*)?["']([^"']+)"""), 1, 2),
2296
+ (re.compile(r"""@RequestMapping\s*\(\s*(?:value\s*=\s*|path\s*=\s*)?["']([^"']+)"""), None, 1),
2297
+ # Express / FastAPI / Flask-style routers.
2298
+ (re.compile(r"""@?(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*["']([^"']+)"""), 1, 2),
2299
+ )
1388
2300
  HANDLER_NAME_RE = re.compile(r"(Command|Query)Handler$|^Handle")
1389
2301
 
1390
2302
 
1391
- def _route_family(path_literal):
1392
- """The first meaningful segment of a route — the family a cutover switches.
2303
+ def _routes_in(body):
2304
+ """Every route a source file declares, as ("VERB", "/path") — "" when the syntax
2305
+ does not say which verb."""
2306
+ out = []
2307
+ for rx, vg, pg in ROUTE_DECL_RES:
2308
+ for m in rx.finditer(body or ""):
2309
+ path = m.group(pg)
2310
+ if path:
2311
+ out.append(((m.group(vg) or "").upper() if vg else "", path))
2312
+ return out
2313
+
2314
+
2315
+ def _name_words(path):
2316
+ """A file name's words, lower-cased: `RecordEncounterEndpoint.cs` → record,
2317
+ encounter, endpoint; `order.controller.ts` → order, controller."""
2318
+ stem = os.path.splitext(os.path.basename(path))[0]
2319
+ return [w.lower() for w in NAME_WORD_RE.findall(re.sub(r"[._\-]+", " ", stem))]
2320
+
1393
2321
 
1394
- /api/v2/orders/{id}/lines -> orders. Version and api prefixes are carried by
1395
- every route in the service and separate nothing."""
1396
- parts = [p for p in re.split(r"[/\\]", (path_literal or "").strip("/"))
1397
- if p and not p.startswith(("{", ":", "[", "<"))]
1398
- for p in parts:
2322
+ def api_and_data_files(root, files):
2323
+ """The scan's `route_like_files` and `data_layer_files`: source that serves the API
2324
+ surface, and source that persists the model.
2325
+
2326
+ A route file is named for its role — its LAST word is controller, endpoint, route,
2327
+ router, urls, views… — or declares a route in its body (a minimal-API `MapGet`, an
2328
+ attribute route, an Express or FastEndpoints call). A file in a domain-layer folder
2329
+ is never one, whatever it is called. A data-layer file sits in a persistence folder,
2330
+ is named for a persistence role (DbContext, Repository, Dao, Migration), is SQL or a
2331
+ Prisma schema, or declares an ORM mapping. Tests and samples are neither: they
2332
+ exercise the surface, they are not it."""
2333
+ routes, data = [], []
2334
+ for f in files:
2335
+ low = f.lower()
2336
+ is_src = bool(SCAN_SOURCE_RE.search(f))
2337
+ if not (is_src or low.endswith((".sql", ".prisma"))) or TEST_PATH_RE.search(f):
2338
+ continue
2339
+ body = None
2340
+ if is_src and not DOMAIN_DIR_RE.search(os.path.dirname(f) + "/"):
2341
+ words = _name_words(f)
2342
+ if words and words[-1] in ROUTE_ROLE_WORDS:
2343
+ routes.append(f)
2344
+ else:
2345
+ body = _read_source(root, f)
2346
+ if _routes_in(body):
2347
+ routes.append(f)
2348
+ stem = os.path.splitext(os.path.basename(f))[0]
2349
+ if (not is_src or DATA_DIR_RE.search(os.path.dirname(f) + "/")
2350
+ or DATA_NAME_RE.search(stem)):
2351
+ data.append(f)
2352
+ elif is_src:
2353
+ if body is None:
2354
+ body = _read_source(root, f)
2355
+ if DATA_DECL_RE.search(body or ""):
2356
+ data.append(f)
2357
+ return routes, data
2358
+
2359
+
2360
+ def _route_segments(path_literal, skip=()):
2361
+ """A route's literal segments, with api/version prefixes, parameters and any
2362
+ segment in `skip` removed. `skip` carries the bounded context's own name: the kit's
2363
+ route convention is /<context>/<aggregate-plural>/{id}/<sub-resource>, and a context
2364
+ prefix is carried by every route in that context and separates nothing inside it."""
2365
+ skip = {_ui_norm(s) for s in skip}
2366
+ out = []
2367
+ for p in re.split(r"[/\\]", (path_literal or "").strip("/")):
2368
+ if not p or p.startswith(("{", ":", "[", "<", "$")):
2369
+ continue
1399
2370
  low = p.lower()
1400
- if low in ("api", "rest", "graphql", "v1", "v2", "v3", "internal", "public"):
2371
+ if low in ("api", "rest", "graphql", "internal", "public") or re.fullmatch(r"v\d+", low):
1401
2372
  continue
1402
- if re.fullmatch(r"v\d+", low):
2373
+ if _ui_norm(p) in skip and not out:
1403
2374
  continue
1404
- return singularize(p)
1405
- return ""
2375
+ out.append(p)
2376
+ return out
2377
+
2378
+
2379
+ def _route_family(path_literal, skip=()):
2380
+ """The first meaningful segment of a route — the family a cutover switches.
2381
+
2382
+ /api/v2/orders/{id}/lines -> orders; /care/encounters/{id} -> encounter once
2383
+ `care` is the context being skipped."""
2384
+ segs = _route_segments(path_literal, skip)
2385
+ return singularize(segs[0]) if segs else ""
1406
2386
 
1407
2387
 
1408
- def seam_overlay(root, files, candidates, churn_map, ui_attribution=None):
2388
+ def seam_overlay(root, files, candidates, churn_map, ui_attribution=None, contexts=None):
1409
2389
  """Per-candidate seam signals. Two distinct route families on one aggregate
1410
- is the split candidate the feature proposal will surface with its evidence."""
2390
+ is the split candidate the feature proposal will surface with its evidence.
2391
+
2392
+ `contexts` ({context: [candidate, …]}) names each candidate's bounded context so
2393
+ its route prefix can be skipped — without it every route in a /care/… service
2394
+ falls into one `care` family and no split is ever visible."""
1411
2395
  owner = _candidate_of_file(candidates)
1412
2396
  by_name = {c["name"]: c for c in candidates}
2397
+ ctx_of = {n: ctx for ctx, names in (contexts or {}).items() for n in names}
2398
+ for c in candidates:
2399
+ if c.get("context"):
2400
+ ctx_of[c["name"]] = c["context"]
1413
2401
  out = {c["name"]: {"route_families": {}, "command_groups": set(),
1414
2402
  "ui_segments": set(), "churn": 0, "api_files": 0}
1415
2403
  for c in candidates}
@@ -1433,8 +2421,13 @@ def seam_overlay(root, files, candidates, churn_map, ui_attribution=None):
1433
2421
  body = _read_source(root, f)
1434
2422
  if not body:
1435
2423
  continue
1436
- for m in ROUTE_ATTR_RE.finditer(body):
1437
- fam = _route_family(next((g for g in m.groups() if g), ""))
2424
+ # A handler is named by its CLASS, not its file: FooCommandHandler routinely
2425
+ # lives in FooCommand.cs beside the command it handles.
2426
+ for m in HANDLER_CLASS_RE.finditer(body):
2427
+ out[name]["command_groups"].add(_entity_noun(m.group(1) + ".x") or m.group(1))
2428
+ skip = (ctx_of[name],) if ctx_of.get(name) else ()
2429
+ for _verb, path in _routes_in(body):
2430
+ fam = _route_family(path, skip)
1438
2431
  if fam:
1439
2432
  out[name]["route_families"][fam] = out[name]["route_families"].get(fam, 0) + 1
1440
2433
  out[name]["api_files"] += 1
@@ -1503,6 +2496,20 @@ def derive_contexts(candidates, files):
1503
2496
  return part
1504
2497
  return ""
1505
2498
 
2499
+ # A DECLARED context — the module folder that holds a Domain layer, which the kit's
2500
+ # folder-structure rule makes one folder per bounded context — is taken as it
2501
+ # stands: its name verbatim (it is the name specs/<Context>/ must carry for a slice
2502
+ # to be traceable in both trees), and never folded for holding one aggregate. A
2503
+ # context with one aggregate is a real and common shape; only an INFERRED path
2504
+ # segment can be incidental.
2505
+ declared = {}
2506
+ for c in candidates:
2507
+ if c.get("context"):
2508
+ declared.setdefault(c["context"], []).append(c["name"])
2509
+ candidates = [c for c in candidates if not c.get("context")]
2510
+ if not candidates:
2511
+ return {k: sorted(v) for k, v in sorted(declared.items())}
2512
+
1506
2513
  groups = {}
1507
2514
  for c in candidates:
1508
2515
  # Voted across the candidate's own files rather than read off module_dir:
@@ -1528,6 +2535,8 @@ def derive_contexts(candidates, files):
1528
2535
  for k in singles:
1529
2536
  merged.extend(groups.pop(k))
1530
2537
  groups.setdefault("Core", []).extend(merged)
2538
+ for k, v in declared.items():
2539
+ groups.setdefault(k, []).extend(v)
1531
2540
  return {k: sorted(v) for k, v in sorted(groups.items())}
1532
2541
 
1533
2542
 
@@ -1555,53 +2564,216 @@ def dependency_waves(nodes, edges):
1555
2564
  return waves, cycles
1556
2565
 
1557
2566
 
1558
- def propose_features(candidates, seams, edges, contexts):
1559
- """Default 1:1 — one aggregate, one feature, same name.
1560
-
1561
- A split is PROPOSED where Pass B found two or more distinct seams on one
1562
- aggregate, carrying the evidence that suggested it, and is never applied:
1563
- the operator confirms it by editing the proposal. Where a split is taken the
1564
- feature is named from the capability, not from a second copy of the noun."""
2567
+ def _creating_op(aggregate, ops, context):
2568
+ """The operation that brings an instance of `aggregate` into existence, or None.
2569
+
2570
+ A POST on the aggregate's collection — no instance id in the path — is the
2571
+ evidence; a creation verb in a name that carries the aggregate is the fallback
2572
+ where the route is not declared. Whichever feature holds this operation is the
2573
+ one every other feature on the aggregate has to follow: nothing can be amended,
2574
+ signed or cancelled before something created it."""
2575
+ mine = [o for o in ops if o["acts_on"][:1] == [aggregate] and o["kind"] == "command"]
2576
+ for o in mine:
2577
+ for verb, path in o["routes"]:
2578
+ if verb == "POST" and len(_route_segments(path, (context,))) == 1 \
2579
+ and "{" not in path.rstrip("/").rsplit("/", 1)[-1]:
2580
+ return o["name"]
2581
+ for o in mine:
2582
+ if CREATE_NAME_RE.match(o["name"]) and o["name"][CREATE_NAME_RE.match(o["name"]).end():] == aggregate:
2583
+ return o["name"]
2584
+ return None
2585
+
2586
+
2587
+ def propose_features(candidates, seams, edges, contexts, operations=None, ui_feats=None):
2588
+ """Features, proposed from the one kind of evidence that names a capability.
2589
+
2590
+ GREENFIELD SHAPE, READ BACK. A feature is a capability the developer chose; it
2591
+ acts on one or more aggregates of ONE bounded context, and several features may act
2592
+ on one aggregate. Its operations are what its user stories need done — several to a
2593
+ story as often as not, and a read may serve more than one feature. Code records a feature in
2594
+ exactly one place — the UI lane's features/<Feature>/ folder, named after the spec
2595
+ feature verbatim — so:
2596
+
2597
+ 1. every UI feature folder becomes a feature, named verbatim, holding the
2598
+ operations it calls and acting on those operations' aggregates;
2599
+ 2. a read no feature calls is SHARED — it joins every feature acting on its
2600
+ aggregate, because reads are not what separates two capabilities;
2601
+ 3. commands no feature calls, and aggregates no UI feature touches, fall back to
2602
+ one feature per aggregate named after it — the documented default, labelled
2603
+ as a default so the operator renames it to the capability it serves.
2604
+
2605
+ An operation is never a feature, and neither is a whole bounded context.
2606
+
2607
+ ORDER comes from creation: on each aggregate the feature holding its creating
2608
+ operation goes first, every other feature on it depends on that one, and an
2609
+ aggregate that references another by id depends on the other's creator. With no
2610
+ operations at all the aggregate graph orders the defaults, as it always did.
2611
+
2612
+ A split is PROPOSED where Pass B found two or more distinct seams on a default
2613
+ feature's aggregate, carrying the evidence that suggested it, and is never
2614
+ applied."""
1565
2615
  ctx_of = {name: ctx for ctx, names in contexts.items() for name in names}
1566
- out = []
2616
+ cand = {c["name"]: c for c in candidates}
2617
+ ops = [o for o in (operations or []) if o["acts_on"] and o["acts_on"][0] in cand]
2618
+ op_by_key = {_ui_norm(o["name"]): o for o in ops}
2619
+
2620
+ # Which aggregate a route family names, learned from the operations' own routes.
2621
+ fam_to_agg = {}
2622
+ for o in ops:
2623
+ for _v, path in o["routes"]:
2624
+ fam = _route_family(path, (ctx_of.get(o["acts_on"][0], ""),))
2625
+ if fam:
2626
+ fam_to_agg.setdefault(_ui_norm(fam), {}).setdefault(o["acts_on"][0], 0)
2627
+ fam_to_agg[_ui_norm(fam)][o["acts_on"][0]] += 1
2628
+
2629
+ feats, claimed, unlinked = [], set(), []
2630
+ for name, rec in sorted((ui_feats or {}).items()):
2631
+ mine = []
2632
+ for ident in sorted(rec["calls"]) + [name]:
2633
+ k = _ui_norm(ident)
2634
+ o = op_by_key.get(k) or op_by_key.get(re.sub(r"action$", "", k))
2635
+ if o and o not in mine:
2636
+ mine.append(o)
2637
+ aggs = []
2638
+ for o in mine:
2639
+ aggs += [a for a in o["acts_on"] if a not in aggs]
2640
+ for lit in sorted(rec["routes"]):
2641
+ for ctx in set(ctx_of.values()) or {""}:
2642
+ fam = _route_family(lit, (ctx,))
2643
+ hit = fam_to_agg.get(_ui_norm(fam), {})
2644
+ if hit:
2645
+ a = max(sorted(hit), key=hit.get)
2646
+ if a not in aggs:
2647
+ aggs.append(a)
2648
+ if not aggs:
2649
+ unlinked.append(name)
2650
+ continue
2651
+ votes = {}
2652
+ for a in aggs:
2653
+ votes[ctx_of.get(a, "Core")] = votes.get(ctx_of.get(a, "Core"), 0) + 1
2654
+ ctx = max(sorted(votes), key=votes.get)
2655
+ claimed.update(o["name"] for o in mine)
2656
+ feats.append({"feature": name, "context": ctx,
2657
+ "aggregates": [a for a in aggs if ctx_of.get(a, "Core") == ctx],
2658
+ "references": [a for a in aggs if ctx_of.get(a, "Core") != ctx],
2659
+ "operations": [o["name"] for o in mine], "called": len(mine),
2660
+ "source": "ui-feature", "ui_files": len(rec["files"])})
2661
+
2662
+ # Shared reads join every feature already acting on their aggregate.
2663
+ for o in ops:
2664
+ if o["name"] in claimed or o["kind"] != "query":
2665
+ continue
2666
+ takers = [f for f in feats if o["acts_on"][0] in f["aggregates"]]
2667
+ for f in takers:
2668
+ f["operations"].append(o["name"])
2669
+ if takers:
2670
+ claimed.add(o["name"])
2671
+
2672
+ # The default: one feature per aggregate for what no capability claimed.
2673
+ by_name = {f["feature"]: f for f in feats}
1567
2674
  for c in candidates:
1568
- sig = seams.get(c["name"], {})
2675
+ a = c["name"]
2676
+ on_a = [f for f in feats if a in f["aggregates"]]
2677
+ rest = [o["name"] for o in ops if o["acts_on"][0] == a and o["name"] not in claimed]
2678
+ if on_a and not rest:
2679
+ continue
2680
+ name = a if a not in by_name else f"{a}Operations"
2681
+ f = {"feature": name, "context": ctx_of.get(a, "Core"), "aggregates": [a],
2682
+ "references": [], "operations": rest, "source": "aggregate-default",
2683
+ "ui_files": 0}
2684
+ feats.append(f)
2685
+ by_name[name] = f
2686
+ claimed.update(rest)
2687
+
2688
+ # Creation order.
2689
+ op_of = {o["name"]: o for o in ops}
2690
+ creator = {}
2691
+ for c in candidates:
2692
+ mk = _creating_op(c["name"], ops, ctx_of.get(c["name"], ""))
2693
+ holder = next((f["feature"] for f in feats if mk and mk in f["operations"]), None)
2694
+ if not holder:
2695
+ homes = [f["feature"] for f in feats if f["aggregates"][:1] == [c["name"]]]
2696
+ holder = homes[0] if len(homes) == 1 else None
2697
+ if holder:
2698
+ creator[c["name"]] = holder
2699
+
2700
+ out = []
2701
+ for f in feats:
2702
+ primary = f["aggregates"][0]
2703
+ deps = set()
2704
+ for a in f["aggregates"]:
2705
+ if creator.get(a) and creator[a] != f["feature"]:
2706
+ deps.add(creator[a])
2707
+ for b in edges.get(a, set()):
2708
+ if creator.get(b) and creator[b] != f["feature"]:
2709
+ deps.add(creator[b])
2710
+ sig = seams.get(primary, {})
1569
2711
  fams = sig.get("route_families", [])
2712
+ op_files = {x for n in f["operations"] for x in op_of.get(n, {}).get("files", [])}
2713
+ sole = len([g for g in feats if primary in g["aggregates"]]) == 1
2714
+ if f["source"] == "ui-feature":
2715
+ shared_reads = len(f["operations"]) - f["called"]
2716
+ evidence = (f"UI feature folder features/{f['feature']}/ calls {f['called']} "
2717
+ f"operation(s)" + (f" + {shared_reads} shared read(s)" if shared_reads else ""))
2718
+ elif f["operations"]:
2719
+ evidence = ("aggregate default — no UI feature calls these operations; rename it "
2720
+ "to the capability they serve, or move them to a feature above")
2721
+ else:
2722
+ evidence = sig.get("evidence", "") or "aggregate default — no operations found"
1570
2723
  feature = {
1571
- "feature": c["name"],
1572
- "aggregate": c["name"],
1573
- "context": ctx_of.get(c["name"], "Core"),
1574
- "files": c.get("raw_file_count", c.get("file_count", 0)),
1575
- "churn": c.get("churn", 0),
2724
+ "feature": f["feature"],
2725
+ "aggregate": primary,
2726
+ "aggregates": f["aggregates"],
2727
+ "context": f["context"],
2728
+ "files": (cand[primary].get("raw_file_count", cand[primary].get("file_count", 0))
2729
+ if sole else len(op_files)) + f["ui_files"],
2730
+ "churn": cand[primary].get("churn", 0) if sole else 0,
1576
2731
  "seam": sig.get("seam", ""),
1577
2732
  "seam_count": sig.get("seam_count", 0),
1578
- "evidence": sig.get("evidence", ""),
1579
- "depends_on": sorted(edges.get(c["name"], set())),
2733
+ "evidence": evidence,
2734
+ "depends_on": sorted(deps),
2735
+ "operations": f["operations"],
2736
+ "source": f["source"],
2737
+ "references_by_id": f["references"],
1580
2738
  "split_proposed": [],
1581
2739
  }
1582
- if sig.get("split_candidate"):
2740
+ if f["source"] == "aggregate-default" and sig.get("split_candidate"):
1583
2741
  feature["split_proposed"] = [
1584
- {"feature": _capability_name(c["name"], f),
1585
- "seam": f,
1586
- "why": f"a second route family `{f}` cuts over independently of `{fams[0]}`"}
1587
- for f in fams[1:4]
2742
+ {"feature": _capability_name(primary, fm),
2743
+ "seam": fm,
2744
+ "why": f"a second route family `{fm}` cuts over independently of `{fams[0]}`"}
2745
+ for fm in fams[1:4]
1588
2746
  ]
1589
2747
  out.append(feature)
1590
2748
  out.sort(key=lambda x: (x["context"], -x["files"], x["feature"]))
1591
- return out
2749
+ # What the operator must decide rather than accept: commands that fell to a default
2750
+ # beside real capabilities on the same aggregate, and UI features that call nothing
2751
+ # the scan could tie to an aggregate.
2752
+ capab = {a for f in feats if f["source"] == "ui-feature" for a in f["aggregates"]}
2753
+ notes = {
2754
+ "orphaned": [(f["feature"], n) for f in feats if f["source"] == "aggregate-default"
2755
+ and f["aggregates"][0] in capab for n in f["operations"]],
2756
+ "unlinked_ui": unlinked,
2757
+ }
2758
+ return out, notes
1592
2759
 
1593
2760
 
1594
- def aggregate_signals(files, churn_map):
1595
- """Derive CANDIDATE aggregate roots — the base unit the inventory and onboard
1596
- workflows turn into a specs/<Feature>/ slice.
2761
+ def aggregate_signals(files, churn_map, root=None):
2762
+ """Derive CANDIDATE aggregate roots — the unit onboard writes one OKF concept for.
1597
2763
 
1598
- Delegates to Pass A's structure strategy, which reads the repo's organising
1599
- axis (module-major, project-major, vertical slice, n-tier, or the build
1600
- manifest) instead of assuming one. Every caller of this function gets the
1601
- same candidate shape it always did, with the strategy's extra keys added.
2764
+ Where the code DECLARES its roots and operations act on them (and `root` is given
2765
+ so the source can be read), those are the candidates, each stamped with its
2766
+ bounded context and its operations — the same rule choose_strategy applies.
2767
+ Otherwise Pass A's structure strategy reads the repo's organising axis
2768
+ (module-major, project-major, vertical slice, n-tier, or the build manifest)
2769
+ instead of assuming one. Every caller gets the same candidate shape either way.
1602
2770
 
1603
2771
  Signals only — the workflow's agent draws the real boundaries. Empty for a
1604
2772
  repo with no recognizable structure at all."""
2773
+ if root:
2774
+ declared = strategy_domain(root, files, churn_map)
2775
+ if any(c.get("operations") for c in declared):
2776
+ return declared
1605
2777
  return strategy_structure(None, files, churn_map)
1606
2778
 
1607
2779
 
@@ -1660,7 +2832,88 @@ def _ui_name_tokens(name):
1660
2832
  return out
1661
2833
 
1662
2834
 
1663
- def attribute_ui_files(signals, fe_files, churn_map, ui_roots):
2835
+ # A UI feature folder: features/<Feature>/ under the UI lane. Lower-case on purpose —
2836
+ # the service lane's PascalCase Features/<Operation>/ is a different thing (one
2837
+ # operation of a module) and must never be read as a capability.
2838
+ UI_FEATURE_SEG_RE = re.compile(r"(?:^|/)features/([^/]+)/")
2839
+ UI_SOURCE_RE = re.compile(r"\.(tsx?|jsx?|mjs|vue|svelte)$", re.IGNORECASE)
2840
+ API_IMPORT_RE = re.compile(r"""import\s*(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]""")
2841
+ API_NS_IMPORT_RE = re.compile(r"""import\s+\*\s+as\s+(\w+)\s+from\s*['"]([^'"]+)['"]""")
2842
+ API_MODULE_RE = re.compile(r"(?:^|[/@])apis?(?:/|$)")
2843
+ UI_ROUTE_LITERAL_RE = re.compile(r"""[`'"](/[A-Za-z][A-Za-z0-9_\-/${}.:]*)[`'"]""")
2844
+
2845
+
2846
+ def ui_features(root, fe_files, ui_prefix):
2847
+ """The CAPABILITIES the UI lane records: {Feature: {"files", "calls", "routes"}}.
2848
+
2849
+ The frontend rules make features/<Feature>/ the spec feature's name verbatim —
2850
+ "that match is what makes a slice traceable in both lanes". It is therefore the
2851
+ only place in code where a feature, as distinct from an aggregate or an
2852
+ operation, is written down. `calls` are the API-client functions the feature
2853
+ imports (the operations it invokes, by name); `routes` are the path literals it
2854
+ builds, which name the aggregates it reads even where it imports nothing.
2855
+
2856
+ Runs only under a DECLARED UI root: without one scope_to_lanes hands back the
2857
+ whole repo, and a backend's Features/ folders are not capabilities."""
2858
+ if not ui_prefix:
2859
+ return {}
2860
+ feats = {}
2861
+ for f in fe_files:
2862
+ if not f.startswith(ui_prefix) or not UI_SOURCE_RE.search(f):
2863
+ continue
2864
+ m = UI_FEATURE_SEG_RE.search(f[len(ui_prefix) - 1:])
2865
+ if not m:
2866
+ continue
2867
+ rec = feats.setdefault(m.group(1), {"files": [], "calls": set(), "routes": set()})
2868
+ rec["files"].append(f)
2869
+ body = _read_source(root, f)
2870
+ for names, mod in API_IMPORT_RE.findall(body):
2871
+ if API_MODULE_RE.search(mod):
2872
+ rec["calls"].update(n.strip().split(" as ")[0].strip()
2873
+ for n in names.split(",") if n.strip())
2874
+ for ns, mod in API_NS_IMPORT_RE.findall(body):
2875
+ if API_MODULE_RE.search(mod):
2876
+ rec["calls"].update(re.findall(rf"\b{re.escape(ns)}\.(\w+)\s*\(", body))
2877
+ rec["routes"].update(UI_ROUTE_LITERAL_RE.findall(body))
2878
+ return feats
2879
+
2880
+
2881
+ def detect_operations(root, files, candidates):
2882
+ """Operations for a repo whose code declares no aggregate root: the slices under a
2883
+ module's feature layer, and CQRS handler classes wherever they are. Each acts on
2884
+ the candidate that owns most of its files — the only link available without a
2885
+ declared model."""
2886
+ children = _child_dirs(files)
2887
+ owner = _candidate_of_file(candidates)
2888
+ op_files = {}
2889
+ for f in files:
2890
+ if not _lang_of(f) or TEST_PATH_RE.search(f):
2891
+ continue
2892
+ parts = f.split("/")
2893
+ _i, op = _feature_layer_op(parts[:-1], children, os.path.splitext(parts[-1])[0])
2894
+ if op:
2895
+ op_files.setdefault(op, []).append(f)
2896
+ continue
2897
+ for m in HANDLER_CLASS_RE.finditer(_code_only(f, _read_source(root, f)) if f.endswith(
2898
+ (".cs", ".java", ".kt", ".ts", ".py")) else ""):
2899
+ op_files.setdefault(m.group(1), []).append(f)
2900
+ ops = []
2901
+ for op, fs in sorted(op_files.items()):
2902
+ votes = {}
2903
+ for f in fs:
2904
+ if owner.get(f):
2905
+ votes[owner[f]] = votes.get(owner[f], 0) + 1
2906
+ routes = []
2907
+ for f in fs:
2908
+ routes.extend(_routes_in(_read_source(root, f)))
2909
+ ops.append({"name": op, "context": "", "module": "", "files": sorted(set(fs)),
2910
+ "acts_on": [max(sorted(votes), key=votes.get)] if votes else [],
2911
+ "kind": "query" if QUERY_NAME_RE.match(op) else "command",
2912
+ "routes": sorted(set(routes))})
2913
+ return ops
2914
+
2915
+
2916
+ def attribute_ui_files(signals, fe_files, churn_map, ui_roots, feature_aggs=None):
1664
2917
  """Attribute each frontend file to the aggregate it serves.
1665
2918
 
1666
2919
  WHY THIS EXISTS. scope_to_lanes splits the substrate so the backend and frontend
@@ -1669,7 +2922,14 @@ def attribute_ui_files(signals, fe_files, churn_map, ui_roots):
1669
2922
  serves it. adopt then reverses that slice with no UI source within reach, so the
1670
2923
  frontend lane's design has nothing to be derived from.
1671
2924
 
1672
- The match is by NAME with both sides normalised: a DIRECTORY SEGMENT named for the
2925
+ THE CONFIRMED MAP FIRST. `feature_aggs` is {feature: [aggregates it acts on]} from
2926
+ the feature map the operator confirmed. A file under a UI `features/<Feature>/`
2927
+ folder the map names serves exactly those aggregates — every one of them, since a
2928
+ feature may act on several — whatever the file is called. A capability is named for
2929
+ what the user does (RecordEncounter, PlaceMedicationOrder), so matching its folder
2930
+ against aggregate names alone leaves the lane's own feature folders unattributed.
2931
+
2932
+ Everything else is matched by NAME with both sides normalised: a DIRECTORY SEGMENT named for the
1673
2933
  concept (the strong signal — a feature folder), or a filename whose concept stem
1674
2934
  matches once its UI role suffix is stripped. Where several aggregates match, the
1675
2935
  LONGEST name wins, so `MedicinalProduct` takes a file that `Product` would also
@@ -1714,19 +2974,37 @@ def attribute_ui_files(signals, fe_files, churn_map, ui_roots):
1714
2974
  for c, _ in token_map:
1715
2975
  c.setdefault("ui_files", [])
1716
2976
 
1717
- unattributed = []
2977
+ # {normalised feature: [candidates]} — only features whose aggregates the scan found.
2978
+ by_name = {c["name"]: c for c in signals}
2979
+ served_by = {}
2980
+ for feat, aggs in (feature_aggs or {}).items():
2981
+ cs = [by_name[a] for a in aggs if a in by_name]
2982
+ if cs:
2983
+ served_by[_ui_norm(feat)] = cs
2984
+
2985
+ def mapped(f):
2986
+ p = next((p for p in prefixes if f.startswith(p)), "")
2987
+ m = UI_FEATURE_SEG_RE.search(f[len(p):])
2988
+ return served_by.get(_ui_norm(m.group(1))) if m else None
2989
+
2990
+ unattributed, attributed_files, by_map = [], set(), 0
1718
2991
  for f in ui_files:
1719
- segments = {_ui_norm(p) for p in os.path.dirname(f).split("/") if p}
1720
- stem = _ui_stem(f)
1721
- for c, tokens in token_map:
1722
- if (segments & tokens) or (stem and stem in tokens):
1723
- c["ui_files"].append(f)
1724
- c["churn"] += churn_map.get(f, 0)
1725
- break
2992
+ owners = mapped(f)
2993
+ if owners:
2994
+ by_map += 1
1726
2995
  else:
2996
+ segments = {_ui_norm(p) for p in os.path.dirname(f).split("/") if p}
2997
+ stem = _ui_stem(f)
2998
+ owners = next(([c] for c, tokens in token_map
2999
+ if (segments & tokens) or (stem and stem in tokens)), [])
3000
+ if not owners:
1727
3001
  unattributed.append(f)
3002
+ continue
3003
+ attributed_files.add(f)
3004
+ for c in owners:
3005
+ c["ui_files"].append(f)
3006
+ c["churn"] += churn_map.get(f, 0)
1728
3007
 
1729
- attributed = 0
1730
3008
  with_ui = 0
1731
3009
  for c, _ in token_map:
1732
3010
  c["ui_files"] = sorted(set(c["ui_files"]))[:CAP]
@@ -1734,15 +3012,71 @@ def attribute_ui_files(signals, fe_files, churn_map, ui_roots):
1734
3012
  # aggregate ranking is backend evidence and a UI-heavy slice must not
1735
3013
  # outrank a domain-heavy one on component count.
1736
3014
  c["ui_file_count"] = len(c["ui_files"])
1737
- attributed += c["ui_file_count"]
1738
3015
  if c["ui_file_count"]:
1739
3016
  with_ui += 1
1740
3017
 
1741
- return {"attributed_file_count": attributed, "aggregates_with_ui": with_ui,
3018
+ return {"attributed_file_count": len(attributed_files), "aggregates_with_ui": with_ui,
3019
+ "attributed_by_feature_map": by_map,
1742
3020
  "unattributed_count": len(unattributed),
1743
3021
  "unattributed": sorted(unattributed)[:CAP], "search_roots": roots}
1744
3022
 
1745
3023
 
3024
+ def _feature_aggregates(map_path):
3025
+ """{feature: [aggregates it acts on]} from a feature map, or {} when there is none
3026
+ or it cannot be read — the scan runs before any map exists, too."""
3027
+ try:
3028
+ with open(map_path, encoding="utf-8") as fh:
3029
+ m = json.load(fh)
3030
+ except (OSError, ValueError):
3031
+ return {}
3032
+ out = {}
3033
+ for f in m.get("features", []) if isinstance(m, dict) else []:
3034
+ name = f.get("feature") if isinstance(f, dict) else None
3035
+ aggs = (f.get("aggregates") or ([f["aggregate"]] if f.get("aggregate") else [])) \
3036
+ if name else []
3037
+ if name and aggs:
3038
+ out[name] = [a for a in aggs if isinstance(a, str)]
3039
+ return out
3040
+
3041
+
3042
+ # What each lane's steps do not need from the substrate. The service view drops the UI
3043
+ # lane's signals; the UI view drops the backend's aggregates and its API/data listings.
3044
+ _SERVICE_ONLY_KEYS = ("aggregate_signals", "route_like_files", "data_layer_files")
3045
+ _UI_ONLY_KEYS = ("frontend_signals", "ui_attribution")
3046
+ _PATH_LIST_KEYS = ("dependency_manifests", "entrypoints", "api_definitions",
3047
+ "route_like_files", "data_layer_files", "ops_files", "docs",
3048
+ "config_files", "adr_docs")
3049
+
3050
+
3051
+ def lane_views(inv, ui_prefix):
3052
+ """The substrate as each lane's authoring steps need it: (service_view, ui_view).
3053
+
3054
+ onboard inlines the substrate into every step that authors from it, and each of
3055
+ those steps governs ONE lane. The full substrate hands the service lane's rule
3056
+ author the UI's whole file inventory, and the UI lane's authors every backend
3057
+ aggregate — material their prompts then tell them to ignore. Each view keeps the
3058
+ repository-wide facts (git, manifests, CI, docs, top-level layout) and its own
3059
+ lane's signals. Where a UI root is declared, the path lists and the churn are also
3060
+ split at it: the service view keeps what lies outside it, the UI view what lies
3061
+ inside. Without a declared UI root there is nothing to split them on."""
3062
+ def split(view, keep):
3063
+ for k in _PATH_LIST_KEYS:
3064
+ if isinstance(view.get(k), list):
3065
+ view[k] = [f for f in view[k] if keep(f)]
3066
+ hist = dict(view["git"]["history"])
3067
+ hist["churn"] = [c for c in hist.get("churn", []) if keep(c["file"])]
3068
+ view["git"] = dict(view["git"], history=hist)
3069
+ return view
3070
+
3071
+ svc = {k: v for k, v in inv.items() if k not in _UI_ONLY_KEYS}
3072
+ ui = {k: v for k, v in inv.items() if k not in _SERVICE_ONLY_KEYS}
3073
+ svc["view"], ui["view"] = "service", "ui"
3074
+ if ui_prefix:
3075
+ svc = split(svc, lambda f: not f.startswith(ui_prefix))
3076
+ ui = split(ui, lambda f: f.startswith(ui_prefix))
3077
+ return svc, ui
3078
+
3079
+
1746
3080
  def bundle_state(bundle_dir):
1747
3081
  """How many concept .md files a candidate bundle already holds (the OKF
1748
3082
  generate guard: a non-empty bundle means /appweaver inventory would
@@ -1795,11 +3129,11 @@ def scope_to_lanes(files, service_root, ui_root):
1795
3129
 
1796
3130
  def cmd_scan(args):
1797
3131
  root = os.path.abspath(args.root or os.getcwd())
1798
- config_excludes = load_config_excludes(args.config)
3132
+ config_excludes = load_config_excludes(args.config) + kit_excludes(root)
1799
3133
  out = os.path.abspath(args.out) if args.out else os.path.join(root, ".appweaver", "inventory", "inventory.json")
1800
3134
  bundle_dir = os.path.abspath(args.bundle) if args.bundle else os.path.join(root, "knowledge")
1801
3135
 
1802
- files = list_files(root, config_excludes)
3136
+ files = list_files(root, config_excludes, (args.service_root, args.ui_root))
1803
3137
  churn_list, commits_scanned = churn(root, config_excludes)
1804
3138
  churn_map = {c["file"]: c["commits"] for c in churn_list}
1805
3139
 
@@ -1819,16 +3153,18 @@ def cmd_scan(args):
1819
3153
  topdirs[top] = topdirs.get(top, 0) + 1
1820
3154
  top_level_dirs = dict(sorted(topdirs.items(), key=lambda kv: kv[1], reverse=True)[:20])
1821
3155
 
1822
- def category(rx):
1823
- hits = matching(files, rx)
3156
+ def category(rx_or_hits):
3157
+ hits = (matching(files, rx_or_hits) if hasattr(rx_or_hits, "search")
3158
+ else list(rx_or_hits))
1824
3159
  return {"items": hits[:CAP], "truncated": len(hits) > CAP}
1825
3160
 
3161
+ route_files, data_files = api_and_data_files(root, files)
1826
3162
  cats = {
1827
3163
  "dependency_manifests": category(MANIFESTS_RE),
1828
3164
  "entrypoints": category(ENTRYPOINTS_RE),
1829
3165
  "api_definitions": category(API_DEFS_RE),
1830
- "route_like_files": category(ROUTES_RE),
1831
- "data_layer_files": category(DB_FILES_RE),
3166
+ "route_like_files": category(route_files),
3167
+ "data_layer_files": category(data_files),
1832
3168
  "ops_files": category(OPS_FILES_RE),
1833
3169
  "docs": category(DOCS_RE),
1834
3170
  "config_files": category(CONFIGS_RE),
@@ -1836,7 +3172,7 @@ def cmd_scan(args):
1836
3172
  }
1837
3173
  be_files, fe_files, svc_prefix, ui_prefix = scope_to_lanes(
1838
3174
  files, args.service_root, args.ui_root)
1839
- signals = aggregate_signals(be_files, churn_map)
3175
+ signals = aggregate_signals(be_files, churn_map, root)
1840
3176
  fsig = frontend_signals(fe_files, churn_map)
1841
3177
 
1842
3178
  # A DECLARED UI lane is a lane. The strength test below UI_STRONG_MIN exists to
@@ -1867,7 +3203,10 @@ def cmd_scan(args):
1867
3203
  ui_search_roots = [r.get("root", "") for r in (fsig.get("roots") or [])]
1868
3204
  else:
1869
3205
  ui_search_roots = []
1870
- ui_attr = attribute_ui_files(signals, fe_files, churn_map, ui_search_roots)
3206
+ mp = os.path.abspath(args.map) if args.map else os.path.join(
3207
+ root, ".appweaver", "inventory", "feature-map.json")
3208
+ ui_attr = attribute_ui_files(signals, fe_files, churn_map, ui_search_roots,
3209
+ _feature_aggregates(mp))
1871
3210
 
1872
3211
  present, md_count = bundle_state(bundle_dir)
1873
3212
 
@@ -1907,6 +3246,11 @@ def cmd_scan(args):
1907
3246
  os.makedirs(os.path.dirname(out), exist_ok=True)
1908
3247
  with open(out, "w", encoding="utf-8") as f:
1909
3248
  json.dump(inv, f, indent=2)
3249
+ stem = os.path.splitext(out)[0]
3250
+ out_service, out_ui = stem + ".service.json", stem + ".ui.json"
3251
+ for path, view in zip((out_service, out_ui), lane_views(inv, ui_prefix)):
3252
+ with open(path, "w", encoding="utf-8") as f:
3253
+ json.dump(view, f, indent=2)
1910
3254
 
1911
3255
  # Human summary -> STDERR (must not pollute the JSON contract on stdout).
1912
3256
  print(f"Inventory written to {out}", file=sys.stderr)
@@ -1937,6 +3281,9 @@ def cmd_scan(args):
1937
3281
  # Machine contract -> LAST stdout line (bound by the orchestrator).
1938
3282
  print(json.dumps({
1939
3283
  "inventory_json": out,
3284
+ # The same substrate cut per lane, for steps that author one lane's governance.
3285
+ "inventory_service_json": out_service,
3286
+ "inventory_ui_json": out_ui,
1940
3287
  "root": root,
1941
3288
  "file_count": inv["file_count"],
1942
3289
  "head": inv["git"]["head"],
@@ -1986,12 +3333,13 @@ PROPOSAL_FALLBACK = """# Proposed Inventory
1986
3333
 
1987
3334
  **Repository:** `{root}` · **Commit:** `{head}` · **Generated:** {date}
1988
3335
  **Pass A strategy:** `{strategy}` — {reason}
1989
- **Scanned:** {files} files, {code} source files · **Coverage:** {coverage}%
3336
+ **Scanned:** {scanned} · **Coverage:** {coverage}%
1990
3337
 
1991
3338
  The boundary map for this codebase, and yours to change. Edit it, then run
1992
3339
  `/appweaver inventory apply` to validate the edits and regenerate the machine map
1993
- onboard reads. The Aggregate column must name an aggregate the scan found;
1994
- everything else is yours.
3340
+ onboard reads. The Aggregate column must name an aggregate the scan found, and an
3341
+ Operations cell only operations it found; everything else is yours. One row per
3342
+ feature per aggregate it acts on.
1995
3343
 
1996
3344
  ## Strategy probe
1997
3345
 
@@ -2001,10 +3349,18 @@ everything else is yours.
2001
3349
 
2002
3350
  {context_table}
2003
3351
 
3352
+ ## Aggregates
3353
+
3354
+ {aggregate_table}
3355
+
2004
3356
  ## Features
2005
3357
 
2006
3358
  {feature_table}
2007
- {splits_section}{cycles_section}"""
3359
+ {review_section}{splits_section}{cycles_section}
3360
+ ## Operations
3361
+
3362
+ {operation_table}
3363
+ {unplaced_section}"""
2008
3364
 
2009
3365
  PROPOSAL_TEMPLATE_NAME = "proposed-inventory-template.md"
2010
3366
 
@@ -2060,11 +3416,44 @@ def _parse_md_tables(text, section):
2060
3416
  return rows
2061
3417
 
2062
3418
 
2063
- def _write_proposal(path, meta, contexts, features, waves, cycles, probe_rows):
3419
+ # Folders listed under Unplaced source before the rest are counted instead of shown.
3420
+ UNPLACED_ROWS = 25
3421
+
3422
+
3423
+ def _names_preview(cands, limit=4):
3424
+ """The first few candidate names, so a strategy is judged by WHAT it found and not
3425
+ only by how many: `structure` reporting 2 aggregates on a one-context codebase has
3426
+ found the context and the host project, and the count alone does not say so."""
3427
+ names = [c["name"] for c in cands]
3428
+ return ", ".join(names[:limit]) + (" …" if len(names) > limit else "")
3429
+
3430
+
3431
+ def _scanned_line(files, be, fe, svc, ui):
3432
+ """What the scan read, lane by lane. The repository's own tooling, docs and kit files
3433
+ sit outside both lanes and are read by nothing here, so a repository-wide file count
3434
+ would overstate the scan."""
3435
+ be_src = sum(1 for f in be if CODE_EXT_RE.search(f))
3436
+ if not svc and not ui:
3437
+ return f"the whole repository — {len(files)} files, {be_src} source"
3438
+ head = (f"service lane `{svc}` — {len(be)} files, {be_src} source" if svc else
3439
+ f"everything outside `{ui}` — {len(be)} files, {be_src} source")
3440
+ return head + (f" · UI lane `{ui}` — {len(fe)} files" if ui else "")
3441
+
3442
+
3443
+ def _write_proposal(path, meta, contexts, features, waves, cycles, probe_rows,
3444
+ operations=None, notes=None, candidates=None, unplaced=None):
2064
3445
  ctx_files, ctx_deps = {}, {}
2065
3446
  fmap = {f["feature"]: f for f in features}
3447
+ # A context's files are its aggregates' files, which are disjoint. Adding up its
3448
+ # features' counts instead misses the model files two features share and counts a
3449
+ # shared read once per feature that joins it.
3450
+ agg_ctx = {a: f["context"] for f in features for a in (f.get("aggregates") or [f["aggregate"]])}
3451
+ for c in candidates or []:
3452
+ ctx = agg_ctx.get(c["name"], c.get("context", ""))
3453
+ ctx_files[ctx] = ctx_files.get(ctx, 0) + c.get("raw_file_count", 0)
2066
3454
  for f in features:
2067
- ctx_files[f["context"]] = ctx_files.get(f["context"], 0) + f["files"]
3455
+ if not candidates:
3456
+ ctx_files[f["context"]] = ctx_files.get(f["context"], 0) + f["files"]
2068
3457
  for d in f["depends_on"]:
2069
3458
  dctx = fmap.get(d, {}).get("context")
2070
3459
  if dctx and dctx != f["context"]:
@@ -2075,25 +3464,91 @@ def _write_proposal(path, meta, contexts, features, waves, cycles, probe_rows):
2075
3464
  for f in features:
2076
3465
  ctx_wave[f["context"]] = min(ctx_wave.get(f["context"], 99),
2077
3466
  wave_of.get(f["feature"], 99))
3467
+ ctx_aggs = {}
3468
+ for f in features:
3469
+ ctx_aggs.setdefault(f["context"], set()).update(f.get("aggregates") or [f["aggregate"]])
2078
3470
 
2079
3471
  context_table = _md_table(
2080
- ["Order", "Bounded Context", "Depends On", "Features", "Files"],
3472
+ ["Order", "Bounded Context", "Depends On", "Aggregates", "Features", "Files"],
2081
3473
  [[i + 1, ctx, ", ".join(sorted(ctx_deps.get(ctx, set()))) or "—",
2082
- len(names), ctx_files.get(ctx, 0)]
3474
+ ", ".join(sorted(ctx_aggs.get(ctx, set()))) or "—",
3475
+ len({f["feature"] for f in features if f["context"] == ctx}), ctx_files.get(ctx, 0)]
2083
3476
  for i, (ctx, names) in enumerate(
2084
3477
  sorted(contexts.items(), key=lambda kv: (ctx_wave.get(kv[0], 99), kv[0])))
2085
3478
  if names])
2086
3479
 
3480
+ # ONE ROW PER (feature, aggregate) — the many-to-many relation written the way
3481
+ # `apply` reads it back: two rows with one Feature name are one feature acting on
3482
+ # two aggregates. The Operations cell lists what the feature does to THAT
3483
+ # aggregate, and is editable: moving a name between rows moves the operation.
3484
+ op_of = {o["name"]: o for o in (operations or [])}
2087
3485
  rows = []
2088
3486
  for f in sorted(features, key=lambda x: (wave_of.get(x["feature"], 99),
2089
3487
  x["context"], x["feature"])):
2090
- rows.append([f["feature"], f["context"], f["aggregate"],
2091
- wave_of.get(f["feature"], "?"),
2092
- ", ".join(f["depends_on"]) or "—",
2093
- f["files"], f["churn"], f["seam"] or "—"])
3488
+ aggs = f.get("aggregates") or [f["aggregate"]]
3489
+ for i, a in enumerate(aggs):
3490
+ ops_here = [n for n in f.get("operations", [])
3491
+ if (op_of.get(n, {}).get("acts_on") or [aggs[0]])[0] == a
3492
+ or (a == aggs[0] and not op_of.get(n, {}).get("acts_on"))]
3493
+ rows.append([f["feature"], f["context"], a,
3494
+ wave_of.get(f["feature"], "?"),
3495
+ (", ".join(f["depends_on"]) or "—") if i == 0 else "—",
3496
+ f["files"] if i == 0 else "", f["churn"] if i == 0 else "",
3497
+ (f["seam"] or "—") if i == 0 else "—",
3498
+ ", ".join(ops_here) or "—"])
2094
3499
  feature_table = _md_table(
2095
3500
  ["Feature", "Bounded Context", "Aggregate", "Wave", "Depends On",
2096
- "Files", "Churn", "Seam"], rows)
3501
+ "Files", "Churn", "Seam", "Operations"], rows)
3502
+
3503
+ # Read-only evidence: what the aggregates ARE and what each operation touches.
3504
+ acting = {}
3505
+ for f in features:
3506
+ for a in f.get("aggregates") or [f["aggregate"]]:
3507
+ acting.setdefault(a, []).append(f["feature"])
3508
+ agg_rows = []
3509
+ for c in sorted(candidates or [], key=lambda c: (c.get("context", ""), c["name"])):
3510
+ agg_rows.append([c["name"], next((f["context"] for f in features
3511
+ if c["name"] in (f.get("aggregates") or [f["aggregate"]])),
3512
+ c.get("context", "—")),
3513
+ f"`{c['root_file']}`" if c.get("root_file") else "—",
3514
+ c.get("raw_file_count", 0),
3515
+ len([o for o in (operations or []) if o["acts_on"][:1] == [c["name"]]]),
3516
+ ", ".join(acting.get(c["name"], [])) or "—"])
3517
+ aggregate_table = _md_table(
3518
+ ["Aggregate", "Bounded Context", "Declared in", "Files", "Operations", "Acted on by"],
3519
+ agg_rows) if agg_rows else "_No aggregates were identified._"
3520
+
3521
+ operation_table = ""
3522
+ if operations:
3523
+ operation_table = _md_table(
3524
+ ["Operation", "Kind", "Acts on", "Route", "Feature(s)"],
3525
+ [[o["name"], o["kind"], ", ".join(o["acts_on"]) or "—",
3526
+ " · ".join(f"{v} {p}".strip() for v, p in o["routes"][:2]) or "—",
3527
+ ", ".join(o.get("features", [])) or "—"]
3528
+ for o in sorted(operations, key=lambda o: (o["context"], (o["acts_on"] or [""])[0],
3529
+ o["name"]))])
3530
+ else:
3531
+ operation_table = ("_No operations were found — no feature layer of slices and no "
3532
+ "command/query handler classes. Features below are one per "
3533
+ "aggregate by default._")
3534
+
3535
+ review_section = ""
3536
+ orphaned = (notes or {}).get("orphaned") or []
3537
+ unlinked = (notes or {}).get("unlinked_ui") or []
3538
+ if orphaned or unlinked:
3539
+ lines = ["\n## Needs a decision\n"]
3540
+ if orphaned:
3541
+ lines.append("These commands act on an aggregate that has capability features, "
3542
+ "but no UI feature calls them, so they fell to a per-aggregate "
3543
+ "default. Move each into the feature it belongs to — edit the "
3544
+ "Operations cells above — or rename the default to the capability "
3545
+ "it is.\n")
3546
+ lines.append(_md_table(["Default feature", "Operation"], orphaned) + "\n")
3547
+ if unlinked:
3548
+ lines.append("These UI feature folders call no operation the scan could tie to an "
3549
+ "aggregate, so no feature was proposed for them: "
3550
+ + ", ".join(f"`features/{u}/`" for u in unlinked) + ".\n")
3551
+ review_section = "\n".join(lines)
2097
3552
 
2098
3553
  # Both trailing sections are conditional and carry their own leading blank line,
2099
3554
  # so the template can sit them side by side and neither leaves a gap when absent.
@@ -2118,13 +3573,41 @@ def _write_proposal(path, meta, contexts, features, waves, cycles, probe_rows):
2118
3573
  "merging them in the Features table.\n\n"
2119
3574
  + "\n".join(f"- {' → '.join(c)} → {c[0]}" for c in cycles) + "\n")
2120
3575
 
3576
+ # Informational: nothing here is editable, and most of it needs nothing. It is here
3577
+ # so that what the map leaves out is seen rather than inferred from a percentage.
3578
+ unplaced_section = ""
3579
+ if unplaced:
3580
+ by_dir = {}
3581
+ for f in unplaced:
3582
+ by_dir.setdefault(os.path.dirname(f) or ".", []).append(os.path.basename(f))
3583
+ dirs = sorted(by_dir)
3584
+ shown = dirs[:UNPLACED_ROWS]
3585
+ unplaced_section = (
3586
+ "\n## Unplaced source\n\n"
3587
+ f"{len(unplaced)} source file(s) in the service lane that no aggregate owns — "
3588
+ "the part of the code the Coverage figure leaves out. Hosting, dependency "
3589
+ "wiring, infrastructure and cross-cutting helpers belong here and need nothing. "
3590
+ "Look twice at a folder that plainly serves one aggregate (a convention the scan "
3591
+ "did not recognise), code nothing in the product uses (a leftover template or "
3592
+ "sample), and a whole project that is a bounded context of its own.\n\n"
3593
+ + _md_table(["Folder", "Files", "Examples"],
3594
+ [[f"`{d}/`", len(by_dir[d]),
3595
+ ", ".join(sorted(by_dir[d])[:4]) + (" …" if len(by_dir[d]) > 4 else "")]
3596
+ for d in shown])
3597
+ + (f"\n\n…and {len(dirs) - len(shown)} more folder(s)." if len(dirs) > len(shown) else "")
3598
+ + "\n")
3599
+
2121
3600
  values = dict(meta)
2122
3601
  values.update({
2123
3602
  "probe_table": _md_table(
2124
- ["Strategy", "Features", "Coverage", "Singletons", "Chosen", "Availability"],
3603
+ ["Strategy", "Aggregates", "Names", "Coverage", "Singletons", "Chosen", "Availability"],
2125
3604
  probe_rows),
3605
+ "unplaced_section": unplaced_section,
2126
3606
  "context_table": context_table,
2127
3607
  "feature_table": feature_table,
3608
+ "aggregate_table": aggregate_table,
3609
+ "operation_table": operation_table,
3610
+ "review_section": review_section,
2128
3611
  "splits_section": splits_section,
2129
3612
  "cycles_section": cycles_section,
2130
3613
  })
@@ -2181,13 +3664,14 @@ def cmd_propose(args):
2181
3664
  """Pass A + B + C over the repo: write the editable boundary map, and the
2182
3665
  machine map onboard reads until the operator edits the first one."""
2183
3666
  root = os.path.abspath(args.root or os.getcwd())
2184
- files = list_files(root, load_config_excludes(args.config))
3667
+ excludes = load_config_excludes(args.config) + kit_excludes(root)
3668
+ files = list_files(root, excludes, (args.service_root, args.ui_root))
2185
3669
  be_files, fe_files, svc_prefix, ui_prefix = scope_to_lanes(
2186
3670
  files, args.service_root, args.ui_root)
2187
- churn_list, _ = churn(root, load_config_excludes(args.config))
3671
+ churn_list, _ = churn(root, excludes)
2188
3672
  churn_map = {c["file"]: c["commits"] for c in churn_list}
2189
3673
 
2190
- results, avail, graph, gstats, ents = run_probe(root, be_files, churn_map)
3674
+ results, avail, graph, gstats, ents, model = run_probe(root, be_files, churn_map)
2191
3675
  # The menu hands back the whole option line, numbers and all, so the first
2192
3676
  # token is the strategy and the rest is what the operator was shown.
2193
3677
  wanted = (args.strategy or "").strip().split()[0].strip("`*_-").lower() if args.strategy else ""
@@ -2200,40 +3684,83 @@ def cmd_propose(args):
2200
3684
  picked = wanted
2201
3685
  else:
2202
3686
  picked = choose_strategy(results)
3687
+
3688
+ md = os.path.abspath(args.out) if args.out else os.path.join(root, "specs", "proposed-inventory.md")
3689
+ mp = os.path.abspath(args.map) if args.map else os.path.join(root, ".appweaver", "inventory", "feature-map.json")
3690
+ code_total = len([f for f in be_files if CODE_EXT_RE.search(f)])
2203
3691
  if not picked:
2204
- print(json.dumps({"ok": "false", "feature_count": 0, "error":
2205
- "no Pass A strategy produced candidates"}))
2206
- return 1
3692
+ why = "; ".join(f"{n}: {avail[n]['reason']}" for n in STRATEGY_PREFERENCE)
3693
+ return _nothing_to_propose(
3694
+ root, mp, f"No candidate aggregates were found in the service lane "
3695
+ f"'{svc_prefix or '.'}' ({len(be_files)} file(s), {code_total} of them "
3696
+ f"source) — {why}.")
2207
3697
 
2208
3698
  candidates = results[picked]["candidates"]
2209
- edges = candidate_edges(candidates, graph)
2210
- seams = seam_overlay(root, be_files, candidates, churn_map)
2211
3699
  contexts = derive_contexts(candidates, be_files)
2212
- features = propose_features(candidates, seams, edges, contexts)
3700
+ # Declared aggregates are ordered by what their types reference, not by imports: in
3701
+ # a namespace language every file of one Domain folder shares a namespace, so the
3702
+ # import graph links each root to every other and reads as one cycle.
3703
+ edges = (model["root_edges"] if picked == "domain"
3704
+ else candidate_edges(candidates, graph))
3705
+ seams = seam_overlay(root, be_files, candidates, churn_map, contexts=contexts)
3706
+ operations = (model["operations"] if picked == "domain"
3707
+ else detect_operations(root, be_files, candidates))
3708
+ ctx_of = {n: ctx for ctx, names in contexts.items() for n in names}
3709
+ for o in operations:
3710
+ o["context"] = o["context"] or ctx_of.get((o["acts_on"] or [""])[0], "")
3711
+ ufeats = ui_features(root, fe_files, ui_prefix)
3712
+ features, notes = propose_features(candidates, seams, edges, contexts, operations, ufeats)
3713
+ for o in operations:
3714
+ o["features"] = [f["feature"] for f in features if o["name"] in f["operations"]]
3715
+ if not features:
3716
+ return _nothing_to_propose(
3717
+ root, mp, f"`{picked}` found {len(candidates)} aggregate(s), but no feature "
3718
+ f"was proposed over them.")
2213
3719
  waves, cycles = dependency_waves([f["feature"] for f in features],
2214
3720
  {f["feature"]: set(f["depends_on"]) for f in features})
3721
+ # The map lists features in the order the proposal's table does — dependency order —
3722
+ # so a reader of the map that walks its list meets a creator before what needs it,
3723
+ # and `apply` over an unedited proposal writes the same list back.
3724
+ wave_of = {m: w["wave"] for w in waves for m in w["members"]}
3725
+ features.sort(key=lambda f: (wave_of.get(f["feature"], 99), f["context"], f["feature"]))
2215
3726
 
2216
- md = os.path.abspath(args.out) if args.out else os.path.join(root, "specs", "proposed-inventory.md")
2217
- mp = os.path.abspath(args.map) if args.map else os.path.join(root, ".appweaver", "inventory", "feature-map.json")
2218
- code_total = len([f for f in be_files if CODE_EXT_RE.search(f)])
3727
+ lane_files = len(set(be_files) | set(fe_files)) if (svc_prefix or ui_prefix) else len(files)
2219
3728
  meta = {"root": root, "head": git(root, "rev-parse", "--short", "HEAD") or "-",
2220
3729
  "date": datetime.date.today().isoformat(), "strategy": picked,
2221
- "reason": results[picked]["reason"], "files": len(files),
2222
- "code": code_total, "coverage": results[picked]["coverage_pct"]}
2223
- probe_rows = [[n, results[n]["count"], f"{results[n]['coverage_pct']}%",
3730
+ "reason": results[picked]["reason"], "files": lane_files,
3731
+ "code": code_total, "coverage": results[picked]["coverage_pct"],
3732
+ "scanned": _scanned_line(files, be_files, fe_files, svc_prefix, ui_prefix)}
3733
+ probe_rows = [[n, results[n]["count"], _names_preview(results[n]["candidates"]),
3734
+ f"{results[n]['coverage_pct']}%",
2224
3735
  results[n]["singletons"], "**yes**" if n == picked else "",
2225
3736
  results[n]["reason"]] if n in results else
2226
- [n, "—", "—", "—", "", f"unavailable — {avail[n]['reason']}"]
3737
+ [n, "—", "—", "—", "—", "", f"unavailable — {avail[n]['reason']}"]
2227
3738
  for n in STRATEGY_PREFERENCE]
2228
- _write_proposal(md, meta, contexts, features, waves, cycles, probe_rows)
2229
- _write_feature_map(mp, meta, contexts, features, waves, cycles, results, avail, gstats)
3739
+ covered = {f for c in candidates for f in c.get("covered_code", [])}
3740
+ unplaced = sorted(f for f in be_files if CODE_EXT_RE.search(f) and f not in covered
3741
+ and not TEST_PATH_RE.search(f))
3742
+ aggregate_detail = [
3743
+ {"name": c["name"], "context": ctx_of.get(c["name"], ""),
3744
+ "root_file": c.get("root_file", ""), "declared": picked == "domain",
3745
+ "entities": list(c.get("entities") or []),
3746
+ "operations": [o["name"] for o in operations if o["acts_on"][:1] == [c["name"]]],
3747
+ "references_by_id": sorted(edges.get(c["name"], set()))}
3748
+ for c in candidates]
3749
+ _write_proposal(md, meta, contexts, features, waves, cycles, probe_rows,
3750
+ operations, notes, [dict(c, context=ctx_of.get(c["name"], "")) for c in candidates],
3751
+ unplaced)
3752
+ _write_feature_map(mp, meta, contexts, features, waves, cycles, results, avail, gstats,
3753
+ operations=operations, aggregate_detail=aggregate_detail)
2230
3754
 
2231
3755
  print(f"Proposed inventory written to {md}", file=sys.stderr)
2232
- print(f" strategy: {picked} features: {len(features)} "
2233
- f"contexts: {len(contexts)} waves: {len(waves)}", file=sys.stderr)
3756
+ print(f" strategy: {picked} contexts: {len(contexts)} aggregates: {len(candidates)} "
3757
+ f"features: {len(features)} operations: {len(operations)} waves: {len(waves)}",
3758
+ file=sys.stderr)
2234
3759
  print(json.dumps({
2235
3760
  "ok": "true", "proposal_md": md, "feature_map": mp, "root": root,
2236
3761
  "strategy": picked, "feature_count": len(features),
3762
+ "aggregate_count": len(candidates), "operation_count": len(operations),
3763
+ "orphaned_count": len(notes["orphaned"]),
2237
3764
  "context_count": len(contexts), "wave_count": len(waves),
2238
3765
  "cycle_count": len(cycles),
2239
3766
  "contexts_ordered": ", ".join(_ordered_contexts(contexts, features, waves)),
@@ -2242,9 +3769,11 @@ def cmd_propose(args):
2242
3769
  # One option line per AVAILABLE strategy, each carrying its own numbers so
2243
3770
  # the pick is made against evidence. A strategy that would return nothing
2244
3771
  # is never offered — an empty pick is the zero-candidate stop by choice.
3772
+ "unplaced_count": len(unplaced),
2245
3773
  "strategy_options": [
2246
- f"{n} — {results[n]['count']} features, {results[n]['coverage_pct']}% coverage, "
2247
- f"{results[n]['singletons']} singleton(s)"
3774
+ f"{n} — {results[n]['count']} aggregates ({_names_preview(results[n]['candidates'])}), "
3775
+ f"{results[n]['coverage_pct']}% coverage, {results[n]['singletons']} singleton(s)"
3776
+ + (" [declared by the code]" if n == "domain" else "")
2248
3777
  + (" [owns/references available]" if n == "persistence" else "")
2249
3778
  for n in STRATEGY_PREFERENCE if n in results
2250
3779
  ],
@@ -2252,6 +3781,24 @@ def cmd_propose(args):
2252
3781
  return 0
2253
3782
 
2254
3783
 
3784
+ def _nothing_to_propose(root, mp, message):
3785
+ """A scan with nothing to propose writes nothing. feature-map.json is what onboard
3786
+ reads, so an empty one would be onboarded as a project with no contexts, and a map
3787
+ from an earlier run would be overwritten by it — that map is left as it was.
3788
+
3789
+ Exits 0: finding nothing is a result the workflow's gate reports, not a failure of
3790
+ the script, so the propose step needs no `on_error` to reach that gate."""
3791
+ kept = os.path.isfile(mp)
3792
+ message += (" Nothing was written" + (f"; the map from an earlier run at {mp} is unchanged."
3793
+ if kept else "."))
3794
+ print(message, file=sys.stderr)
3795
+ print(json.dumps({"ok": "false", "root": root, "feature_map": "",
3796
+ "feature_count": 0, "aggregate_count": 0,
3797
+ "existing_map_kept": "true" if kept else "false",
3798
+ "error": message}))
3799
+ return 0
3800
+
3801
+
2255
3802
  def _ordered_contexts(contexts, features, waves):
2256
3803
  wave_of = {m: w["wave"] for w in waves for m in w["members"]}
2257
3804
  first = {}
@@ -2262,11 +3809,16 @@ def _ordered_contexts(contexts, features, waves):
2262
3809
 
2263
3810
 
2264
3811
  def _write_feature_map(path, meta, contexts, features, waves, cycles,
2265
- results=None, avail=None, gstats=None, aggregates=None):
3812
+ results=None, avail=None, gstats=None, aggregates=None,
3813
+ operations=None, aggregate_detail=None, prior=None):
2266
3814
  """The machine contract onboard reads. Regenerated from the markdown on every
2267
3815
  apply, so the document stays the thing a person edits and this stays derived.
2268
3816
  `aggregates` is the registry of aggregates the scan found — what the Aggregate
2269
- column may name — and outlives any feature that stops listing one."""
3817
+ column may name — and outlives any feature that stops listing one.
3818
+
3819
+ `operations` and `aggregate_detail` are the model below the features: which
3820
+ operations exist, what each acts on, and which context declares each aggregate.
3821
+ Every key a reader already relies on keeps its shape — these two are additions."""
2270
3822
  registry = aggregates if aggregates is not None else sorted(
2271
3823
  {a for f in features for a in (f.get("aggregates") or [f.get("aggregate")]) if a})
2272
3824
  payload = {
@@ -2278,6 +3830,8 @@ def _write_feature_map(path, meta, contexts, features, waves, cycles,
2278
3830
  "contexts": [
2279
3831
  {"name": c, "order": i,
2280
3832
  "features": [f["feature"] for f in features if f["context"] == c],
3833
+ "aggregates": sorted({a for f in features if f["context"] == c
3834
+ for a in (f.get("aggregates") or [f.get("aggregate")]) if a}),
2281
3835
  "onboarded": False}
2282
3836
  for i, c in enumerate(_ordered_contexts(contexts, features, waves))
2283
3837
  ],
@@ -2286,6 +3840,18 @@ def _write_feature_map(path, meta, contexts, features, waves, cycles,
2286
3840
  "waves": waves,
2287
3841
  "cycles": cycles,
2288
3842
  }
3843
+ if operations is not None:
3844
+ payload["operations"] = [
3845
+ {"name": o["name"], "context": o.get("context", ""), "kind": o.get("kind", ""),
3846
+ "acts_on": o.get("acts_on", []),
3847
+ "routes": [f"{v} {p}".strip() for v, p in o.get("routes", [])]
3848
+ if o.get("routes") and isinstance(o["routes"][0], (list, tuple))
3849
+ else list(o.get("routes", [])),
3850
+ "files": len(o["files"]) if isinstance(o.get("files"), list) else o.get("files", 0),
3851
+ "features": [f["feature"] for f in features if o["name"] in f.get("operations", [])]}
3852
+ for o in operations]
3853
+ if aggregate_detail is not None:
3854
+ payload["aggregate_detail"] = aggregate_detail
2289
3855
  if results is not None:
2290
3856
  payload["probe"] = {
2291
3857
  n: {"count": results[n]["count"], "coverage_pct": results[n]["coverage_pct"],
@@ -2294,6 +3860,12 @@ def _write_feature_map(path, meta, contexts, features, waves, cycles,
2294
3860
  for n, a in (avail or {}).items():
2295
3861
  payload["probe"].setdefault(n, {"available": False, "reason": a["reason"]})
2296
3862
  payload["reference_graph"] = gstats or {}
3863
+ else:
3864
+ # A rewrite from the edited proposal keeps the scan's own record of how the
3865
+ # boundaries were found; the edit changed the boundaries, not the evidence.
3866
+ for k in ("probe", "reference_graph"):
3867
+ if (prior or {}).get(k) is not None:
3868
+ payload[k] = prior[k]
2297
3869
  os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
2298
3870
  with open(path, "w", encoding="utf-8") as fh:
2299
3871
  json.dump(payload, fh, indent=2)
@@ -2321,6 +3893,9 @@ def cmd_apply(args):
2321
3893
  known_aggs = set(prior.get("aggregates") or [])
2322
3894
  for f in prior.get("features", []):
2323
3895
  known_aggs.update(a for a in [f.get("aggregate")] + list(f.get("aggregates") or []) if a)
3896
+ # The operations the SCAN found — what an Operations cell may name. A map written
3897
+ # before operations were recorded has none, and its cells are then not checked.
3898
+ known_ops = {o["name"] for o in prior.get("operations") or []}
2324
3899
 
2325
3900
  text = open(md, encoding="utf-8").read()
2326
3901
  rows = _parse_md_tables(text, "Features")
@@ -2344,6 +3919,17 @@ def cmd_apply(args):
2344
3919
  problems.append(f"feature '{name}' names aggregate '{agg}', which the scan "
2345
3920
  f"did not find — use one of: {', '.join(sorted(known_aggs)[:8])}…")
2346
3921
  continue
3922
+ # The Operations cell (9th) is optional: a file written before it existed has no
3923
+ # such column, and then the operations carry over from the previous map.
3924
+ ops = None
3925
+ if len(cells) > 8:
3926
+ ops = [o.strip() for o in re.split(r"[,;]", cells[8])
3927
+ if o.strip() and o.strip() != "—"]
3928
+ unknown = [o for o in ops if known_ops and o not in known_ops]
3929
+ if unknown:
3930
+ problems.append(f"feature '{name}' lists operation(s) {', '.join(unknown)}, "
3931
+ f"which the scan did not find")
3932
+ continue
2347
3933
  base = prior_by_feature.get(name) or prior_by_agg.get(agg) or {}
2348
3934
  if name in seen:
2349
3935
  # Same feature twice is ONE feature over several aggregates: combine them, the
@@ -2358,6 +3944,9 @@ def cmd_apply(args):
2358
3944
  if agg not in prev["aggregates"]:
2359
3945
  prev["aggregates"].append(agg)
2360
3946
  prev["depends_on"] += [d for d in deps if d not in prev["depends_on"]]
3947
+ if ops is not None:
3948
+ prev["operations"] = prev.get("operations", []) + [
3949
+ o for o in ops if o not in prev.get("operations", [])]
2361
3950
  continue
2362
3951
  # The aggregates are what THIS file says, never what the previous map said: a row
2363
3952
  # removed here has to leave the feature, and a base borrowed from another feature
@@ -2365,6 +3954,12 @@ def cmd_apply(args):
2365
3954
  entry = dict(base)
2366
3955
  entry.update({"feature": name, "context": ctx, "aggregate": agg,
2367
3956
  "aggregates": [agg], "depends_on": deps})
3957
+ if ops is not None:
3958
+ entry["operations"] = ops
3959
+ elif base is not prior_by_feature.get(name):
3960
+ # Borrowed from another feature over the same aggregate: its operations are
3961
+ # that feature's, not this one's.
3962
+ entry["operations"] = []
2368
3963
  entry.setdefault("files", 0)
2369
3964
  entry.setdefault("churn", 0)
2370
3965
  entry.setdefault("seam", "")
@@ -2400,13 +3995,20 @@ def cmd_apply(args):
2400
3995
  meta = {"date": datetime.date.today().isoformat(), "root": root,
2401
3996
  "head": prior.get("commit", "-"), "strategy": prior.get("strategy", "edited")}
2402
3997
  _write_feature_map(mp, meta, contexts, features, waves, cycles,
2403
- aggregates=sorted(known_aggs) or None)
3998
+ aggregates=sorted(known_aggs) or None,
3999
+ operations=prior.get("operations"),
4000
+ aggregate_detail=prior.get("aggregate_detail"), prior=prior)
2404
4001
 
2405
4002
  print(f"Applied {md} -> {mp}", file=sys.stderr)
2406
4003
  print(f" {len(features)} feature(s) across {len(contexts)} context(s), "
2407
4004
  f"{len(waves)} wave(s){', CYCLES PRESENT' if cycles else ''}", file=sys.stderr)
4005
+ listed = {o for f in features for o in f.get("operations") or []}
2408
4006
  print(json.dumps({
2409
4007
  "ok": "true", "feature_map": mp, "feature_count": len(features),
4008
+ "aggregate_count": len(known_aggs), "operation_count": len(known_ops),
4009
+ # Scanned operations no feature lists any more: an edit that dropped one from
4010
+ # every Operations cell has left it with nowhere to be reversed into.
4011
+ "orphaned_count": len(known_ops - listed),
2410
4012
  "context_count": len(contexts), "wave_count": len(waves),
2411
4013
  "cycle_count": len(cycles),
2412
4014
  "contexts_ordered": ", ".join(_ordered_contexts(contexts, features, waves)),
@@ -2440,7 +4042,7 @@ def cmd_context_state(args):
2440
4042
 
2441
4043
  contexts = m.get("contexts", [])
2442
4044
  ordered = [c["name"] for c in contexts]
2443
- want = (args.context or "").strip()
4045
+ want, concerns = split_concerns(args.context)
2444
4046
  match = next((c for c in contexts if c["name"].lower() == want.lower()), None)
2445
4047
  if want and not match:
2446
4048
  print(json.dumps({"ok": "false", "map_present": "true", "context_known": "false",
@@ -2458,12 +4060,7 @@ def cmd_context_state(args):
2458
4060
  def key(n):
2459
4061
  return re.sub(r"[^a-z0-9]", "", (n or "").lower())
2460
4062
 
2461
- present = set()
2462
- agg_dir = os.path.join(bundle, "aggregates")
2463
- if os.path.isdir(agg_dir):
2464
- for fn in os.listdir(agg_dir):
2465
- if fn.endswith(".md") and fn != "index.md":
2466
- present.add(key(os.path.splitext(fn)[0]))
4063
+ present = aggregate_concept_keys(bundle, key)
2467
4064
 
2468
4065
  # A context is onboarded when every AGGREGATE its features act on has a concept. The
2469
4066
  # bundle is the project's knowledge and a concept is an aggregate: a context whose map
@@ -2492,6 +4089,7 @@ def cmd_context_state(args):
2492
4089
  if not target:
2493
4090
  print(json.dumps({"ok": "false", "map_present": "true", "context_known": "true",
2494
4091
  "all_onboarded": "true", "contexts_ordered": ", ".join(ordered),
4092
+ "onboarded_contexts": ", ".join(onboarded),
2495
4093
  "error": "every bounded context in the map is already onboarded"}))
2496
4094
  return 1
2497
4095
 
@@ -2559,10 +4157,62 @@ def cmd_context_state(args):
2559
4157
  "context_count": len(contexts),
2560
4158
  "feature_map": mp,
2561
4159
  "strategy": m.get("strategy", ""),
4160
+ "all_onboarded": "false",
4161
+ # The operator's "concerns:" clause, split off the context name, and the one
4162
+ # line the rule-set authors are handed — never the raw input.
4163
+ "concerns": ", ".join(concerns),
4164
+ "concerns_line": (f"The operator asked you to look for these concerns: "
4165
+ f"{', '.join(concerns)}." if concerns else
4166
+ "The operator listed no concerns to look for."),
2562
4167
  }))
2563
4168
  return 0
2564
4169
 
2565
4170
 
4171
+ # A `concerns:` clause after the context name: "Billing; concerns: auditing, concurrency".
4172
+ CONCERNS_CLAUSE_RE = re.compile(r"[;,]?\s*\bconcerns?\s*:\s*(.*)$", re.IGNORECASE | re.S)
4173
+
4174
+
4175
+ def split_concerns(text):
4176
+ """(context, [concerns]) from onboard's input. The concerns clause is a search hint
4177
+ for the rule-set authors; the context is what the map is searched for, and leaving
4178
+ the clause on it made every such input an unknown context."""
4179
+ s = (text or "").strip()
4180
+ m = CONCERNS_CLAUSE_RE.search(s)
4181
+ if not m:
4182
+ return s.strip(" ;,"), []
4183
+ concerns = [c.strip(" .") for c in re.split(r"[,;]", m.group(1)) if c.strip(" .")]
4184
+ return s[:m.start()].strip(" ;,"), concerns
4185
+
4186
+
4187
+ def aggregate_concept_keys(bundle, key):
4188
+ """The keys of every aggregate concept in the bundle, wherever it sits.
4189
+
4190
+ A concept is found the way every other reader finds one (aw-xref's resolver,
4191
+ `concept`, `list-aggregates`): by walking the whole bundle for a `type` naming an
4192
+ aggregate, keyed on its title and its file name. onboard writes concepts under
4193
+ aggregates/, while implement and adopt create them at <slug>/<slug>.md; reading one
4194
+ folder only reported a context whose concepts came the other way as never
4195
+ onboarded. A file under aggregates/ with no readable type still counts, as it did
4196
+ before types were read."""
4197
+ present = set()
4198
+ if not os.path.isdir(bundle):
4199
+ return present
4200
+ for dirpath, dirnames, filenames in os.walk(bundle):
4201
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
4202
+ in_aggs = os.path.basename(dirpath) == "aggregates"
4203
+ for fn in filenames:
4204
+ if not fn.endswith(".md") or fn in ("index.md", "log.md"):
4205
+ continue
4206
+ fm = _read_frontmatter(os.path.join(dirpath, fn))
4207
+ typ = fm.get("type") if isinstance(fm.get("type"), str) else ""
4208
+ if "aggregate" in typ.lower() or (in_aggs and not typ):
4209
+ present.add(key(os.path.splitext(fn)[0]))
4210
+ title = fm.get("title")
4211
+ if isinstance(title, str) and title.strip():
4212
+ present.add(key(title))
4213
+ return present
4214
+
4215
+
2566
4216
  def cmd_scaffold_context(args):
2567
4217
  """Create the SPECS directories one bounded context's features will live in:
2568
4218
  specs/<Context>/<Feature>/ for every feature the map assigns to it.
@@ -2882,6 +4532,9 @@ def main():
2882
4532
  help="The UI lane's declared source path. Frontend signals are derived "
2883
4533
  "from this subtree only, and it is excluded from the backend's — "
2884
4534
  "so neither lane's substrate carries the other's files.")
4535
+ ps.add_argument("--map", help="Machine map whose features attribute UI feature folders "
4536
+ "(default: <root>/.appweaver/inventory/feature-map.json, "
4537
+ "when present).")
2885
4538
  ps.set_defaults(func=cmd_scan)
2886
4539
 
2887
4540
  pp = sub.add_parser("propose", help="Pass A+B+C: write the editable Feature / Bounded Context map.")