aw-spec-cli 0.1.31 → 0.1.32

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.
@@ -1,19 +1,17 @@
1
1
  #!/usr/bin/env python3
2
2
  """aw-inventory.py — brownfield repository inventory for the aw-spec pipeline.
3
3
 
4
- The deterministic, model-free substrate for the Brownfield-Pipeline B0
5
- (Inventory) phase (see Brownfield-Pipeline.md).
6
- It scans an existing ("brownfield") codebase and emits a factual JSON substrate
7
- that the /appweaver inventory workflow's enrichment agent turns into an
8
- aggregate-organized OKF v0.1 knowledge bundle. The agent uses this so it never
9
- has to guess the repo layout.
10
-
11
- Ported from Reference/okf-inventory.sh (which was ~90% embedded python already)
12
- into a single cross-platform python core, with two brownfield additions:
4
+ The deterministic, model-free reader behind /appweaver inventory and the scan
5
+ /appweaver onboard runs (see Brownfield-Pipeline.md). It reads an existing
6
+ ("brownfield") codebase and reports what the code shows: the boundary map inventory
7
+ proposes and applies, and the JSON substrate onboard builds a bounded context's OKF
8
+ knowledge from. It never authors specs, governance or knowledge, and never modifies
9
+ source. What the substrate carries:
13
10
 
14
11
  * aggregate_signals — candidate DDD aggregate roots derived from domain /
15
- entity / model / route file clustering, ranked by churn + evidence. These
16
- are SIGNALS, not decisions: the workflow's agent draws the real aggregate
12
+ entity / model / route file clustering, ranked by churn + evidence; on a
13
+ project with a UI lane and no service lane, the API resources the UI calls
14
+ (ui_reading), as the map was proposed from. These are SIGNALS, not decisions: the workflow's agent draws the real aggregate
17
15
  boundaries. This is the "inventory following aggregate root" the pipeline
18
16
  needs — one aggregate ≈ one candidate specs/<Feature>/ vertical slice.
19
17
  * frontend_signals — candidate FRONTEND lane root folders derived from component /
@@ -36,23 +34,46 @@ orchestrator's execute_script can bind it); human-readable summaries go to STDER
36
34
  so they never corrupt the JSON contract:
37
35
 
38
36
  scan [--root <dir>] [--out <path>] [--bundle <dir>] [--config <yml>]
39
- -> writes the full substrate JSON to --out; prints a compact summary
40
- object {inventory_json, file_count, head, branch, aggregate_count,
37
+ [--service-root <p>] [--ui-root <p>] [--map <json>]
38
+ -> writes the full substrate JSON to --out, each lane's signals read from its
39
+ declared lane root folder only and the aggregates read with the strategy
40
+ the map was built from; prints a compact summary object
41
+ {inventory_json, file_count, head, branch, aggregate_count,
41
42
  frontend_present, frontend_separable, frontend_root_count,
42
43
  frontend_roots, bundle_dir, bundle_present, bundle_md_count}
44
+ propose [--root <dir>] [--config <yml>] [--service-root <p>] [--ui-root <p>]
45
+ [--strategy <request>] [--out <md>] [--map <json>]
46
+ -> Pass A+B+C: writes the editable specs/proposed-inventory.md and the
47
+ machine map onboard reads; prints {strategy, strategy_options,
48
+ forced_option, feature/context/aggregate/operation/wave counts,
49
+ contexts_ordered, coverage_pct, …}. --strategy is the operator's request:
50
+ its first word forces a strategy when it names one
51
+ apply [--root <dir>] [--proposal <md>] [--map <json>]
52
+ -> validates the operator's edits to the proposal and regenerates the
53
+ machine map; prints its counts and contexts_ordered, or the rows it
54
+ cannot resolve in {problems}
55
+ context-state [--root <dir>] [--context <Name>] [--map <json>] [--bundle <dir>]
56
+ -> what onboard needs before touching one bounded context (default: the
57
+ first not yet onboarded): its features and aggregates, whether it is
58
+ onboarded already, and the contexts it waits on {blocked_by, …}
59
+ scaffold-context --context <Name> [--root <dir>] [--map <json>]
60
+ -> creates specs/<Context>/<Feature>/ for every feature the map assigns to
61
+ that context — directories only, no documents
43
62
  concept --bundle <dir> --aggregate <name>
44
63
  -> resolves ONE aggregate's OKF concept + source_files (the code that
45
64
  IS the slice) — the index adopt reverse-engineers a feature from
46
- list-aggregates --bundle <dir>
47
- -> enumerates the bundle's Aggregate concepts (the brownfield feature
48
- map) → {aggregates[{name,title,slug,concept_path,...}], count} — the
49
- selectable feature list select-feature offers in a brownfield repo
65
+ list-aggregates --bundle <dir> [--context <Name>] [--root <dir>] [--map <json>]
66
+ -> enumerates the bundle's Aggregate concepts, each offered as the
67
+ features the map puts on it → {aggregates[{name,title,slug,
68
+ concept_path,...}], count} — the selectable feature list
69
+ select-feature offers in a brownfield repo
50
70
  validate --bundle <dir> [--repo-root <dir>]
51
71
  -> runs the OKF v0.1 conformance checker (validate_okf.py, beside
52
72
  this file) and re-emits {concepts, errors[], warnings[], result,
53
73
  conformant}
54
74
 
55
- Usage: aw-inventory.py <scan|concept|list-aggregates|validate> [options]
75
+ Usage: aw-inventory.py <scan|propose|apply|context-state|scaffold-context|concept|
76
+ list-aggregates|validate> [options]
56
77
  """
57
78
 
58
79
  import argparse
@@ -347,15 +368,22 @@ def frontend_signals(files, churn_map):
347
368
 
348
369
  # Directory / filename hints that a file participates in a domain aggregate.
349
370
  DOMAIN_DIR_RE = re.compile(r"(^|/)(domain|aggregates?|entit(y|ies)|models?)(/|$)", re.IGNORECASE)
350
- # Layer folders inside a vertical slice. The PARENT of any of these is an
371
+ # Layer folders inside a module. The PARENT of any of these is an
351
372
  # aggregate-module candidate (e.g. .../MedicinalProducts/Domain/... -> the
352
- # aggregate module is MedicinalProducts). This mirrors the aw-spec
353
- # MinimalMonolith vertical-slice layout and is the strongest, lowest-noise
354
- # aggregate signal — far better than clustering on file basenames.
373
+ # aggregate module is MedicinalProducts). The words are each stack's own names
374
+ # for a layer: the vertical-slice layout the kit generates, n-tier and clean
375
+ # architecture, Go's http/transport/delivery, hexagonal adapters and ports
376
+ # (inbound/outbound), and the rest/grpc/graphql/routes/routers/schemas folders
377
+ # an API keeps. Words that are as often a project, UI or container folder (web,
378
+ # views, services, resources) are not layers here: they would split a module.
379
+ # This is the strongest, lowest-noise aggregate signal — far better than
380
+ # clustering on file basenames.
355
381
  LAYER_DIR_RE = re.compile(
356
382
  r"^(domain|features?|application|use[-_]?cases?|handlers?|persistence|"
357
383
  r"infrastructure|infra|data|repositor(y|ies)|api|endpoints?|controllers?|"
358
- r"aggregates?|entit(y|ies)|models?|dtos?|contracts?)$",
384
+ r"aggregates?|entit(y|ies)|models?|dtos?|contracts?|"
385
+ r"http|transport|delivery|rest|grpc|graphql|routes|routers?|schemas|"
386
+ r"adapters?|ports?|inbound|outbound)$",
359
387
  re.IGNORECASE,
360
388
  )
361
389
  # Which bucket a layer folder maps to, for grouping a candidate's files.
@@ -371,7 +399,16 @@ LAYER_KIND = {
371
399
  "api": "api", "endpoint": "api", "endpoints": "api",
372
400
  "controller": "api", "controllers": "api",
373
401
  "dto": "api", "dtos": "api", "contract": "api", "contracts": "api",
402
+ "http": "api", "transport": "api", "delivery": "api", "rest": "api",
403
+ "grpc": "api", "graphql": "api", "routes": "api", "router": "api",
404
+ "routers": "api", "schemas": "api", "inbound": "api",
405
+ "adapter": "infra", "adapters": "infra", "outbound": "infra",
406
+ "port": "feature", "ports": "feature",
374
407
  }
408
+ # Hexagonal edge folders. They bound a module like any layer, but say only that a
409
+ # file sits on the edge, not what it does: the next layer folder below one names the
410
+ # file's bucket (adapter/in/controller -> api, adapter/out/persistence -> infra).
411
+ EDGE_LAYER_NAMES = frozenset(("adapter", "adapters", "port", "ports"))
375
412
  # Type-role suffixes stripped to recover the underlying aggregate/entity noun
376
413
  # (used only for the secondary within-aggregate "entities" hint, and for the
377
414
  # flat-repo fallback when there are no layer folders).
@@ -381,11 +418,12 @@ ROLE_SUFFIX_RE = re.compile(
381
418
  r"Factory|Spec|Specification|View)$"
382
419
  )
383
420
  # Verb prefixes on CQRS command/use-case names, stripped so RegisterMedicinal…
384
- # resolves toward its aggregate rather than becoming its own candidate.
421
+ # resolves toward its aggregate rather than becoming its own candidate. Only a whole
422
+ # PascalCase word is a verb: Address, Settings and Listing are nouns.
385
423
  VERB_PREFIX_RE = re.compile(
386
424
  r"^(Register|Create|Add|Update|Modify|Change|Delete|Remove|Get|List|Find|"
387
425
  r"Fetch|Set|Assign|Approve|Reject|Cancel|Submit|Publish|Archive|Restore|"
388
- r"Enable|Disable|Activate|Deactivate|Send|Generate)"
426
+ r"Enable|Disable|Activate|Deactivate|Send|Generate)(?=[A-Z])"
389
427
  )
390
428
  CODE_EXT_RE = re.compile(r"\.(py|js|ts|go|rs|rb|java|cs|php|kt|scala|sql|prisma)$", re.IGNORECASE)
391
429
 
@@ -491,9 +529,10 @@ def kit_excludes(root):
491
529
 
492
530
  `guardrails/` and `foundations/` are ordinary names a project may use for its own
493
531
  code, so each is excluded only when it has the kit's shape: `guardrails/` holding
494
- one of the kit's rule tiers, `foundations/` holding a scaffold pack (a directory with
495
- a `*.pack.txt`). The agent directories and agent instruction files are excluded
496
- whenever present."""
532
+ one of the kit's rule tiers, `foundations/` holding what the kit puts there — a
533
+ scaffold pack (`*.pack.txt`), a spec foundation (`*.spec.txt`), a component runbook
534
+ (`<ecosystem>/components/<Name>/integration.md`) or the spec template at its root.
535
+ The agent directories and agent instruction files are excluded whenever present."""
497
536
  names = [d for d in KIT_AGENT_DIRS if os.path.isdir(os.path.join(root, d))]
498
537
  g = os.path.join(root, "guardrails")
499
538
  if any(os.path.isdir(os.path.join(g, t)) for t in ("project-specific", "technology", "domain")):
@@ -501,9 +540,12 @@ def kit_excludes(root):
501
540
  f = os.path.join(root, "foundations")
502
541
  if os.path.isdir(f):
503
542
  for dirpath, dirnames, filenames in os.walk(f):
504
- if dirpath[len(f):].count(os.sep) >= 3:
543
+ rel = os.path.relpath(dirpath, f).split(os.sep)
544
+ if len(rel) >= 3:
505
545
  dirnames[:] = []
506
- if any(n.endswith(".pack.txt") for n in filenames):
546
+ if (any(n.endswith((".pack.txt", ".spec.txt")) for n in filenames)
547
+ or (dirpath == f and "foundation.spec.template.txt" in filenames)
548
+ or (len(rel) == 3 and rel[1] == "components" and "integration.md" in filenames)):
507
549
  names.append("foundations")
508
550
  break
509
551
  rxs = [re.compile("^" + re.escape(n) + "/") for n in names]
@@ -658,17 +700,33 @@ def matching(files, rx):
658
700
  return [f for f in files if rx.search(f)]
659
701
 
660
702
 
703
+ # File names that say what a file is to the program, not which noun it serves: an entry
704
+ # point or a module's wiring. Compared lower-case, before any suffix is stripped. Each is
705
+ # also a noun some domain uses (a university's Program, a loan Application), so a file in
706
+ # a domain layer folder keeps its name.
707
+ ENTRY_POINT_NAMES = frozenset((
708
+ "main", "__main__", "index", "app", "application", "server", "program", "startup",
709
+ "bootstrap", "init", "__init__", "mod", "lib", "setup", "wsgi", "asgi", "manage", "urls",
710
+ ))
711
+
712
+
661
713
  def _entity_noun(path):
662
- """Recover the domain noun from a file basename (role suffixes + a CQRS verb
663
- prefix stripped): RegisterMedicinalProductCommandHandler -> MedicinalProduct.
664
- Signal-grade; returns None for non-nouny names."""
665
- base = os.path.splitext(os.path.basename(path))[0]
666
- base = re.split(r"[._-]", base)[0]
714
+ """Recover the domain noun from a file basename, in PascalCase and singular: the name
715
+ up to its first dot, its snake_case or kebab-case words joined, then an interface's
716
+ leading I, role suffixes and a CQRS verb prefix stripped. medication_order.py and
717
+ medication-orders.routes.ts give MedicationOrder, IPatientRepository.cs gives Patient,
718
+ RegisterMedicinalProductCommandHandler.cs gives MedicinalProduct.
719
+ Signal-grade; returns None for an entry point outside a domain layer and for
720
+ non-nouny names."""
721
+ stem = os.path.splitext(os.path.basename(path))[0].split(".")[0]
722
+ if stem.lower() in ENTRY_POINT_NAMES and _bucket_of(path.split("/")[:-1], "") != "domain":
723
+ return None
724
+ base = re.sub(r"^I(?=[A-Z][a-z])", "", _pascal(stem))
667
725
  prev = None
668
726
  while prev != base:
669
727
  prev = base
670
728
  base = ROLE_SUFFIX_RE.sub("", base)
671
- base = VERB_PREFIX_RE.sub("", base)
729
+ base = singularize(VERB_PREFIX_RE.sub("", base))
672
730
  return base if base and base[:1].isalpha() and len(base) > 2 else None
673
731
 
674
732
 
@@ -704,6 +762,10 @@ DOTTED_SEG_RE = re.compile(r"^[A-Za-z_]\w*(\.[A-Za-z_]\w*)+$")
704
762
  # Organisation/product prefixes that lead a dotted project name without naming
705
763
  # a module. Dropped from the left before the module segment is read.
706
764
  VENDOR_PREFIX_RE = re.compile(r"^(com|org|net|io|co|inc|ltd)$", re.IGNORECASE)
765
+ # Source folders whose code sits in a package path that starts with a reverse domain
766
+ # (src/main/java/com/acme/clinic/). Below one, a vendor folder and the folders that only
767
+ # lead from it to the base package name the organisation and the product, never a module.
768
+ JVM_SOURCE_DIRS = frozenset(("java", "kotlin", "scala", "groovy"))
707
769
 
708
770
 
709
771
  # Folders that hold ONE vertical slice per child. These are also members of
@@ -743,6 +805,21 @@ PROJECT_ROLE_RE = re.compile(
743
805
  )
744
806
 
745
807
 
808
+ # Folders too generic to bound a module alone — web/ is as often a project or a UI, every
809
+ # project has a services/ — that ARE a layer beside a domain layer: care/{domain,web} is
810
+ # one module's two layers, and io/clinic/{domain,web} one application's.
811
+ PAIRED_LAYER_KIND = {"web": "api", "service": "feature", "services": "feature"}
812
+
813
+
814
+ def _paired_layer(parent, seg, children):
815
+ """The layer bucket a generic folder names where a sibling folder is a domain layer,
816
+ else None. `parent` is the folder holding `seg`."""
817
+ kind = PAIRED_LAYER_KIND.get((seg or "").lower())
818
+ if kind and any(_layer_of(c) == "domain" for c in children.get(parent, ())):
819
+ return kind
820
+ return None
821
+
822
+
746
823
  def _layer_of(seg, soft=False):
747
824
  """The layer bucket a path segment names, or None when it names no layer.
748
825
 
@@ -754,6 +831,18 @@ def _layer_of(seg, soft=False):
754
831
  return SOFT_LAYER_KIND.get(s) if soft else None
755
832
 
756
833
 
834
+ def _bucket_of(dirs, default, soft=True):
835
+ """The bucket of the first layer folder in `dirs`, else `default`. A hexagonal edge
836
+ folder defers to the next layer folder below it."""
837
+ for i, p in enumerate(dirs):
838
+ kind = _layer_of(p, soft=soft)
839
+ if kind:
840
+ if p.lower() in EDGE_LAYER_NAMES:
841
+ return _bucket_of(dirs[i + 1:], kind, soft)
842
+ return kind
843
+ return default
844
+
845
+
757
846
  def _project_roots(files):
758
847
  """Directories a build manifest calls a project. This is the one module
759
848
  boundary in a repo that is EVIDENCED rather than inferred from a folder
@@ -832,7 +921,8 @@ def _child_dirs(files):
832
921
 
833
922
  def _holds_layers(path, children):
834
923
  """Whether a directory holds a domain layer plus at least one other layer folder."""
835
- kinds = {_layer_of(c) for c in children.get(path, set())} - {None}
924
+ kinds = {_layer_of(c) or _paired_layer(path, c, children)
925
+ for c in children.get(path, set())} - {None}
836
926
  return "domain" in kinds and len(kinds) >= 2
837
927
 
838
928
 
@@ -885,9 +975,56 @@ def _is_module_layer(dirs, i, children):
885
975
  for g in children.get(f"{here}/{c}", set()))
886
976
 
887
977
 
978
+ def _noun_words(path):
979
+ """A file name's words, each singular: AppointmentsApiController -> Appointment, Api,
980
+ Controller. The name is read up to its first dot, an interface's leading I dropped."""
981
+ stem = os.path.basename(path).split(".")[0]
982
+ name = re.sub(r"^I(?=[A-Z][a-z])", "", _pascal(stem))
983
+ return [singularize(w) for w in re.findall(r"[A-Z][a-z0-9]*|[A-Z]+(?![a-z])", name)]
984
+
985
+
986
+ def _anchored_noun(path, domain_nouns, fallback=True):
987
+ """The domain noun a file serves in a tree read by noun: the longest noun of the
988
+ domain layer whose words run together in the file's name (RecordEncounterUseCase,
989
+ EncounterJpaRepository -> Encounter), else — with `fallback` — the file's own noun."""
990
+ words = _noun_words(path)
991
+ hits = [noun for noun, nw in domain_nouns.items()
992
+ if any(words[i:i + len(nw)] == nw for i in range(len(words) - len(nw) + 1))]
993
+ if hits:
994
+ return max(hits, key=lambda n: (len(domain_nouns[n]), len(n), n))
995
+ return _entity_noun(path) if fallback else None
996
+
997
+
998
+ def _app_roots(files, proj_roots):
999
+ """Folders that hold an application: its entry point, or the build manifest of the
1000
+ lane's only project. A manifest beside others marks a component, which is a module."""
1001
+ entry = {f.rsplit("/", 1)[0] for f in files if "/" in f
1002
+ and os.path.basename(f).split(".")[0].lower() in ENTRY_POINT_NAMES}
1003
+ return entry | (set(proj_roots) if len(proj_roots) == 1 else set())
1004
+
1005
+
1006
+ def _is_app_root(dirs, j, children, app_roots):
1007
+ """Whether dirs[:j+1] is the APPLICATION rather than one module of it: an app root
1008
+ (_app_roots) that is no test, sample or demo code, with no sibling folder holding
1009
+ layers of its own. Its layer folders are then the app's top level — a layer-first
1010
+ tree, read by noun."""
1011
+ path = "/".join(dirs[:j + 1])
1012
+ if path not in app_roots or TEST_PATH_RE.search(path):
1013
+ return False
1014
+ parent = "/".join(dirs[:j])
1015
+ return not any(_layer_of(g)
1016
+ for s in children.get(parent, set()) if s != dirs[j]
1017
+ for g in children.get(f"{parent}/{s}" if parent else s, set()))
1018
+
1019
+
888
1020
  # How much a rule's naming is trusted when two rules land files in one group.
889
1021
  AXIS_RANK = {"slice-major": 0, "project-major": 1, "module-major": 2,
890
1022
  "layer-major": 3, "project-root": 4, "flat": 5}
1023
+ # Folders that hold code every module shares — never a module of their own. Compared
1024
+ # with case and separators removed. `core` is left out: it is often a real context.
1025
+ SHARED_CODE_NAMES = frozenset((
1026
+ "shared", "common", "commons", "kernel", "sharedkernel", "buildingblock",
1027
+ "buildingblocks", "crosscutting"))
891
1028
 
892
1029
 
893
1030
  def _structure_rows(files):
@@ -912,13 +1049,24 @@ def _structure_rows(files):
912
1049
  proj_roots = _project_roots(files)
913
1050
  product = _product_prefix(files)
914
1051
  children = _child_dirs(files)
1052
+ app_roots = _app_roots(files, proj_roots)
1053
+ domain_nouns = {}
1054
+ for f in files:
1055
+ dirs = f.split("/")[:-1]
1056
+ if TEST_PATH_RE.search(f) or not (
1057
+ _bucket_of(dirs, "") == "domain"
1058
+ or any(_dotted_module(d)[1] == "domain" for d in dirs)):
1059
+ continue
1060
+ n = _entity_noun(f)
1061
+ if n:
1062
+ domain_nouns[n] = _noun_words(n)
915
1063
 
916
1064
  for f in files:
917
1065
  parts = f.split("/")
918
1066
  if len(parts) < 2:
919
1067
  continue
920
1068
  dirs = parts[:-1]
921
- hit = None
1069
+ hit, unplaced = None, False
922
1070
 
923
1071
  # slice-major — a slice container names the module in its CHILD, unless the
924
1072
  # container is itself one LAYER of a module (see _is_module_layer), in which
@@ -929,14 +1077,13 @@ def _structure_rows(files):
929
1077
  if _is_module_layer(dirs, i, children):
930
1078
  parent = dirs[i - 1]
931
1079
  name = (_clean_project_name(parent, product)
932
- if DOTTED_SEG_RE.match(parent) else singularize(parent) or parent)
1080
+ if DOTTED_SEG_RE.match(parent) else _pascal(singularize(parent) or parent))
933
1081
  hit = ("/".join(dirs[:i]), name, "feature", "module-major")
934
1082
  break
935
1083
  nxt = dirs[i + 1] if i + 1 < len(dirs) else ""
936
1084
  if nxt and not _layer_of(nxt) and not SLICE_CONTAINER_RE.match(nxt):
937
- kind = next((_layer_of(p, soft=True) for p in dirs[i + 2:]
938
- if _layer_of(p, soft=True)), "feature")
939
- name = singularize(nxt) or nxt
1085
+ kind = _bucket_of(dirs[i + 2:], "feature")
1086
+ name = _pascal(singularize(nxt) or nxt)
940
1087
  hit = ("/".join(dirs[:i + 2]), name, kind, "slice-major")
941
1088
  break
942
1089
 
@@ -951,19 +1098,31 @@ def _structure_rows(files):
951
1098
  # module-major / layer-major — a layer FOLDER, read through its parent.
952
1099
  if not hit:
953
1100
  for i, seg in enumerate(dirs):
954
- kind = _layer_of(seg)
1101
+ kind = _layer_of(seg) or _paired_layer("/".join(dirs[:i]), seg, children)
955
1102
  if not kind:
956
1103
  continue
1104
+ kind = _bucket_of(dirs[i:], kind, soft=False)
957
1105
  parent = dirs[i - 1] if i else ""
958
- if i and (not CONTAINER_DIR_RE.match(parent) or _is_module_dir(dirs, i - 1, children)):
1106
+ if i and (not CONTAINER_DIR_RE.match(parent) or _is_module_dir(dirs, i - 1, children)) \
1107
+ and not _is_app_root(dirs, i - 1, children, app_roots):
959
1108
  name = (_clean_project_name(parent, product)
960
- if DOTTED_SEG_RE.match(parent) else singularize(parent) or parent)
1109
+ if DOTTED_SEG_RE.match(parent) else _pascal(singularize(parent) or parent))
961
1110
  hit = ("/".join(dirs[:i]), name, kind, "module-major")
962
1111
  else:
963
- noun = _entity_noun(f)
1112
+ # Where the domain layer names the nouns, an infrastructure or unbucketed
1113
+ # file naming none of them (a DbContext, an interceptor, a helper) serves
1114
+ # every aggregate rather than being one, and stays unplaced. A controller,
1115
+ # handler or use case keeps its own noun, so its operations keep an
1116
+ # aggregate (ReportsController -> Report).
1117
+ hosting = bool(domain_nouns) and kind in ("infra", "other")
1118
+ noun = _anchored_noun(f, domain_nouns, fallback=not hosting)
964
1119
  if noun:
965
1120
  hit = ("/".join(dirs[:i] + [noun]), noun, kind, "layer-major")
1121
+ elif hosting:
1122
+ unplaced = True
966
1123
  break
1124
+ if unplaced:
1125
+ continue
967
1126
 
968
1127
  # A MODULE folder owns everything beneath it, whatever its sub-folders are called.
969
1128
  # The layer vocabulary names the folders that bound a module; a module's own
@@ -972,12 +1131,11 @@ def _structure_rows(files):
972
1131
  # module SHAPE wins (see _is_module_dir), so a layer-major src/ is never one.
973
1132
  if not hit:
974
1133
  for j in range(len(dirs) - 1, -1, -1):
975
- if _is_module_dir(dirs, j, children):
1134
+ if _is_module_dir(dirs, j, children) and not _is_app_root(dirs, j, children, app_roots):
976
1135
  parent = dirs[j]
977
1136
  name = (_clean_project_name(parent, product)
978
- if DOTTED_SEG_RE.match(parent) else singularize(parent) or parent)
979
- kind = next((_layer_of(p, soft=True) for p in dirs[j + 1:]
980
- if _layer_of(p, soft=True)), "other")
1137
+ if DOTTED_SEG_RE.match(parent) else _pascal(singularize(parent) or parent))
1138
+ kind = _bucket_of(dirs[j + 1:], "other")
981
1139
  hit = ("/".join(dirs[:j + 1]), name, kind, "module-major")
982
1140
  break
983
1141
 
@@ -989,8 +1147,7 @@ def _structure_rows(files):
989
1147
  if DOTTED_SEG_RE.match(seg):
990
1148
  name = _clean_project_name(seg, product)
991
1149
  if name:
992
- kind = next((_layer_of(p, soft=True) for p in dirs[i + 1:]
993
- if _layer_of(p, soft=True)), "other")
1150
+ kind = _bucket_of(dirs[i + 1:], "other")
994
1151
  hit = ("/".join(dirs[:i] + [name]), name, kind, "project-major")
995
1152
  break
996
1153
 
@@ -999,8 +1156,7 @@ def _structure_rows(files):
999
1156
  for i in range(len(dirs), 0, -1):
1000
1157
  d = "/".join(dirs[:i])
1001
1158
  if d in proj_roots:
1002
- kind = next((_layer_of(p, soft=True) for p in dirs[i:]
1003
- if _layer_of(p, soft=True)), "other")
1159
+ kind = _bucket_of(dirs[i:], "other")
1004
1160
  hit = (d, _clean_project_name(dirs[i - 1], product), kind, "project-root")
1005
1161
  break
1006
1162
 
@@ -1095,12 +1251,9 @@ def _finish_candidates(cands, churn_map, strategy, min_files=1):
1095
1251
  return out
1096
1252
 
1097
1253
 
1098
- def strategy_structure(root, files, churn_map, graph=None):
1099
- """S1 — cluster on the repo's own organising axis.
1100
-
1101
- Always available: every repo has a layout. Where no layer vocabulary appears
1102
- at all, the noun fallback still clusters the domain/route/api files on their
1103
- basename, so a flat repo is not a zero-candidate repo."""
1254
+ def _structure_groups(files, churn_map):
1255
+ """The groups _structure_rows draws, as candidates keyed by group, and how many files
1256
+ each axis placed."""
1104
1257
  cands, axes, naming = {}, {}, {}
1105
1258
  for key, name, kind, f, axis in _structure_rows(files):
1106
1259
  axes[axis] = axes.get(axis, 0) + 1
@@ -1117,6 +1270,41 @@ def strategy_structure(root, files, churn_map, graph=None):
1117
1270
  n = _entity_noun(f)
1118
1271
  if n:
1119
1272
  c["entities"].add(n)
1273
+ return cands, axes
1274
+
1275
+
1276
+ def _hosting_keys(groups):
1277
+ """The groups that are no module and stay unplaced, as the domain strategy leaves them:
1278
+ a shared-code folder (shared/, common/, a shared kernel), and a project root that holds
1279
+ modules of its own — its manifest, entry point and loose helpers are the host of those
1280
+ modules, not one more. Shared code is set aside first, so a project root holding only a
1281
+ shared folder is the module, and stays."""
1282
+ shared = {k for k, c in groups.items()
1283
+ if {_ui_norm(k.rsplit("/", 1)[-1]), _ui_norm(c["name"])} & SHARED_CODE_NAMES}
1284
+ rest = [k for k in groups if k not in shared]
1285
+ return shared | {k for k in rest if groups[k].get("axis") == "project-root"
1286
+ and any(o != k and o.startswith(k + "/") for o in rest)}
1287
+
1288
+
1289
+ def _hosting_files(files):
1290
+ """The files of the _hosting_keys groups: what `structure` leaves unplaced."""
1291
+ groups, _axes = _structure_groups(files, {})
1292
+ return {f for k in _hosting_keys(groups) for lk in LAYER_KEYS for f in groups[k][lk]}
1293
+
1294
+
1295
+ def strategy_structure(root, files, churn_map, graph=None, name_only=False):
1296
+ """S1 — cluster on the repo's own organising axis.
1297
+
1298
+ Always available: every repo has a layout. Where no layer vocabulary appears
1299
+ at all, the noun fallback still clusters the domain/route/api files on their
1300
+ basename, so a flat repo is not a zero-candidate repo.
1301
+
1302
+ `name_only` is the call S2 and S3 make to name their clusters: every group is kept,
1303
+ shared code and a module-holding project root included, so each file has a name."""
1304
+ cands, axes = _structure_groups(files, churn_map)
1305
+ if not name_only:
1306
+ for key in _hosting_keys(cands):
1307
+ del cands[key]
1120
1308
 
1121
1309
  if not cands:
1122
1310
  for f in (matching(files, DB_FILES_RE) + [x for x in files if DOMAIN_DIR_RE.search(x)]
@@ -1320,7 +1508,7 @@ def strategy_reference(root, files, churn_map, graph=None):
1320
1508
  high-fan-in cluster it is. Clusters arrive UNNAMED — S1 names them, which is
1321
1509
  why it is the layer underneath rather than a competing option."""
1322
1510
  graph = graph if graph is not None else build_reference_graph(root, files)[0]
1323
- named = strategy_structure(root, files, churn_map)
1511
+ named = strategy_structure(root, files, churn_map, name_only=True)
1324
1512
  owner = _candidate_of_file(named)
1325
1513
 
1326
1514
  # In-degree first: the files everything imports are the shared kernel, and
@@ -1382,8 +1570,7 @@ def strategy_reference(root, files, churn_map, graph=None):
1382
1570
  key = os.path.commonprefix([os.path.dirname(f) + "/" for f in group]).rstrip("/")
1383
1571
  c = cands.setdefault(name.lower(), _blank_candidate(key, name))
1384
1572
  for f in group:
1385
- kind = next((_layer_of(p, soft=True) for p in f.split("/")[:-1]
1386
- if _layer_of(p, soft=True)), "other")
1573
+ kind = _bucket_of(f.split("/")[:-1], "other")
1387
1574
  c[kind + "_files"].append(f)
1388
1575
  c["churn"] += churn_map.get(f, 0)
1389
1576
  if kind == "domain":
@@ -1392,12 +1579,15 @@ def strategy_reference(root, files, churn_map, graph=None):
1392
1579
  c["entities"].add(n)
1393
1580
 
1394
1581
  # Files no import touches are still part of the repo; S1 places them so the
1395
- # strategy's coverage is comparable with the others'.
1582
+ # strategy's coverage is comparable with the others'. What S1 itself leaves unplaced
1583
+ # (_hosting_keys: shared code, a module-holding project root's manifest and entry
1584
+ # point) stays unplaced here too, rather than becoming a cluster named after its folder.
1396
1585
  placed = {f for grp in clusters.values() for f in grp}
1586
+ hosting = _hosting_files(files)
1397
1587
  for c in named:
1398
1588
  for k in LAYER_KEYS:
1399
1589
  for f in c[k]:
1400
- if f not in placed:
1590
+ if f not in placed and f not in hosting:
1401
1591
  tgt = cands.setdefault(c["name"].lower(), _blank_candidate(c["module_dir"], c["name"]))
1402
1592
  tgt[k].append(f)
1403
1593
  return _finish_candidates(cands, churn_map, "reference")
@@ -1611,7 +1801,7 @@ def strategy_persistence(root, files, churn_map, graph=None):
1611
1801
  owned = {t for e in ents.values() for t in e["owns"] if t in ents}
1612
1802
  roots = [n for n in sorted(ents) if n not in owned]
1613
1803
 
1614
- named = strategy_structure(root, files, churn_map)
1804
+ named = strategy_structure(root, files, churn_map, name_only=True)
1615
1805
  owner = _candidate_of_file(named)
1616
1806
  cands = {}
1617
1807
  for r in roots:
@@ -1633,8 +1823,7 @@ def strategy_persistence(root, files, churn_map, graph=None):
1633
1823
  if t in ents and t not in closure})
1634
1824
  c["confidence"] = "high"
1635
1825
  for f in sorted(member_files):
1636
- kind = next((_layer_of(p, soft=True) for p in f.split("/")[:-1]
1637
- if _layer_of(p, soft=True)), "domain")
1826
+ kind = _bucket_of(f.split("/")[:-1], "domain")
1638
1827
  c[kind + "_files"].append(f)
1639
1828
  c["churn"] += churn_map.get(f, 0)
1640
1829
 
@@ -1669,12 +1858,22 @@ def strategy_persistence(root, files, churn_map, graph=None):
1669
1858
  # and its only trace in code is the UI lane's features/<Feature>/ folder (see
1670
1859
  # ui_features) — which is why features are proposed in Pass C, not found in Pass A.
1671
1860
 
1672
- AGG_ROOT_MARKER_RE = re.compile(r"\b(?:I?AggregateRoot\w*|AbstractAggregateRoot)\b")
1673
- AGG_ROOT_ANNOTATION_RE = re.compile(r"@AggregateRoot\b")
1861
+ # Any base type whose name carries AggregateRoot — AggregateRoot<T>, IAggregateRoot,
1862
+ # AbstractAggregateRoot, BaseAggregateRoot, AggregateRootBase: no stack's rules fix what
1863
+ # the abstraction is called. The marker annotation may be written fully qualified.
1864
+ AGG_ROOT_MARKER_RE = re.compile(r"\b\w*AggregateRoot\w*\b")
1865
+ AGG_ROOT_ANNOTATION_RE = re.compile(r"@(?:[\w.]+\.)?AggregateRoot\b")
1674
1866
  ENTITY_MARKER_RE = re.compile(r"\b(?:I?Entity(?:Base)?|IAggregateChild|BaseEntity)\b")
1675
1867
  # Upper-case names only: every supported language names its types that way, and prose
1676
1868
  # does not — "this record avoids…" in a doc comment would otherwise declare `avoids`.
1677
1869
  TYPE_DECL_RE = re.compile(r"\b(class|record|struct|interface|enum)\s+([A-Z]\w*)")
1870
+ # Go: `type Name struct {…}`, `type Name interface {…}`, `type Name <type>`, each alone or
1871
+ # in a grouped `type ( … )` block, type parameters allowed. A struct says what it IS by
1872
+ # the types it embeds — a field with no name — where other languages use a base list.
1873
+ GO_TYPE_RE = re.compile(r"^[ \t]*type\b[ \t]*(\()?", re.M)
1874
+ GO_TYPE_SPEC_RE = re.compile(
1875
+ r"[ \t]*([A-Z]\w*)(?:\[[^\]\n]*\])?[ \t]*(?:=[ \t]*)?(?:(struct|interface)(?!\w))?")
1876
+ GO_EMBED_RE = re.compile(r"[ \t]*\*?(?:\w+\.)?([A-Za-z_]\w*)(?:\[[^\]\n]*\])?[ \t]*(?:`[^`\n]*`)?[ \t]*$")
1678
1877
  C_COMMENT_RE = re.compile(r"/\*.*?\*/|//[^\n]*", re.S)
1679
1878
 
1680
1879
 
@@ -1691,7 +1890,9 @@ TEST_PATH_RE = re.compile(
1691
1890
  r"(^|/)(tests?|__tests__|samples?|examples?|demos?|"
1692
1891
  r"[^/]*\.(?:Unit|Integration|Functional|Acceptance)?Tests?)(/|$)",
1693
1892
  re.IGNORECASE)
1694
- HANDLER_CLASS_RE = re.compile(r"\bclass\s+([A-Za-z_]\w*?)(Command|Query)Handler\b")
1893
+ # A handler class, or a Go struct named the same way.
1894
+ HANDLER_CLASS_RE = re.compile(
1895
+ r"\b(?:class|type(?=\s+\w+Handler\s+struct\b))\s+([A-Za-z_]\w*?)(Command|Query)Handler\b")
1695
1896
  QUERY_NAME_RE = re.compile(r"^(Get|List|Find|Search|Fetch|Query|Read|Lookup|Count|Export|Browse)[A-Z]")
1696
1897
  CREATE_NAME_RE = re.compile(r"^(Create|Register|Record|Start|Open|New|Add|Submit|Place|Book|Schedule)")
1697
1898
  IDENT_RE = re.compile(r"[A-Za-z_]\w*")
@@ -1720,6 +1921,49 @@ def _type_header(body, end):
1720
1921
  return header
1721
1922
 
1722
1923
 
1924
+ def _go_lines(body, a, z):
1925
+ """(offset, line) for each line of body[a:z] that starts outside every brace opened
1926
+ in that span — a grouped block's declarations, a struct's own fields."""
1927
+ depth, i = 0, a
1928
+ while i < z:
1929
+ j = body.find("\n", i, z)
1930
+ j = z if j == -1 else j
1931
+ line = body[i:j]
1932
+ if depth == 0:
1933
+ yield i, line
1934
+ depth += line.count("{") - line.count("}")
1935
+ i = j + 1
1936
+
1937
+
1938
+ def _type_decls(path, body):
1939
+ """Every type a file declares, as (kind, name, header, start, end): `header` is what
1940
+ the declaration says the type IS (see _type_header) and `end` where its body is
1941
+ looked for. A Go struct's header is the types it embeds, its fields read at its own
1942
+ level; a Go type that is neither a struct nor an interface has kind `type`."""
1943
+ if _lang_of(path) != "go":
1944
+ for m in TYPE_DECL_RE.finditer(body):
1945
+ yield m.group(1), m.group(2), _type_header(body, m.end()), m.start(), m.end()
1946
+ return
1947
+ for m in GO_TYPE_RE.finditer(body):
1948
+ if m.group(1):
1949
+ close = _close_bracket(body, m.end() - 1)
1950
+ specs = [i for i, ln in _go_lines(body, m.end(), close) if ln.strip()]
1951
+ else:
1952
+ specs = [m.end()]
1953
+ for at in specs:
1954
+ s = GO_TYPE_SPEC_RE.match(body, at)
1955
+ if not s:
1956
+ continue
1957
+ header = ""
1958
+ if s.group(2) == "struct":
1959
+ brace = body.find("{", s.end())
1960
+ if brace != -1 and not body[s.end():brace].strip():
1961
+ header = " ".join(e.group(1) for _i, ln in _go_lines(
1962
+ body, brace + 1, _close_bracket(body, brace))
1963
+ for e in [GO_EMBED_RE.fullmatch(ln)] if e)
1964
+ yield s.group(2) or "type", s.group(1), header, at, s.end()
1965
+
1966
+
1723
1967
  def _type_role(kind, name, header, path, pre):
1724
1968
  """What a declared type is in the domain model: root, entity, value, event, spec,
1725
1969
  enum or contract. Read from its declaration, never from its folder alone."""
@@ -1867,6 +2111,835 @@ def _attribute_leftovers(modules, module_of, text, types, decl_of, refs, owner,
1867
2111
  owner[f] = o
1868
2112
 
1869
2113
 
2114
+ # ═══════════════════════════════════════════════════════════════════════════
2115
+ # Operations the code PUBLISHES — endpoints and use cases, in any stack.
2116
+ # ═══════════════════════════════════════════════════════════════════════════
2117
+ #
2118
+ # A slice folder and a CQRS handler class are two ways code writes an operation down.
2119
+ # Two more are read here, the same way whatever the language:
2120
+ #
2121
+ # ENDPOINT a route declaration that names its HTTP verb: an attribute ([HttpPost]),
2122
+ # an annotation (@PostMapping, JAX-RS @POST, NestJS @Post()), a decorator
2123
+ # (@router.post, @app.route(methods=…)) or a router call (router.post("/x",
2124
+ # h), r.Post("/x", h), app.MapPost("/x", …), mux.HandleFunc("POST /x", h)).
2125
+ # A controller with no route declaration at all is read by convention, one
2126
+ # endpoint per public action. Endpoints that resolve to one name — an MVC
2127
+ # Create's GET form and its POST submit — are one operation.
2128
+ # USE CASE an application-layer type named for one thing the system does
2129
+ # (RecordEncounterUseCase, PlaceOrderInteractor) that declares one public
2130
+ # operation. A type declaring several is a service, not an operation: the
2131
+ # endpoints calling it are the operations.
2132
+ #
2133
+ # An endpoint is NAMED by the operation id the code publishes for it — the name the API
2134
+ # document carries and the frontend lane binds to — else by the operation it invokes (a
2135
+ # use case, command or handler found already), else by its handler's own name with the
2136
+ # resource noun added where the handler name lacks it (EncounterController.record →
2137
+ # RecordEncounter). An endpoint that invokes a known operation IS that operation: it adds
2138
+ # its routes and its controller to it rather than standing beside it.
2139
+ #
2140
+ # Routes are JOINED the way the framework joins them: a class-level prefix
2141
+ # (@RequestMapping, @Path, [Route], [RoutePrefix], @Controller), a router value made with
2142
+ # one (Group, MapGroup, APIRouter(prefix=), Blueprint(url_prefix=), new Router({prefix}),
2143
+ # a same-file app.use("/x", router)), or a routing block (r.Route("/x", func…)).
2144
+
2145
+ # Declarations that sit on the method or function they route: (languages, pattern, role).
2146
+ ANNOT_DECLS = (
2147
+ ({"java", "kt"}, re.compile(r"@(Get|Post|Put|Patch|Delete)Mapping\b(?:\s*\(([^)]*)\))?"), "verb"),
2148
+ ({"java", "kt"}, re.compile(r"@RequestMapping\b(?:\s*\(([^)]*)\))?"), "request"),
2149
+ ({"java", "kt"}, re.compile(r"@(GET|POST|PUT|PATCH|DELETE)\b(?!\s*\()"), "jaxrs"),
2150
+ ({"java", "kt"}, re.compile(r"@Path\s*\(([^)]*)\)"), "path"),
2151
+ ({"cs"}, re.compile(r"[\[,]\s*Http(Get|Post|Put|Patch|Delete)(?:Attribute)?\b(?:\s*\(([^)]*)\))?"), "verb"),
2152
+ ({"cs"}, re.compile(r"[\[,]\s*(?:Route|RoutePrefix)(?:Attribute)?\s*\(([^)]*)\)"), "path"),
2153
+ ({"js"}, re.compile(r"@(Get|Post|Put|Patch|Delete)\s*\(([^)]*)\)"), "verb"),
2154
+ ({"js"}, re.compile(r"@Controller\s*\(([^)]*)\)"), "path"),
2155
+ ({"py"}, re.compile(r"@\s*(\w+)\.(get|post|put|patch|delete)\s*\(([^)]*)\)"), "pyverb"),
2156
+ ({"py"}, re.compile(r"@\s*(\w+)\.(?:route|api_route)\s*\(([^)]*)\)"), "pyroute"),
2157
+ )
2158
+ # Declarations that are calls on a router value, the handler passed as an argument.
2159
+ CALL_DECLS = (
2160
+ ({"cs"}, re.compile(r"(?:\b(\w+)\s*\.\s*)?\bMap(Get|Post|Put|Patch|Delete)\s*\(\s*\"([^\"]*)\""), "map"),
2161
+ ({"cs"}, re.compile(r"(?<![\w.])(Get|Post|Put|Patch|Delete)\s*\(\s*\"(/[^\"]*)\"\s*\)"), "fastendpoints"),
2162
+ ({"js", "go"}, re.compile(r"\b(\w+)\s*\.\s*(get|post|put|patch|delete|Get|Post|Put|Patch|Delete|"
2163
+ r"GET|POST|PUT|PATCH|DELETE)\s*\(\s*([\"'`])(/[^\"'`\n]*|)\3\s*,"), "router"),
2164
+ ({"go"}, re.compile(r"\b(\w+)\s*\.\s*Handle(?:Func)?\s*\(\s*\"(?:(GET|POST|PUT|PATCH|DELETE)\s+)?"
2165
+ r"(/[^\"]*)\"\s*,"), "mux"),
2166
+ )
2167
+ # A router call's receiver that is an HTTP CLIENT or a request/response value, never a
2168
+ # router: axios.post("/x", body) sends a request, it does not serve one.
2169
+ CLIENT_RECV_RE = re.compile(
2170
+ r"^(?:https?|axios|fetch|got|ky|superagent|requests?|httpx|session|req|res|ctx|"
2171
+ r"\w*client|resttemplate|webclient|map|cache|headers|params|query)$", re.IGNORECASE)
2172
+ # Router values created with a prefix: (pattern, groups = var[, parent], path).
2173
+ PREFIX_VAR_RES = (
2174
+ ({"go", "cs"}, re.compile(r"\b(\w+)\s*:?=\s*(\w+)\s*\.\s*(?:Group|MapGroup)\s*\(\s*\"([^\"]*)\"")),
2175
+ ({"py"}, re.compile(r"\b(\w+)\s*=\s*APIRouter\s*\([^)]*?\bprefix\s*=\s*[\"']([^\"']*)[\"']")),
2176
+ ({"py"}, re.compile(r"\b(\w+)\s*=\s*Blueprint\s*\([^)]*?\burl_prefix\s*=\s*[\"']([^\"']*)[\"']")),
2177
+ ({"js"}, re.compile(r"\b(\w+)\s*=\s*new\s+Router\s*\(\s*\{[^}]*?\bprefix\s*:\s*[\"'`]([^\"'`]*)[\"'`]")),
2178
+ )
2179
+ JS_MOUNT_RE = re.compile(r"\b\w+\s*\.\s*use\s*\(\s*([\"'`])(/[^\"'`\n]*)\1\s*,\s*(\w+)\s*\)")
2180
+ GO_ROUTE_BLOCK_RE = re.compile(r"\b\w+\s*\.\s*Route\s*\(\s*\"([^\"]*)\"\s*,\s*func\s*\(")
2181
+ # The operation id an endpoint publishes, in the dialects that let code set it.
2182
+ # The id an endpoint is published under in the API document, as each stack declares it
2183
+ # beside the endpoint. Captured verbatim — the contract is checked against this string.
2184
+ OPID_RES = (
2185
+ re.compile(r"\boperationId\s*[=:]\s*[\"'`]([\w.\-]+)[\"'`]"), # springdoc / swagger-core @Operation, NestJS @ApiOperation
2186
+ re.compile(r"\bOperationId\s*=\s*\"([\w.\-]+)\""), # Swashbuckle [SwaggerOperation]
2187
+ re.compile(r"\boperation_id\s*=\s*[\"']([\w.\-]+)[\"']"), # FastAPI, drf-spectacular
2188
+ re.compile(r"\.WithName\s*\(\s*\"([\w.\-]+)\"\s*\)"), # ASP.NET Core minimal APIs
2189
+ re.compile(r"\[EndpointName\s*\(\s*\"([\w.\-]+)\"\s*\)"), # ASP.NET Core [EndpointName]
2190
+ re.compile(r"@OperationId\s*\(\s*[\"'`]([\w.\-]+)[\"'`]"), # tsoa
2191
+ re.compile(r"^\s*operationId\s*:\s*[\"']?([\w.\-]+)[\"']?\s*$", re.M), # YAML in a docstring
2192
+ )
2193
+ # Function definitions a router call can name as its handler, in the languages whose
2194
+ # routers take a function rather than annotate one.
2195
+ FUNC_DEF_RES = {
2196
+ "js": (re.compile(r"\bfunction\s+(\w+)\s*\("),
2197
+ re.compile(r"\b(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?"
2198
+ r"(?:function\b|\([^)]*\)\s*(?::[^=;{]+)?=>|\w+\s*=>)")),
2199
+ "go": (re.compile(r"\bfunc\s+(?:\([^)]*\)\s*)?(\w+)\s*\("),),
2200
+ }
2201
+ CLASS_DECL_RE = re.compile(r"\bclass\s+([A-Z]\w*)")
2202
+ CS_CONTROLLER_BASE_RE = re.compile(r"\b(?:Api)?Controller(?:Base)?\b")
2203
+ CS_ACTION_RE = re.compile(
2204
+ r"^[ \t]*public\s+(?!static\b)(?:(?:async|virtual|override|new|sealed)\s+)*"
2205
+ r"(?:Task\s*<\s*)?(?:I?ActionResult(?:\s*<[^>]*>)?|\w*Result|IHttpActionResult|"
2206
+ r"HttpResponseMessage)\s*>?\s+(\w+)\s*\(", re.M)
2207
+ USECASE_NAME_RE = re.compile(r"^I?([A-Z]\w*?)(?:UseCase|Interactor)$")
2208
+ # A name a variable is declared with, per language family: (pattern, type group, var group).
2209
+ VAR_TYPE_RES = {
2210
+ "java": (re.compile(r"\b([A-Z]\w*)(?:\s*<[^<>;()]*>)?\??\s+([a-z_]\w*)\s*[;,)=]"), 1, 2),
2211
+ "js": (re.compile(r"\b([a-z_]\w*)\s*\??:\s*(?:\w+\.)?([A-Z]\w*)"), 2, 1),
2212
+ "go": (re.compile(r"\b([a-z_]\w*)\s+\*?(?:\w+\.)?([A-Z]\w*)\b"), 2, 1),
2213
+ }
2214
+ for _l in ("cs", "scala"):
2215
+ VAR_TYPE_RES[_l] = VAR_TYPE_RES["java"]
2216
+ for _l in ("kt", "py"):
2217
+ VAR_TYPE_RES[_l] = VAR_TYPE_RES["js"]
2218
+ _HANDLER_SKIP = frozenset((
2219
+ "if", "for", "foreach", "while", "switch", "catch", "return", "new", "typeof", "nameof",
2220
+ "sizeof", "await", "using", "lock", "throw", "function", "def", "func", "fun", "async",
2221
+ "super", "this", "base", "default", "when", "constructor"))
2222
+ # Handler names that say nothing about WHAT is done; the verb and the noun name it instead.
2223
+ GENERIC_HANDLER = frozenset((
2224
+ "handle", "handler", "handleasync", "execute", "executeasync", "invoke", "call", "run",
2225
+ "process", "serve", "servehttp", "endpoint", "action", "apply"))
2226
+ # MVC's conventional action names, read as the operation they stand for.
2227
+ MVC_ACTION_ALIAS = {"index": "List", "details": "Get", "detail": "Get", "show": "Get",
2228
+ "edit": "Update", "new": "Create", "store": "Create", "destroy": "Delete"}
2229
+ VERB_WORD = {"POST": "Create", "PUT": "Update", "PATCH": "Update", "DELETE": "Delete"}
2230
+ NOUN_ROLE_RE = re.compile(
2231
+ r"(?:RestController|Controller|Resource|Endpoints?|Routes?|Router|Handlers?|Api|Views?|"
2232
+ r"Rest|Service)$")
2233
+ NOUN_STOP_WORDS = frozenset((
2234
+ "controller", "controllers", "endpoint", "endpoints", "route", "routes", "router", "routers",
2235
+ "routing", "handler", "handlers", "api", "apis", "rest", "resource", "resources", "view",
2236
+ "views", "urls", "http", "web", "server", "index", "main", "app"))
2237
+
2238
+
2239
+ def _close_bracket(body, i):
2240
+ """Index of the bracket closing the one at body[i], or len(body)."""
2241
+ depth = 0
2242
+ for j in range(i, min(len(body), i + 50_000)):
2243
+ ch = body[j]
2244
+ if ch in "([{":
2245
+ depth += 1
2246
+ elif ch in ")]}":
2247
+ depth -= 1
2248
+ if depth == 0:
2249
+ return j
2250
+ return len(body)
2251
+
2252
+
2253
+ def _top_args(s):
2254
+ """A call's argument text split at its top-level commas."""
2255
+ out, depth, cur = [], 0, []
2256
+ for ch in s:
2257
+ if ch in "([{":
2258
+ depth += 1
2259
+ elif ch in ")]}":
2260
+ depth -= 1
2261
+ if ch == "," and depth == 0:
2262
+ out.append("".join(cur))
2263
+ cur = []
2264
+ else:
2265
+ cur.append(ch)
2266
+ out.append("".join(cur))
2267
+ return [a.strip() for a in out if a.strip()]
2268
+
2269
+
2270
+ def _path_arg(args):
2271
+ """The route template a declaration's arguments carry, or ""."""
2272
+ m = re.search(r"\b(?:value|path)\s*[=:]\s*\{?\s*([\"'`])([^\"'`\n]*)\1", args or "")
2273
+ if m:
2274
+ return m.group(2)
2275
+ m = re.match(r"\s*\{?\s*([\"'`])([^\"'`\n]*)\1", args or "")
2276
+ return m.group(2) if m else ""
2277
+
2278
+
2279
+ def _join_route(*parts):
2280
+ segs = [s for p in parts if p for s in p.strip().split("/") if s]
2281
+ return "/" + "/".join(segs)
2282
+
2283
+
2284
+ def _handler_after(body, pos):
2285
+ """The method or function an annotation block ending at `pos` sits on."""
2286
+ seg = body[pos:pos + 1500]
2287
+ for m in re.finditer(r"([A-Za-z_]\w*)\s*(?:<[^<>()]*>)?\s*\(", seg):
2288
+ before = seg[:m.start()].rstrip()[-1:]
2289
+ if before in ("@", "[", ",", ".", "(") or m.group(1) in _HANDLER_SKIP:
2290
+ continue
2291
+ return m.group(1)
2292
+ return None
2293
+
2294
+
2295
+ def _call_handler(body, open_paren):
2296
+ """The handler a router call is given — its last argument, when that is a name."""
2297
+ args = _top_args(body[open_paren + 1:_close_bracket(body, open_paren)])
2298
+ last = args[-1] if len(args) > 1 else ""
2299
+ if not re.fullmatch(r"[\w.$]+", last):
2300
+ return None
2301
+ segs = last.split(".")
2302
+ if len(segs) > 1 and segs[-1].lower() in GENERIC_HANDLER:
2303
+ return segs[-2]
2304
+ return segs[-1]
2305
+
2306
+
2307
+ def _resource_noun(class_name, path, route):
2308
+ """The resource an endpoint serves: its class named for its role, else its file, else
2309
+ the first segment of its route."""
2310
+ base, prev = class_name or "", None
2311
+ while prev != base: # AppointmentsApiController → Appointments
2312
+ prev, base = base, NOUN_ROLE_RE.sub("", base)
2313
+ if base:
2314
+ return singularize(base)
2315
+ words = [w for w in _name_words(path) if w not in NOUN_STOP_WORDS]
2316
+ if words:
2317
+ return singularize("".join(_pascal_name(w) for w in words))
2318
+ segs = _route_segments(route)
2319
+ return _pascal(singularize(segs[-1] if len(segs) > 1 and segs[-1] else (segs or [""])[0]))
2320
+
2321
+
2322
+ def _endpoint_name(handler, noun, routes):
2323
+ """An endpoint's operation name from its handler, the noun added where it is missing:
2324
+ record → RecordEncounter, getById → GetEncounterById, Index → ListPatient."""
2325
+ words = [w[:1].upper() + w[1:]
2326
+ for w in NAME_WORD_RE.findall(re.sub(r"[_\-\s]+", " ", handler or ""))]
2327
+ low = "".join(words).lower()
2328
+ if not words or low in GENERIC_HANDLER:
2329
+ verb = next((v for v, _p in routes if v), "")
2330
+ last = (routes[0][1] if routes else "").rstrip("/").rsplit("/", 1)[-1]
2331
+ words = [VERB_WORD.get(verb) or
2332
+ ("Get" if last.startswith(("{", ":", "<")) else "List" if verb == "GET" else "Handle")]
2333
+ elif len(words) == 1 and low in MVC_ACTION_ALIAS:
2334
+ words = [MVC_ACTION_ALIAS[low]]
2335
+ name = "".join(words)
2336
+ if noun and _ui_norm(noun) not in _ui_norm(name):
2337
+ name = words[0] + noun + "".join(words[1:])
2338
+ return name
2339
+
2340
+
2341
+ def _verb_kind(routes):
2342
+ """query when every verb an operation is served on reads, command when one writes,
2343
+ "" when no route names its verb."""
2344
+ verbs = {v for v, _p in routes if v}
2345
+ return ("query" if verbs == {"GET"} else "command") if verbs else ""
2346
+
2347
+
2348
+ def _block_start(lang, body, pos):
2349
+ """Where the annotation block holding the declaration at `pos` begins: just after
2350
+ the previous member ends, or — Python — at the first decorator of the run."""
2351
+ if lang == "py":
2352
+ start = body.rfind("\n", 0, pos) + 1
2353
+ while start > 0:
2354
+ prev = body.rfind("\n", 0, start - 1) + 1
2355
+ if not body[prev:start].strip().startswith("@"):
2356
+ break
2357
+ start = prev
2358
+ return start
2359
+ return max(body.rfind(c, 0, pos) for c in "{};") + 1
2360
+
2361
+
2362
+ def _file_endpoints(path, body, mounts=None):
2363
+ """Every endpoint one source file declares:
2364
+ [{"routes": [(VERB, path)], "handler", "opid", "noun", "region", "class"}].
2365
+
2366
+ VERB is "" where the declaration serves any verb. The region is the text that is
2367
+ THIS endpoint's — its annotation block and its handler — so a controller declaring
2368
+ several endpoints is read one endpoint at a time. `mounts` is the prefix each router
2369
+ value is mounted under in ANOTHER file (app.use("/x", encounterRouter))."""
2370
+ lang = _lang_of(path)
2371
+ if not lang or not body:
2372
+ return []
2373
+ decls = []
2374
+ for langs, rx, role in ANNOT_DECLS:
2375
+ if lang not in langs:
2376
+ continue
2377
+ for m in rx.finditer(body):
2378
+ d = {"pos": m.start(), "end": m.end(), "style": "annot", "recv": "", "opid": ""}
2379
+ if role == "verb":
2380
+ args = m.group(2) or ""
2381
+ d.update(verbs=[m.group(1).upper()], path=_path_arg(args))
2382
+ n = re.search(r"\bName\s*=\s*\"([\w.\-]+)\"", args) if lang == "cs" else None
2383
+ d["opid"] = n.group(1) if n else ""
2384
+ elif role == "request":
2385
+ args = m.group(1) or ""
2386
+ verbs = re.findall(r"RequestMethod\.(GET|POST|PUT|PATCH|DELETE)", args)
2387
+ d.update(verbs=verbs or None, path=_path_arg(args))
2388
+ elif role == "jaxrs":
2389
+ d.update(verbs=[m.group(1)], path="")
2390
+ elif role == "path":
2391
+ d.update(verbs=None, path=_path_arg(m.group(1)))
2392
+ elif role == "pyverb":
2393
+ d.update(recv=m.group(1), verbs=[m.group(2).upper()], path=_path_arg(m.group(3)))
2394
+ else: # pyroute
2395
+ verbs = re.findall(r"[\"'](GET|POST|PUT|PATCH|DELETE)[\"']", m.group(2), re.I)
2396
+ d.update(recv=m.group(1), verbs=[v.upper() for v in verbs] or ["GET"],
2397
+ path=_path_arg(m.group(2)))
2398
+ decls.append(d)
2399
+ for langs, rx, role in CALL_DECLS:
2400
+ if lang not in langs:
2401
+ continue
2402
+ for m in rx.finditer(body):
2403
+ d = {"pos": m.start(), "end": m.end(), "style": "call", "opid": ""}
2404
+ if role == "map":
2405
+ d.update(recv=m.group(1) or "", verbs=[m.group(2).upper()], path=m.group(3))
2406
+ elif role == "fastendpoints":
2407
+ d.update(recv="", verbs=[m.group(1).upper()], path=m.group(2), style="class")
2408
+ elif role == "router":
2409
+ if CLIENT_RECV_RE.match(m.group(1)):
2410
+ continue
2411
+ d.update(recv=m.group(1), verbs=[m.group(2).upper()], path=m.group(4))
2412
+ else: # mux
2413
+ d.update(recv=m.group(1), verbs=[m.group(2) or ""], path=m.group(3))
2414
+ d["open"] = body.find("(", m.start())
2415
+ decls.append(d)
2416
+ if not decls and lang != "cs":
2417
+ return []
2418
+ decls.sort(key=lambda d: d["pos"])
2419
+
2420
+ brace_lang = lang in ("java", "kt", "cs", "js", "scala")
2421
+ classes = [(m.start(), m.group(1), m.end()) for m in CLASS_DECL_RE.finditer(body)] \
2422
+ if brace_lang else []
2423
+
2424
+ def class_at(pos):
2425
+ return next((c for c in reversed(classes) if c[0] < pos), None)
2426
+
2427
+ def class_end(pos):
2428
+ return next((c[0] for c in classes if c[0] > pos), len(body))
2429
+
2430
+ def clean(a, b):
2431
+ """No member boundary between two offsets: both sit in one annotation block."""
2432
+ return not re.search(r"[{};]", body[min(a, b):max(a, b)])
2433
+
2434
+ # A controller with no route declaration at all is routed by CONVENTION: every public
2435
+ # action is an endpoint at /<controller>/<action> (/api/<controller> for Web API).
2436
+ conventional = []
2437
+ if lang == "cs":
2438
+ for start, name, end in classes:
2439
+ stop = class_end(start)
2440
+ header = _type_header(body, end)
2441
+ if not name.endswith("Controller") or not CS_CONTROLLER_BASE_RE.search(header):
2442
+ continue
2443
+ routed = any(d["path"] or d["verbs"] is None for d in decls
2444
+ if start - 400 < d["pos"] < stop and
2445
+ (d["pos"] > start or clean(d["end"], start)))
2446
+ if not routed:
2447
+ conventional.append((start, stop, name, "ApiController" in header))
2448
+ in_conv = lambda pos: any(a <= pos < b for a, b, _n, _w in conventional) # noqa: E731
2449
+
2450
+ cls_prefix = {}
2451
+ for d in decls:
2452
+ if d["style"] != "annot" or d["verbs"] is not None:
2453
+ continue
2454
+ nxt = next((c for c in classes if c[0] > d["pos"]), None)
2455
+ if nxt and clean(d["end"], nxt[0]):
2456
+ cls_prefix.setdefault(nxt[0], []).append(d["path"])
2457
+ d["class_level"] = True
2458
+ for d in decls:
2459
+ if d["style"] != "annot" or d["verbs"] is not None or d.get("class_level"):
2460
+ continue
2461
+ near = [v for v in decls if v["style"] == "annot" and v["verbs"] is not None
2462
+ and clean(v["pos"], d["pos"])]
2463
+ if near:
2464
+ min(near, key=lambda v: abs(v["pos"] - d["pos"]))["sub"] = d["path"]
2465
+ d["attached"] = True
2466
+ else:
2467
+ d["verbs"] = [""]
2468
+
2469
+ var_prefix = {}
2470
+ for langs, rx in PREFIX_VAR_RES:
2471
+ if lang in langs:
2472
+ for m in rx.finditer(body):
2473
+ g = m.groups()
2474
+ var_prefix[g[0]] = _join_route(var_prefix.get(g[1], ""), g[2]) if len(g) == 3 else g[1]
2475
+ if lang == "js":
2476
+ for m in JS_MOUNT_RE.finditer(body):
2477
+ var_prefix[m.group(3)] = _join_route(m.group(2), var_prefix.get(m.group(3), ""))
2478
+ for var, p in (mounts or {}).items():
2479
+ if var not in var_prefix:
2480
+ var_prefix[var] = p
2481
+ blocks = []
2482
+ if lang == "go":
2483
+ for m in GO_ROUTE_BLOCK_RE.finditer(body):
2484
+ brace = body.find("{", _close_bracket(body, m.end() - 1))
2485
+ if brace != -1:
2486
+ blocks.append((brace, _close_bracket(body, brace), m.group(1)))
2487
+
2488
+ # One endpoint per annotation block (stacked declarations route one handler), one per
2489
+ # router call.
2490
+ groups = []
2491
+ for d in decls:
2492
+ if d["verbs"] is None or d.get("class_level") or d.get("attached") or in_conv(d["pos"]):
2493
+ continue
2494
+ bs = _block_start(lang, body, d["pos"]) if d["style"] == "annot" else d["pos"]
2495
+ if groups and d["style"] == "annot" and groups[-1]["bs"] == bs:
2496
+ groups[-1]["decls"].append(d)
2497
+ else:
2498
+ groups.append({"bs": bs, "decls": [d]})
2499
+
2500
+ out = []
2501
+ for i, g in enumerate(groups):
2502
+ first, last = g["decls"][0], g["decls"][-1]
2503
+ cls = class_at(first["pos"])
2504
+ stop = min(groups[i + 1]["bs"] if i + 1 < len(groups) else len(body),
2505
+ class_end(first["pos"]) if brace_lang else len(body))
2506
+ # An endpoint CLASS (FastEndpoints) is the whole endpoint, its base type included.
2507
+ region = body[cls[0]:class_end(cls[0])] if first["style"] == "class" and cls \
2508
+ else body[g["bs"]:stop]
2509
+ if first["style"] == "annot":
2510
+ handler = _handler_after(body, last["end"])
2511
+ elif first["style"] == "class":
2512
+ handler = NOUN_ROLE_RE.sub("", cls[1]) if cls else None
2513
+ else:
2514
+ handler = _call_handler(body, first["open"])
2515
+ g["called"] = handler
2516
+ cname = cls[1] if cls else ""
2517
+ ctrl = re.sub(r"Controller$", "", cname)
2518
+ routes = []
2519
+ for d in g["decls"]:
2520
+ own = (d.get("sub") or "") + ("/" if d.get("sub") and d["path"] else "") + d["path"] \
2521
+ if d.get("sub") else d["path"]
2522
+ prefix = [] if (lang == "cs" and own.startswith(("/", "~/"))) else \
2523
+ list(cls_prefix.get(cls[0], [])) if cls else []
2524
+ prefix.append(var_prefix.get(d["recv"], ""))
2525
+ prefix += [p for a, b, p in sorted(blocks) if a < d["pos"] < b]
2526
+ full = _join_route(*prefix, own.lstrip("~"))
2527
+ if lang == "cs":
2528
+ full = full.replace("[controller]", ctrl).replace("[action]", handler or "")
2529
+ routes += [(v, full) for v in d["verbs"]]
2530
+ opid = next((d["opid"] for d in g["decls"] if d["opid"]), "") or \
2531
+ next((m.group(1) for rx in OPID_RES for m in [rx.search(region)] if m), "")
2532
+ out.append({"routes": routes, "handler": handler, "opid": opid, "class": cname,
2533
+ "noun": _resource_noun(cname, path, routes[0][1] if routes else ""),
2534
+ "region": region, "called": g.get("called")})
2535
+
2536
+ for start, stop, name, webapi in conventional:
2537
+ ctrl = re.sub(r"Controller$", "", name)
2538
+ for m in CS_ACTION_RE.finditer(body, start, stop):
2539
+ bs = _block_start(lang, body, m.start())
2540
+ block = body[bs:m.start()]
2541
+ if re.search(r"\bNonAction\b", block):
2542
+ continue
2543
+ action = m.group(1)
2544
+ verbs = re.findall(r"\bHttp(Get|Post|Put|Patch|Delete)\b", block)
2545
+ if not verbs and webapi:
2546
+ verbs = [v for v in ("Get", "Post", "Put", "Patch", "Delete") if action.startswith(v)][:1]
2547
+ route = f"/api/{ctrl}" if webapi else f"/{ctrl}/{action}"
2548
+ end = body.find("{", m.end())
2549
+ region = body[bs:_close_bracket(body, end) + 1 if end != -1 else m.end()]
2550
+ routes = [(v.upper(), route) for v in (verbs or ["Get"])]
2551
+ opid = next((x.group(1) for rx in OPID_RES for x in [rx.search(block)] if x), "")
2552
+ out.append({"routes": routes, "handler": action, "opid": opid, "class": name,
2553
+ "noun": _resource_noun(name, path, route), "region": region})
2554
+ # An infrastructure route (_infra_route) is no operation, so it is dropped here, the one
2555
+ # reader every stack's endpoints pass through; an endpoint left with no route goes too.
2556
+ kept = []
2557
+ for ep in out:
2558
+ routes = [r for r in ep["routes"] if not _infra_route(r[1])]
2559
+ if routes or not ep["routes"]:
2560
+ kept.append(dict(ep, routes=routes))
2561
+ return kept
2562
+
2563
+
2564
+ def _type_body(lang, body, start):
2565
+ """The text of the type whose declaration header ends at `start`."""
2566
+ if lang == "py":
2567
+ nl = body.find("\n", start)
2568
+ lines = []
2569
+ for ln in (body[nl + 1:] if nl != -1 else "").split("\n"):
2570
+ if ln.strip() and not ln[:1].isspace():
2571
+ break
2572
+ lines.append(ln)
2573
+ return "\n".join(lines)
2574
+ i, j = body.find("{", start), body.find(";", start)
2575
+ if i == -1 or (j != -1 and j < i):
2576
+ return ""
2577
+ return body[i + 1:_close_bracket(body, i)]
2578
+
2579
+
2580
+ def _public_operations(lang, kind, name, tbody, source=""):
2581
+ """How many public operations a type declares — a use case declares one. A Go
2582
+ struct's methods are declared outside it, on a receiver, so they are read from the
2583
+ `source` of its file."""
2584
+ if lang == "go":
2585
+ names = set(re.findall(r"^[ \t]*([A-Z]\w*)\s*\(", tbody, re.M)) if kind == "interface" \
2586
+ else set(re.findall(rf"\bfunc\s*\(\s*\w*\s*\*?{re.escape(name)}(?:\[[^\]\n]*\])?\s*\)"
2587
+ rf"\s*([A-Z]\w*)\s*[\[(]", source))
2588
+ elif lang == "py":
2589
+ defs = re.findall(r"^([ \t]+)(?:async\s+)?def\s+([A-Za-z]\w*)", tbody, re.M)
2590
+ depth = min((len(i) for i, _n in defs), default=0)
2591
+ names = {n for i, n in defs if len(i) == depth}
2592
+ elif lang == "kt":
2593
+ names = set(re.findall(r"^\s*(?!private\b|protected\b|internal\b)(?:\w+\s+)*fun\s+(\w+)",
2594
+ tbody, re.M))
2595
+ elif kind == "interface":
2596
+ names = set(re.findall(r"\b(\w+)\s*(?:<[^<>]*>)?\s*\([^(){};]*\)\s*(?::\s*[^;{}=]+?)?"
2597
+ r"\s*(?:throws\s+[\w.,\s]+)?;", tbody))
2598
+ elif lang == "js":
2599
+ names = set(re.findall(r"^\s*(?:public\s+)?(?:async\s+)?([a-zA-Z]\w*)\s*\([^)]*\)\s*"
2600
+ r"(?::[^{;]+)?\{", tbody, re.M))
2601
+ else:
2602
+ names = set(re.findall(r"\bpublic\s+(?:(?:async|virtual|override|final|synchronized|"
2603
+ r"abstract)\s+)*[\w<>\[\],.?\s]*?\b(\w+)\s*\(", tbody))
2604
+ return len(names - _HANDLER_SKIP - {name, "toString", "equals", "hashCode", "ToString",
2605
+ "Equals", "GetHashCode"})
2606
+
2607
+
2608
+ def _published_operations(text, children):
2609
+ """The use cases and endpoints a lane declares, each endpoint resolved to the
2610
+ operation it is.
2611
+
2612
+ `text` is {file: source} for the lane's code. Returns
2613
+ {"usecases": {op: [file, …]}, single-operation use-case types: the declaring
2614
+ file, its implementations, files named after it
2615
+ "uc_decl": {op: file},
2616
+ "endpoints": {op: {"files", "regions", "routes", "ids"}}} ids: the published
2617
+ operation ids its endpoints declare, verbatim
2618
+ An endpoint op that is also a slice, a handler or a use case is that operation; the
2619
+ rest are operations of their own."""
2620
+ slice_of, handler_ops, handled = {}, set(), {}
2621
+ for f, body in text.items():
2622
+ parts = f.split("/")
2623
+ _i, op = _feature_layer_op(parts[:-1], children, os.path.splitext(parts[-1])[0])
2624
+ if op:
2625
+ slice_of[f] = op
2626
+ for m in HANDLER_CLASS_RE.finditer(body):
2627
+ if not op:
2628
+ handler_ops.add(m.group(1))
2629
+ # The message a handler declares it takes names the operation too —
2630
+ # IQueryHandler<ListOrdersQuery, …> — whatever the handler class is called.
2631
+ brace = body.find("{", m.end())
2632
+ head = body[m.end():brace if brace != -1 else m.end() + 400][:400]
2633
+ for msg in re.findall(r"\b\w*Handler\s*<\s*([A-Z]\w*)", head):
2634
+ handled.setdefault(msg, op or m.group(1))
2635
+
2636
+ decl_of, tinfo = {}, {}
2637
+ for f, body in text.items():
2638
+ for kind, n, header, _start, end in _type_decls(f, body):
2639
+ decl_of.setdefault(f, set()).add(n)
2640
+ tinfo.setdefault(n, (f, kind, header, end))
2641
+
2642
+ uc_type_op, usecases, uc_decl = {}, {}, {}
2643
+ for n, (f, kind, _h, end) in sorted(tinfo.items()):
2644
+ m = USECASE_NAME_RE.match(n)
2645
+ # A use case named after a type (MedicationOrderUseCase) is that type's service,
2646
+ # like one declaring several operations: its callers are the operations.
2647
+ if not m or f in slice_of or m.group(1) in tinfo:
2648
+ continue
2649
+ lang = _lang_of(f)
2650
+ if _public_operations(lang, kind, n, _type_body(lang, text[f], end), text[f]) > 1:
2651
+ continue
2652
+ op = m.group(1)
2653
+ uc_type_op[n] = op
2654
+ usecases.setdefault(op, [f])
2655
+ uc_decl.setdefault(op, f)
2656
+ for n, (f, _k, header, _e) in tinfo.items():
2657
+ for t in set(IDENT_RE.findall(header)) & set(uc_type_op):
2658
+ if f not in usecases[uc_type_op[t]] and f not in slice_of:
2659
+ usecases[uc_type_op[t]].append(f)
2660
+
2661
+ # Functions by name, for a router call that is handed one defined elsewhere:
2662
+ # router.post("/", recordEncounter) and r.Post("/", h.RecordEncounter) are read
2663
+ # through the body of the function they name.
2664
+ func_body = {}
2665
+ for f, body in text.items():
2666
+ for rx in FUNC_DEF_RES.get(_lang_of(f), ()):
2667
+ for m in rx.finditer(body):
2668
+ brace = body.find("{", m.end())
2669
+ fb = body[brace:_close_bracket(body, brace) + 1] if brace != -1 else ""
2670
+ func_body.setdefault(m.group(1), (f, fb))
2671
+
2672
+ def model_file(f):
2673
+ return bool(DOMAIN_DIR_RE.search(os.path.dirname(f) + "/")) or any(
2674
+ AGG_ROOT_MARKER_RE.search(tinfo[n][2]) or ENTITY_MARKER_RE.search(tinfo[n][2])
2675
+ for n in decl_of.get(f, ()) if tinfo[n][0] == f)
2676
+ taken = {f for fs in usecases.values() for f in fs}
2677
+ for f in sorted(text):
2678
+ if f in slice_of or f in taken or model_file(f):
2679
+ continue
2680
+ names = set(decl_of.get(f, ())) | {os.path.splitext(os.path.basename(f))[0]}
2681
+ op = _named_after(names, {o: o for o in usecases})
2682
+ if op:
2683
+ usecases[op].append(f)
2684
+
2685
+ known = set(slice_of.values()) | handler_ops | set(usecases)
2686
+ index = dict(uc_type_op)
2687
+ for n in sorted(known):
2688
+ for form in (n, n + "Command", n + "Query", n + "Request", n + "UseCase", "I" + n + "UseCase",
2689
+ n + "Interactor", n + "Handler", n + "CommandHandler", n + "QueryHandler"):
2690
+ index.setdefault(form, n)
2691
+ index.update(handled)
2692
+
2693
+ # A router mounted in another file — app.use("/care/encounters", encounterRouter) in
2694
+ # app.ts — prefixes the routes declared on that router where it is defined. Taken only
2695
+ # for a router the mounting file imports rather than defines, and mounted under one
2696
+ # prefix lane-wide: a name mounted twice says nothing about either.
2697
+ mounted = {}
2698
+ for f, body in text.items():
2699
+ if _lang_of(f) != "js":
2700
+ continue
2701
+ for m in JS_MOUNT_RE.finditer(body):
2702
+ var = m.group(3)
2703
+ if not re.search(rf"\b(?:const|let|var)\s+{re.escape(var)}\s*=", body):
2704
+ mounted.setdefault(var, set()).add(m.group(2))
2705
+ mounts = {v: next(iter(p)) for v, p in mounted.items() if len(p) == 1}
2706
+
2707
+ renamed, endpoints = {}, {}
2708
+ for f in sorted(text):
2709
+ if f in slice_of:
2710
+ continue
2711
+ eps = _file_endpoints(f, text[f], mounts)
2712
+ if not eps:
2713
+ continue
2714
+ rx, tg, vg = VAR_TYPE_RES.get(_lang_of(f), (None, 0, 0))
2715
+ file_types = {}
2716
+ for m in rx.finditer(text[f]) if rx else ():
2717
+ file_types.setdefault(m.group(vg), m.group(tg))
2718
+ for ep in eps:
2719
+ called = func_body.get(ep.get("called") or "")
2720
+ if called and called[1] not in ep["region"]:
2721
+ ep["region"] += "\n" + called[1]
2722
+ # A variable has the type its endpoint declares it with, else the type its
2723
+ # file does: two actions that each take a `command` take two commands.
2724
+ var_type = dict(file_types)
2725
+ own = {}
2726
+ for m in rx.finditer(ep["region"]) if rx else ():
2727
+ own.setdefault(m.group(vg), m.group(tg))
2728
+ var_type.update(own)
2729
+ # What the handler CALLS outranks what it merely names: a variable bound to a
2730
+ # use case or handler first, then a command, query or use-case type named in
2731
+ # the body — a request DTO in the signature can name another operation.
2732
+ words = IDENT_RE.findall(ep["region"])
2733
+ invoked = next((index[var_type[w]] for w in words
2734
+ if w in var_type and var_type[w] in index), None) or \
2735
+ next((index[w] for w in words if w in index), None)
2736
+ # The published id names the operation where it is an identifier, in the
2737
+ # PascalCase every operation name wears (recordEncounter, record_encounter →
2738
+ # RecordEncounter); a kebab or dotted id is recorded, never made a name. The id
2739
+ # itself is kept verbatim in "ids".
2740
+ opid = _pascal(ep["opid"]) if re.fullmatch(r"\w+", ep["opid"]) else ""
2741
+ if opid and (opid in known or opid in index):
2742
+ op = index.get(opid, opid)
2743
+ elif invoked:
2744
+ op = invoked
2745
+ if opid and invoked in usecases and invoked not in slice_of.values() \
2746
+ and invoked not in handler_ops:
2747
+ renamed.setdefault(invoked, opid)
2748
+ else:
2749
+ op = opid or _endpoint_name(ep["handler"], ep["noun"], ep["routes"])
2750
+ rec = endpoints.setdefault(op, {"files": [], "regions": [], "routes": [], "evidence": [],
2751
+ "ids": []})
2752
+ if f not in rec["files"]:
2753
+ rec["files"].append(f)
2754
+ if ep["opid"] and ep["opid"] not in rec["ids"]:
2755
+ rec["ids"].append(ep["opid"])
2756
+ rec["regions"].append(ep["region"])
2757
+ rec["routes"] += [r for r in ep["routes"] if r not in rec["routes"]]
2758
+ if not invoked and op not in known:
2759
+ for w in set(IDENT_RE.findall(ep["region"])):
2760
+ t = tinfo.get(var_type.get(w, w))
2761
+ if t and t[0] != f and not model_file(t[0]):
2762
+ rec["evidence"].append(text[t[0]])
2763
+
2764
+ # An operation the code publishes under an id is known by that id.
2765
+ for old, new in renamed.items():
2766
+ if new in usecases or new in known:
2767
+ continue
2768
+ usecases[new] = usecases.pop(old)
2769
+ uc_decl[new] = uc_decl.pop(old)
2770
+ if old in endpoints:
2771
+ endpoints[new] = endpoints.pop(old)
2772
+ for ep in endpoints.values():
2773
+ ep["regions"] += ep.pop("evidence")
2774
+ return {"usecases": usecases, "uc_decl": uc_decl, "endpoints": endpoints}
2775
+
2776
+
2777
+ # A route Name on an ASP.NET attribute is the id Swashbuckle publishes the action under.
2778
+ CS_ROUTE_NAME_RE = re.compile(r"\[Http\w+\s*\([^)\]]*\bName\s*=\s*\"([\w.\-]+)\"")
2779
+
2780
+
2781
+ def _declared_ids(sources):
2782
+ """The published operation ids that [(file, source), …] declares beside its endpoints,
2783
+ in the order they appear."""
2784
+ out = []
2785
+ for f, body in sources:
2786
+ rxs = OPID_RES + ((CS_ROUTE_NAME_RE,) if _lang_of(f) == "cs" else ())
2787
+ for _p, i in sorted((m.start(), m.group(1)) for rx in rxs for m in rx.finditer(body)):
2788
+ if i not in out:
2789
+ out.append(i)
2790
+ return out
2791
+
2792
+
2793
+ # ── Ids published BY ROUTE: an API document, or doc comments that generate one ──
2794
+ #
2795
+ # Some stacks keep an operation's id away from its endpoint: in an OpenAPI or Swagger
2796
+ # document checked in beside the code, or in doc comments a generator reads
2797
+ # (swagger-jsdoc, swag). Each declares a verb, a path and an id; the id belongs to the
2798
+ # operation serving that route (_route_key), whatever the operation is called.
2799
+ API_DOC_RE = re.compile(r"\.(?:json|ya?ml)$", re.IGNORECASE)
2800
+ API_DOC_HEAD_RE = re.compile(r"""^[\s{]*["']?(?:openapi|swagger)["']?\s*:\s*["']?\d""", re.M)
2801
+ API_DOC_CAP = 8_000_000
2802
+ HTTP_VERBS = ("get", "post", "put", "patch", "delete", "head", "options")
2803
+ JSDOC_API_RE = re.compile(r"/\*\*((?:(?!\*/).)*?@(?:openapi|swagger)\b.*?)\*/", re.S)
2804
+ SWAG_ID_RE = re.compile(r"@ID\s+(\S+)")
2805
+ SWAG_ROUTER_RE = re.compile(r"@Router\s+(\S+)\s+\[(\w+)\]")
2806
+ SWAG_BASE_RE = re.compile(r"^\s*//\s*@BasePath\s+(\S+)", re.M)
2807
+
2808
+
2809
+ def _api_document(root, rel):
2810
+ """The text of an OpenAPI or Swagger document — known by its own top-level key,
2811
+ whatever the file is called — or "" when the file is not one."""
2812
+ try:
2813
+ p = os.path.join(root, rel)
2814
+ if os.path.getsize(p) > API_DOC_CAP:
2815
+ return ""
2816
+ with open(p, "r", encoding="utf-8", errors="replace") as fh:
2817
+ head = fh.read(4096)
2818
+ return head + fh.read() if API_DOC_HEAD_RE.search(head) else ""
2819
+ except OSError:
2820
+ return ""
2821
+
2822
+
2823
+ def _yaml_operation_ids(text, under_paths=True):
2824
+ """(VERB, path, id) for each operation a block-style OpenAPI YAML declares — under its
2825
+ `paths:` key, or at the top of a fragment (`under_paths=False`) as a doc comment
2826
+ writes it. Only an operation's own operationId counts, never one a link nested in it
2827
+ names."""
2828
+ out = []
2829
+ paths_ind = None if under_paths else -1
2830
+ path_ind = path = vlevel = verb = olevel = None
2831
+ for raw in text.splitlines():
2832
+ t = raw.strip()
2833
+ if not t or t.startswith("#"):
2834
+ continue
2835
+ ind = len(raw) - len(raw.lstrip())
2836
+ if paths_ind is None:
2837
+ if re.fullmatch(r"paths\s*:", t):
2838
+ paths_ind = ind
2839
+ continue
2840
+ if ind <= paths_ind:
2841
+ break
2842
+ if path_ind is None:
2843
+ path_ind = ind
2844
+ if ind < path_ind:
2845
+ break
2846
+ if ind == path_ind:
2847
+ m = re.fullmatch(r"""["']?(/[^"']*?)["']?\s*:""", t)
2848
+ path, vlevel, verb = (m.group(1) if m else None), None, None
2849
+ continue
2850
+ if path is None:
2851
+ continue
2852
+ if vlevel is None:
2853
+ vlevel = ind
2854
+ if ind == vlevel:
2855
+ m = re.fullmatch(r"(\w+)\s*:", t)
2856
+ verb = m.group(1).upper() if m and m.group(1).lower() in HTTP_VERBS else None
2857
+ olevel = None
2858
+ continue
2859
+ if verb is None or ind < vlevel:
2860
+ continue
2861
+ if olevel is None:
2862
+ olevel = ind
2863
+ if ind == olevel:
2864
+ m = re.match(r"""operationId\s*:\s*["']?([\w.\-]+)""", t)
2865
+ if m:
2866
+ out.append((verb, path, m.group(1)))
2867
+ return out
2868
+
2869
+
2870
+ def _doc_base(doc):
2871
+ """The path every route of an API document sits under: Swagger 2's basePath, or the
2872
+ path of OpenAPI 3's first server url."""
2873
+ if isinstance(doc, dict):
2874
+ servers = doc.get("servers") or [{}]
2875
+ base = doc.get("basePath") or (servers[0].get("url", "") if isinstance(servers[0], dict) else "")
2876
+ else:
2877
+ m = re.search(r"""^basePath\s*:\s*["']?([^"'\s]+)""", doc, re.M) or \
2878
+ re.search(r"""^servers\s*:\s*\n\s*-\s*url\s*:\s*["']?([^"'\s]+)""", doc, re.M)
2879
+ base = m.group(1) if m else ""
2880
+ return re.sub(r"^\w+://[^/]*", "", str(base or ""))
2881
+
2882
+
2883
+ def published_ids(root, files):
2884
+ """(in_code, in_docs) — each {route key: [id, …]} — for the ids the lane publishes by
2885
+ route rather than beside the endpoint (see the section comment above): `in_code` from
2886
+ the doc comments in its source, `in_docs` from the API documents it keeps. Test files
2887
+ are not read."""
2888
+ in_code, in_docs = {}, {}
2889
+
2890
+ def add(verb, path, oid, base="", out=in_code):
2891
+ lst = out.setdefault(_route_key(verb, _join_route(base, path)), [])
2892
+ if oid not in lst:
2893
+ lst.append(oid)
2894
+
2895
+ swag, swag_base = [], ""
2896
+ for f in files:
2897
+ if TEST_PATH_RE.search(f):
2898
+ continue
2899
+ if API_DOC_RE.search(f):
2900
+ text = _api_document(root, f)
2901
+ if not text:
2902
+ continue
2903
+ if f.lower().endswith(".json"):
2904
+ try:
2905
+ doc = json.loads(text)
2906
+ except ValueError:
2907
+ continue
2908
+ base = _doc_base(doc)
2909
+ for path, item in (doc.get("paths") or {}).items() if isinstance(doc, dict) else ():
2910
+ for verb, o in (item.items() if isinstance(item, dict) else ()):
2911
+ if verb.lower() in HTTP_VERBS and isinstance(o, dict) and o.get("operationId"):
2912
+ add(verb, path, str(o["operationId"]), base, in_docs)
2913
+ else:
2914
+ base = _doc_base(text)
2915
+ for verb, path, oid in _yaml_operation_ids(text):
2916
+ add(verb, path, oid, base, in_docs)
2917
+ continue
2918
+ lang = _lang_of(f)
2919
+ if lang == "js":
2920
+ for m in JSDOC_API_RE.finditer(_read_source(root, f)):
2921
+ frag = "\n".join(re.sub(r"^\s*\*\s?", "", ln) for ln in m.group(1).splitlines())
2922
+ frag = re.sub(r"^.*@(?:openapi|swagger)\b.*$", "", frag, flags=re.M)
2923
+ for verb, path, oid in _yaml_operation_ids(frag, under_paths=False):
2924
+ add(verb, path, oid)
2925
+ elif lang == "go":
2926
+ body = _read_source(root, f)
2927
+ swag_base = swag_base or next((m.group(1) for m in [SWAG_BASE_RE.search(body)] if m), "")
2928
+ block = []
2929
+ for ln in body.splitlines() + [""]:
2930
+ if ln.strip().startswith("//"):
2931
+ block.append(ln)
2932
+ continue
2933
+ ids = [m.group(1) for b in block for m in [SWAG_ID_RE.search(b)] if m]
2934
+ if ids:
2935
+ swag += [(m.group(2), m.group(1), ids[0])
2936
+ for b in block for m in [SWAG_ROUTER_RE.search(b)] if m]
2937
+ block = []
2938
+ for verb, path, oid in swag:
2939
+ add(verb, path, oid, swag_base)
2940
+ return in_code, in_docs
2941
+
2942
+
1870
2943
  _DOMAIN_MEMO = {}
1871
2944
 
1872
2945
 
@@ -1886,8 +2959,9 @@ def parse_domain(root, files):
1886
2959
  configuration, a DTO mapping) belongs to the owner of what it references. A type
1887
2960
  two roots reach at the same distance, with neither's name as its prefix, is the
1888
2961
  module's shared kernel — PatientId in a context that only references Patient.
1889
- Operations are read in the module and, for a solution split one project per layer,
1890
- across the lane; what is left is placed by _attribute_leftovers."""
2962
+ Operations — slices, handler classes, use cases and endpoints — are read in the
2963
+ module and, for a solution split one project per layer, across the lane; what is
2964
+ left is placed by _attribute_leftovers."""
1891
2965
  if root is None:
1892
2966
  return {"modules": {}, "types": {}, "owner": {}, "shared": {},
1893
2967
  "operations": [], "root_edges": {}}
@@ -1905,12 +2979,10 @@ def parse_domain(root, files):
1905
2979
  if not body:
1906
2980
  continue
1907
2981
  text[f] = body
1908
- for m in TYPE_DECL_RE.finditer(body):
1909
- name = m.group(2)
2982
+ for kind, name, header, start, _end in _type_decls(f, body):
1910
2983
  if name in types:
1911
2984
  continue
1912
- role = _type_role(m.group(1), name, _type_header(body, m.end()), f,
1913
- body[max(0, m.start() - 200):m.start()])
2985
+ role = _type_role(kind, name, header, f, body[max(0, start - 200):start])
1914
2986
  types[name] = {"file": f, "role": role, "module": module_of[f]}
1915
2987
  decl_of.setdefault(f, set()).add(name)
1916
2988
 
@@ -1920,7 +2992,7 @@ def parse_domain(root, files):
1920
2992
  for n, t in sorted(roots.items()):
1921
2993
  key = t["module"]
1922
2994
  seg = key.rsplit("/", 1)[-1]
1923
- ctx = _clean_project_name(seg, product) if DOTTED_SEG_RE.match(seg) else seg
2995
+ ctx = _clean_project_name(seg, product) if DOTTED_SEG_RE.match(seg) else _pascal(seg)
1924
2996
  mod = modules.setdefault(key, {"context": ctx, "dir": key, "roots": [], "files": []})
1925
2997
  mod["roots"].append(n)
1926
2998
  for f, key in module_of.items():
@@ -1937,15 +3009,28 @@ def parse_domain(root, files):
1937
3009
  refs[f] = (set(IDENT_RE.findall(body)) & types_in[module_of[f]]) - decl_of.get(f, set())
1938
3010
 
1939
3011
  owner, shared, ops = {}, {}, {}
3012
+ pub = _published_operations(text, children) if modules else \
3013
+ {"usecases": {}, "uc_decl": {}, "endpoints": {}}
1940
3014
 
1941
- def _add_op(op, fs, in_scope, key=None):
3015
+ def _add_op(op, fs, in_scope, key=None, served=None, require_target=False):
1942
3016
  """Record one operation: the roots its files name (a root by name, or a
1943
3017
  specification of one, is strong; a type a root owns is weak), filed under the
1944
3018
  module `key`, or — with none given — under the module of the root it acts on,
1945
- and dropped when it acts on no root at all."""
3019
+ and dropped when it acts on no root at all.
3020
+
3021
+ `served` is what the operation's ENDPOINTS add (see _published_operations):
3022
+ their files, which a controller shares with other operations and which are
3023
+ therefore read through each endpoint's own region rather than whole, the
3024
+ routes joined there, and the verbs that say whether it reads or writes. An
3025
+ operation only an endpoint declares (`require_target`) is dropped when it acts
3026
+ on no root, wherever it is filed."""
3027
+ served = served or {}
3028
+ shared = set(served.get("files", ()))
3029
+ fs = list(fs) + [f for f in served.get("files", ()) if f not in fs]
3030
+ scan = [text.get(f, "") for f in fs if f not in shared] + list(served.get("regions", ()))
1946
3031
  strong, weak = {}, {}
1947
- for f in fs:
1948
- for tn in IDENT_RE.findall(text.get(f, "")):
3032
+ for body in scan:
3033
+ for tn in IDENT_RE.findall(body):
1949
3034
  t = types.get(tn)
1950
3035
  if not t or not in_scope(t["module"]):
1951
3036
  continue
@@ -1959,26 +3044,33 @@ def parse_domain(root, files):
1959
3044
  pick = strong or weak
1960
3045
  acts_on = sorted(pick, key=lambda r: (-pick[r], r)) if strong else \
1961
3046
  ([max(sorted(weak), key=weak.get)] if weak else [])
3047
+ if not acts_on and (key is None or require_target):
3048
+ return
1962
3049
  if key is None:
1963
- if not acts_on:
1964
- return
1965
3050
  key = types[acts_on[0]]["module"]
1966
3051
  ctx = modules[key]["context"]
1967
3052
  routes = []
1968
3053
  for f in fs:
1969
- routes.extend(_routes_in(text.get(f, "")))
3054
+ if f not in shared:
3055
+ routes.extend(_routes_in(text.get(f, "")))
3056
+ routes += list(served.get("routes", ()))
1970
3057
  # An operation over two aggregates is FILED under the one its route names:
1971
3058
  # POST /access/appointments schedules an Appointment, whatever it loads.
1972
3059
  fams = {_ui_norm(_route_family(p, (ctx,))) for _v, p in routes}
1973
3060
  named = [r for r in acts_on if _ui_norm(r) in fams]
1974
3061
  if named:
1975
3062
  acts_on = named[:1] + [r for r in acts_on if r != named[0]]
3063
+ ids = list(served.get("ids", ()))
3064
+ ids += [i for i in _declared_ids([(f, text.get(f, "")) for f in fs if f not in shared])
3065
+ if i not in ids]
1976
3066
  ops[op] = {
1977
3067
  "name": op, "context": ctx, "module": key,
1978
3068
  "files": sorted(set(fs)), "acts_on": acts_on,
1979
- "kind": "query" if QUERY_NAME_RE.match(op) or any(
1980
- re.search(r"Query\b", os.path.basename(f)) for f in fs) else "command",
3069
+ "kind": _verb_kind(served.get("routes", ())) or (
3070
+ "query" if QUERY_NAME_RE.match(op) or any(
3071
+ re.search(r"Query\b", os.path.basename(f)) for f in fs) else "command"),
1981
3072
  "routes": sorted(set(routes)),
3073
+ "operation_ids": ids,
1982
3074
  }
1983
3075
  for f in fs:
1984
3076
  if acts_on and f not in owner:
@@ -2058,20 +3150,25 @@ def parse_domain(root, files):
2058
3150
  elif f in best:
2059
3151
  shared.setdefault(key, []).append(f)
2060
3152
 
2061
- # Slice folders, plus handler classes outside any feature layer.
3153
+ # Slice folders, plus handler classes outside any feature layer, plus the use
3154
+ # cases this module declares. Each takes the endpoints that serve it.
2062
3155
  for f in plain:
2063
3156
  for m in HANDLER_CLASS_RE.finditer(text.get(f, "")):
2064
3157
  op_files.setdefault(m.group(1), []).append(f)
3158
+ for op, fs in pub["usecases"].items():
3159
+ if module_of.get(pub["uc_decl"][op]) == key:
3160
+ lst = op_files.setdefault(op, [])
3161
+ lst += [f for f in fs if f not in lst]
2065
3162
  for op, fs in sorted(op_files.items()):
2066
- _add_op(op, fs, lambda m, key=key: m == key, key)
3163
+ _add_op(op, fs, lambda m, key=key: m == key, key, pub["endpoints"].get(op))
2067
3164
 
2068
3165
  # Operations OUTSIDE every module that declares a root. A solution split into one
2069
3166
  # project per layer (Domain / Application / Infrastructure / Api) keeps its roots in
2070
3167
  # one project and its handlers in another, so a module-local search finds none —
2071
3168
  # and the model, which exists to be acted on, reads as having no operations. The
2072
- # same two shapes are read here (a feature-layer slice, a CQRS handler class), and
2073
- # an operation counts only when it acts on a declared root: a handler that touches
2074
- # no aggregate of the model is not one of its operations.
3169
+ # same shapes are read here (a feature-layer slice, a CQRS handler class, a use
3170
+ # case), and an operation counts only when it acts on a declared root: a handler
3171
+ # that touches no aggregate of the model is not one of its operations.
2075
3172
  in_model = set(modules)
2076
3173
  outside = [f for f in text if module_of[f] not in in_model]
2077
3174
  out_files = {}
@@ -2083,6 +3180,10 @@ def parse_domain(root, files):
2083
3180
  continue
2084
3181
  for m in HANDLER_CLASS_RE.finditer(text[f]):
2085
3182
  out_files.setdefault(m.group(1), []).append(f)
3183
+ for op, fs in pub["usecases"].items():
3184
+ if module_of.get(pub["uc_decl"][op]) not in in_model:
3185
+ lst = out_files.setdefault(op, [])
3186
+ lst += [f for f in fs if f not in lst]
2086
3187
  out_files = {op: fs for op, fs in out_files.items() if op not in ops}
2087
3188
  # A layer-per-project solution files one operation across several projects — the
2088
3189
  # command, its validator and its endpoint beside the handler — and names each file
@@ -2097,7 +3198,14 @@ def parse_domain(root, files):
2097
3198
  if op:
2098
3199
  out_files[op].append(f)
2099
3200
  for op, fs in sorted(out_files.items()):
2100
- _add_op(op, fs, lambda m: m in in_model)
3201
+ _add_op(op, fs, lambda m: m in in_model, served=pub["endpoints"].get(op))
3202
+ # An endpoint that invokes none of the operations above declares one of its own —
3203
+ # a controller action that does its work inline.
3204
+ for op, served in sorted(pub["endpoints"].items()):
3205
+ if op not in ops:
3206
+ k = module_of.get(served["files"][0])
3207
+ _add_op(op, [], lambda m: m in in_model, k if k in in_model else None, served,
3208
+ require_target=True)
2101
3209
 
2102
3210
  # Everything above decides what orders the aggregates; what follows only decides
2103
3211
  # which aggregate a file is LISTED under. Ordering is kept to the evidence it always
@@ -2164,8 +3272,7 @@ def strategy_domain(root, files, churn_map, graph=None):
2164
3272
  c = cands.get(r.lower())
2165
3273
  if not c:
2166
3274
  continue
2167
- kind = next((_layer_of(p, soft=True) for p in f.split("/")[:-1]
2168
- if _layer_of(p, soft=True)), "domain")
3275
+ kind = _bucket_of(f.split("/")[:-1], "domain")
2169
3276
  c[kind + "_files"].append(f)
2170
3277
  c["churn"] += churn_map.get(f, 0)
2171
3278
  return _finish_candidates(cands, churn_map, "domain")
@@ -2415,8 +3522,7 @@ def seam_overlay(root, files, candidates, churn_map, ui_attribution=None, contex
2415
3522
  continue
2416
3523
  # Application / API / UI only. Domain files say what must stay
2417
3524
  # consistent; they never say where traffic can be cut over.
2418
- kind = next((_layer_of(p, soft=True) for p in f.split("/")[:-1]
2419
- if _layer_of(p, soft=True)), "other")
3525
+ kind = _bucket_of(f.split("/")[:-1], "other")
2420
3526
  if kind == "domain":
2421
3527
  continue
2422
3528
  out[name]["churn"] += churn_map.get(f, 0)
@@ -2484,6 +3590,23 @@ def _capability_name(aggregate, seam):
2484
3590
  return _pascal_name(aggregate) + pascal
2485
3591
 
2486
3592
 
3593
+ def _common_dir(files):
3594
+ """The directory every one of `files` sits under, '/'-terminated, or ''."""
3595
+ common = None
3596
+ for f in files:
3597
+ d = f.split("/")[:-1]
3598
+ if common is None:
3599
+ common = d
3600
+ continue
3601
+ n = 0
3602
+ while n < len(common) and n < len(d) and common[n] == d[n]:
3603
+ n += 1
3604
+ common = common[:n]
3605
+ if not common:
3606
+ break
3607
+ return "/".join(common) + "/" if common else ""
3608
+
3609
+
2487
3610
  def derive_contexts(candidates, files):
2488
3611
  """Group candidates into bounded contexts, primed by the highest directory
2489
3612
  level that actually separates them.
@@ -2491,12 +3614,53 @@ def derive_contexts(candidates, files):
2491
3614
  A context is a grouping of aggregates that share a model. The top-level
2492
3615
  source directory is the best available prior — in a component monorepo those
2493
3616
  directories ARE the contexts — and the operator confirms or rewrites it.
2494
- Where the tree has no such level, the whole repo is one context."""
3617
+ Where the tree has no such level, the whole repo is one context.
3618
+
3619
+ A directory every file sits under separates nothing — a declared lane's root
3620
+ folder (`backend/`) is the common case — so it is never a context: the segments
3621
+ are read below it."""
3622
+ shared = _common_dir(files)
3623
+ children = _child_dirs(files)
3624
+ holds_files = {f.rsplit("/", 1)[0] for f in files if "/" in f}
3625
+
2495
3626
  def context_segment(path):
2496
- """The first path segment that separates one context from another."""
2497
- for part in (path or "").split("/"):
2498
- if not part or CONTAINER_DIR_RE.match(part):
3627
+ """The first folder that separates one context from another. A container folder
3628
+ is read through, and so is a layer folder beside another layer folder: a tree
3629
+ organised by layer at that level (src/controllers/ beside src/models/) names no
3630
+ context there. Below such a layer, a folder is a context only when another of
3631
+ those layers holds a folder of the same name (controllers/care/ beside
3632
+ models/care/); any other is part of its one layer (adapter/in/, domain/model/).
3633
+ A layer-named folder among modules (a Repository/ component) is read as one.
3634
+
3635
+ A JVM package's reverse-domain prefix is read through as well: below a JVM source
3636
+ folder, the vendor folder (com/, io/) and each folder after it that is the only
3637
+ folder in a parent holding no source file (acme/clinic/). The walk resumes at the
3638
+ base package, so a single module folder below it is still read as one."""
3639
+ path = path or ""
3640
+ parent = ""
3641
+ if shared and (path + "/").startswith(shared):
3642
+ parent, path = shared.rstrip("/"), path[len(shared):]
3643
+ parts = [p for p in path.split("/") if p]
3644
+ prev, in_prefix = parent.rsplit("/", 1)[-1], False
3645
+ for i, part in enumerate(parts):
3646
+ kids = children.get(parent, ())
3647
+ if (prev.lower() in JVM_SOURCE_DIRS and VENDOR_PREFIX_RE.match(part)) or (
3648
+ in_prefix and len(kids) == 1 and parent not in holds_files):
3649
+ prev, in_prefix = part, True
3650
+ parent = f"{parent}/{part}" if parent else part
2499
3651
  continue
3652
+ prev, in_prefix = part, False
3653
+ if CONTAINER_DIR_RE.match(part):
3654
+ parent = f"{parent}/{part}" if parent else part
3655
+ continue
3656
+ layers = [s for s in kids
3657
+ if s != part and (_layer_of(s) or _paired_layer(parent, s, children))]
3658
+ if (_layer_of(part) or _paired_layer(parent, part, children)) and layers:
3659
+ below = parts[i + 1] if i + 1 < len(parts) else ""
3660
+ if below and any(below in children.get(f"{parent}/{s}" if parent else s, ())
3661
+ for s in layers):
3662
+ return below
3663
+ return ""
2500
3664
  if DOTTED_SEG_RE.match(part):
2501
3665
  bits = part.split(".")
2502
3666
  return bits[1] if len(bits) > 2 else bits[0]
@@ -2505,10 +3669,10 @@ def derive_contexts(candidates, files):
2505
3669
 
2506
3670
  # A DECLARED context — the module folder that holds a Domain layer, which the kit's
2507
3671
  # folder-structure rule makes one folder per bounded context — is taken as it
2508
- # stands: its name verbatim (it is the name specs/<Context>/ must carry for a slice
2509
- # to be traceable in both trees), and never folded for holding one aggregate. A
2510
- # context with one aggregate is a real and common shape; only an INFERRED path
2511
- # segment can be incidental.
3672
+ # stands: the module folder's name in the PascalCase every name in specs/ carries
3673
+ # (`care/` → Care, `Modules/Care/` → Care), and never folded for holding one
3674
+ # aggregate. A context with one aggregate is a real and common shape; only an
3675
+ # INFERRED path segment can be incidental.
2512
3676
  declared = {}
2513
3677
  for c in candidates:
2514
3678
  if c.get("context"):
@@ -2526,10 +3690,10 @@ def derive_contexts(candidates, files):
2526
3690
  votes = {}
2527
3691
  for k in LAYER_KEYS:
2528
3692
  for f in c[k]:
2529
- seg = context_segment(f)
3693
+ seg = context_segment(f.rsplit("/", 1)[0] if "/" in f else "")
2530
3694
  if seg:
2531
3695
  votes[seg] = votes.get(seg, 0) + 1
2532
- seg = max(sorted(votes), key=votes.get) if votes else context_segment(c.get("module_dir"))
3696
+ seg = max(sorted(votes), key=votes.get) if votes else ""
2533
3697
  groups.setdefault(_pascal_name(singularize(seg)) or "Core", []).append(c["name"])
2534
3698
 
2535
3699
  # A context holding one aggregate in a repo that has several is usually the
@@ -2591,7 +3755,8 @@ def _creating_op(aggregate, ops, context):
2591
3755
  return None
2592
3756
 
2593
3757
 
2594
- def propose_features(candidates, seams, edges, contexts, operations=None, ui_feats=None):
3758
+ def propose_features(candidates, seams, edges, contexts, operations=None, ui_feats=None,
3759
+ ui_calls=None):
2595
3760
  """Features, proposed from the one kind of evidence that names a capability.
2596
3761
 
2597
3762
  GREENFIELD SHAPE, READ BACK. A feature is a capability the developer chose; it
@@ -2599,10 +3764,14 @@ def propose_features(candidates, seams, edges, contexts, operations=None, ui_fea
2599
3764
  on one aggregate. Its operations are what its user stories need done — several to a
2600
3765
  story as often as not, and a read may serve more than one feature. Code records a feature in
2601
3766
  exactly one place — the UI lane's features/<Feature>/ folder, named after the spec
2602
- feature verbatim — so:
3767
+ feature verbatim, or a frontend's own feature folders where it has none (see
3768
+ ui_features) — so:
2603
3769
 
2604
3770
  1. every UI feature folder becomes a feature, named verbatim, holding the
2605
- operations it calls and acting on those operations' aggregates;
3771
+ operations it calls and acting on those operations' aggregates. A call
3772
+ (`ui_calls`, see ui_api_calls) reaches the operation whose route it is, by
3773
+ verb and path (_route_key) — whatever the API functions are called; an
3774
+ imported function or the folder itself named after an operation links too;
2606
3775
  2. a read no feature calls is SHARED — it joins every feature acting on its
2607
3776
  aggregate, because reads are not what separates two capabilities;
2608
3777
  3. commands no feature calls, and aggregates no UI feature touches, fall back to
@@ -2633,6 +3802,18 @@ def propose_features(candidates, seams, edges, contexts, operations=None, ui_fea
2633
3802
  fam_to_agg.setdefault(_ui_norm(fam), {}).setdefault(o["acts_on"][0], 0)
2634
3803
  fam_to_agg[_ui_norm(fam)][o["acts_on"][0]] += 1
2635
3804
 
3805
+ # The service lane's routes, as a UI call writes them. Every operation counts: a
3806
+ # call to one that acts on nothing the scan placed is still served.
3807
+ by_route = {}
3808
+ for o in operations or []:
3809
+ for v, p in o.get("routes") or []:
3810
+ by_route.setdefault(_route_key(v, p), []).append(o)
3811
+ live = {o["name"]: o for o in ops}
3812
+
3813
+ def served(c):
3814
+ k = _route_key(c["verb"], c["path"])
3815
+ return by_route.get(k) or by_route.get(("", k[1])) or []
3816
+
2636
3817
  feats, claimed, unlinked = [], set(), []
2637
3818
  for name, rec in sorted((ui_feats or {}).items()):
2638
3819
  mine = []
@@ -2641,6 +3822,9 @@ def propose_features(candidates, seams, edges, contexts, operations=None, ui_fea
2641
3822
  o = op_by_key.get(k) or op_by_key.get(re.sub(r"action$", "", k))
2642
3823
  if o and o not in mine:
2643
3824
  mine.append(o)
3825
+ by_call = {o["name"] for c in _ui_reached(ui_calls or [], rec)
3826
+ for o in served(c) if o["name"] in live}
3827
+ mine += [live[n] for n in sorted(by_call) if live[n] not in mine]
2644
3828
  aggs = []
2645
3829
  for o in mine:
2646
3830
  aggs += [a for a in o["acts_on"] if a not in aggs]
@@ -2652,8 +3836,9 @@ def propose_features(candidates, seams, edges, contexts, operations=None, ui_fea
2652
3836
  a = max(sorted(hit), key=hit.get)
2653
3837
  if a not in aggs:
2654
3838
  aggs.append(a)
3839
+ roots = rec.get("roots") or [f"features/{name}/"]
2655
3840
  if not aggs:
2656
- unlinked.append(name)
3841
+ unlinked.append(roots)
2657
3842
  continue
2658
3843
  votes = {}
2659
3844
  for a in aggs:
@@ -2664,7 +3849,7 @@ def propose_features(candidates, seams, edges, contexts, operations=None, ui_fea
2664
3849
  "aggregates": [a for a in aggs if ctx_of.get(a, "Core") == ctx],
2665
3850
  "references": [a for a in aggs if ctx_of.get(a, "Core") != ctx],
2666
3851
  "operations": [o["name"] for o in mine], "called": len(mine),
2667
- "source": "ui-feature", "ui_files": len(rec["files"])})
3852
+ "source": "ui-feature", "ui_files": len(rec["files"]), "roots": roots})
2668
3853
 
2669
3854
  # Shared reads join every feature already acting on their aggregate.
2670
3855
  for o in ops:
@@ -2720,7 +3905,7 @@ def propose_features(candidates, seams, edges, contexts, operations=None, ui_fea
2720
3905
  sole = len([g for g in feats if primary in g["aggregates"]]) == 1
2721
3906
  if f["source"] == "ui-feature":
2722
3907
  shared_reads = len(f["operations"]) - f["called"]
2723
- evidence = (f"UI feature folder features/{f['feature']}/ calls {f['called']} "
3908
+ evidence = (f"{_ui_where(f['roots'])} calls {f['called']} "
2724
3909
  f"operation(s)" + (f" + {shared_reads} shared read(s)" if shared_reads else ""))
2725
3910
  elif f["operations"]:
2726
3911
  evidence = ("aggregate default — no UI feature calls these operations; rename it "
@@ -2754,29 +3939,51 @@ def propose_features(candidates, seams, edges, contexts, operations=None, ui_fea
2754
3939
  out.append(feature)
2755
3940
  out.sort(key=lambda x: (x["context"], -x["files"], x["feature"]))
2756
3941
  # What the operator must decide rather than accept: commands that fell to a default
2757
- # beside real capabilities on the same aggregate, and UI features that call nothing
2758
- # the scan could tie to an aggregate.
3942
+ # beside real capabilities on the same aggregate, UI features that call nothing
3943
+ # the scan could tie to an aggregate, and UI calls no service route serves — read
3944
+ # only where the service lane declares routes at all.
2759
3945
  capab = {a for f in feats if f["source"] == "ui-feature" for a in f["aggregates"]}
3946
+ unserved = {}
3947
+ for c in (ui_calls or []) if by_route else []:
3948
+ if not served(c):
3949
+ u = unserved.setdefault((c["verb"], c["path"]), {"features": set(), "files": []})
3950
+ if c["file"] not in u["files"]:
3951
+ u["files"].append(c["file"])
3952
+ for name, rec in (ui_feats or {}).items():
3953
+ for c in _ui_reached(ui_calls or [], rec):
3954
+ if (c["verb"], c["path"]) in unserved:
3955
+ unserved[(c["verb"], c["path"])]["features"].add(name)
2760
3956
  notes = {
2761
3957
  "orphaned": [(f["feature"], n) for f in feats if f["source"] == "aggregate-default"
2762
3958
  and f["aggregates"][0] in capab for n in f["operations"]],
2763
3959
  "unlinked_ui": unlinked,
3960
+ "unserved_ui": [(f"{v} {p}", sorted(u["features"]), sorted(u["files"]))
3961
+ for (v, p), u in sorted(unserved.items(), key=lambda kv: (kv[0][1], kv[0][0]))],
2764
3962
  }
2765
3963
  return out, notes
2766
3964
 
2767
3965
 
2768
- def aggregate_signals(files, churn_map, root=None):
3966
+ def aggregate_signals(files, churn_map, root=None, strategy=""):
2769
3967
  """Derive CANDIDATE aggregate roots — the unit onboard writes one OKF concept for.
2770
3968
 
2771
- Where the code DECLARES its roots and operations act on them (and `root` is given
2772
- so the source can be read), those are the candidates, each stamped with its
2773
- bounded context and its operations — the same rule choose_strategy applies.
2774
- Otherwise Pass A's structure strategy reads the repo's organising axis
2775
- (module-major, project-major, vertical slice, n-tier, or the build manifest)
2776
- instead of assuming one. Every caller gets the same candidate shape either way.
3969
+ `strategy` is the Pass A strategy the feature map was built from. Read with it (and
3970
+ `root` given so the source can be read), the candidates are the aggregates that map
3971
+ names, each with its files, whichever strategy the operator chose.
3972
+
3973
+ Without one, or where it finds nothing: where the code DECLARES its roots and
3974
+ operations act on them, those are the candidates, each stamped with its bounded
3975
+ context and its operations — the same rule choose_strategy applies. Otherwise Pass
3976
+ A's structure strategy reads the repo's organising axis (module-major,
3977
+ project-major, vertical slice, n-tier, or the build manifest) instead of assuming
3978
+ one. Every caller gets the same candidate shape either way.
2777
3979
 
2778
3980
  Signals only — the workflow's agent draws the real boundaries. Empty for a
2779
3981
  repo with no recognizable structure at all."""
3982
+ if root and strategy in STRATEGIES:
3983
+ graph = build_reference_graph(root, files)[0] if strategy == "reference" else None
3984
+ cands = STRATEGIES[strategy](root, files, churn_map, graph)
3985
+ if cands:
3986
+ return cands
2780
3987
  if root:
2781
3988
  declared = strategy_domain(root, files, churn_map)
2782
3989
  if any(c.get("operations") for c in declared):
@@ -2843,80 +4050,450 @@ def _ui_name_tokens(name):
2843
4050
  # the service lane's PascalCase Features/<Operation>/ is a different thing (one
2844
4051
  # operation of a module) and must never be read as a capability.
2845
4052
  UI_FEATURE_SEG_RE = re.compile(r"(?:^|/)features/([^/]+)/")
2846
- UI_SOURCE_RE = re.compile(r"\.(tsx?|jsx?|mjs|vue|svelte)$", re.IGNORECASE)
4053
+ UI_SOURCE_RE = re.compile(r"\.(tsx?|jsx?|mjs|vue|svelte|razor|astro)$", re.IGNORECASE)
2847
4054
  API_IMPORT_RE = re.compile(r"""import\s*(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]""")
2848
4055
  API_NS_IMPORT_RE = re.compile(r"""import\s+\*\s+as\s+(\w+)\s+from\s*['"]([^'"]+)['"]""")
2849
4056
  API_MODULE_RE = re.compile(r"(?:^|[/@])apis?(?:/|$)")
2850
4057
  UI_ROUTE_LITERAL_RE = re.compile(r"""[`'"](/[A-Za-z][A-Za-z0-9_\-/${}.:]*)[`'"]""")
4058
+ # Where a frontend keeps its features when it has no features/ folder, in precedence
4059
+ # order: module folders; route-segment folders (Next.js app/, Angular src/app/); page
4060
+ # folders (React, Nuxt, Blazor Pages/); Vue views/; SvelteKit and Remix routes/.
4061
+ UI_FEATURE_CONTAINERS = ("modules", "app", "pages", "views", "routes")
4062
+ # A container's children that are its infrastructure, not a capability.
4063
+ UI_NOT_FEATURE = frozenset((
4064
+ "shared", "common", "core", "components", "component", "ui", "lib", "libs", "utils",
4065
+ "util", "helpers", "hooks", "composables", "layouts", "layout", "styles", "assets",
4066
+ "api", "apis", "services", "service", "store", "stores", "state", "types", "models",
4067
+ "interfaces", "config", "constants", "i18n", "locales", "test", "tests", "__tests__",
4068
+ "mocks", "__mocks__", "fixtures", "providers", "guards", "interceptors", "pipes",
4069
+ "directives", "environments", "index", "page", "error", "loading", "not-found",
4070
+ "notfound", "404", "500", "_app", "_document", "_error", "root", "app", "main",
4071
+ "home", "shell"))
4072
+ UI_PAGE_FILE_RE = re.compile(r"\.(tsx|jsx|vue|svelte|razor|astro)$", re.IGNORECASE)
4073
+ # Names a component brings in beside an ES import: Blazor's @inject, a constructor
4074
+ # parameter's type (Angular services).
4075
+ UI_INJECT_RE = re.compile(r"@inject\s+(?:[\w.]+\.)?([A-Z]\w*)")
4076
+ UI_CTOR_TYPE_RE = re.compile(r"\b[a-z_]\w*\s*\??:\s*(?:\w+\.)?([A-Z]\w*)")
4077
+ UI_DEFAULT_IMPORT_RE = re.compile(r"""import\s+([A-Za-z_$][\w$]*)\s*(?:,\s*\{[^}]*\})?\s*from\s*['"]""")
4078
+
4079
+
4080
+ def _ui_container_hit(rel, container):
4081
+ """(feature, root) for a frontend file under one kind of container folder:
4082
+ <container>/<Feature>/…, or a page file placed directly in the container. `root` is
4083
+ where the feature was read from, from the container segment on: its folder
4084
+ (`app/(clinic)/orders/`) or its page file (`views/OrdersView.vue`)."""
4085
+ parts = rel.split("/")
4086
+ for i, seg in enumerate(parts[:-1]):
4087
+ if seg.lower() != container:
4088
+ continue
4089
+ rest, groups = parts[i + 1:], 0
4090
+ while len(rest) > 1 and re.fullmatch(r"\(.*\)", rest[0]): # Next.js route groups
4091
+ rest, groups = rest[1:], groups + 1
4092
+ if len(rest) == 1:
4093
+ if not UI_PAGE_FILE_RE.search(rest[0]):
4094
+ return None
4095
+ stem = rest[0].split(".")[0]
4096
+ name = re.sub(r"(?:View|Page|Screen)$", "", stem) or stem # EncountersView.vue
4097
+ root = "/".join(parts[i:])
4098
+ else:
4099
+ name = rest[0]
4100
+ root = "/".join(parts[i:i + 2 + groups]) + "/"
4101
+ if not name or name[0] in "[_+@(." or name.lower() in UI_NOT_FEATURE:
4102
+ return None
4103
+ return name, root
4104
+ return None
2851
4105
 
2852
4106
 
2853
- def ui_features(root, fe_files, ui_prefix):
2854
- """The CAPABILITIES the UI lane records: {Feature: {"files", "calls", "routes"}}.
4107
+ def ui_feature_layout(src):
4108
+ """Where a UI lane keeps its features, read from its source files as (path,
4109
+ lane-relative path) pairs: "features" when any sits in a features/<Feature>/ folder,
4110
+ else the first of UI_FEATURE_CONTAINERS holding a feature outside its tests, else ""."""
4111
+ if any(UI_FEATURE_SEG_RE.search(rel) for _f, rel in src):
4112
+ return "features"
4113
+ for container in UI_FEATURE_CONTAINERS:
4114
+ if any(_ui_container_hit(rel, container) and not UI_TEST_FILE_RE.search(f)
4115
+ for f, rel in src):
4116
+ return container
4117
+ return ""
4118
+
4119
+
4120
+ def ui_feature_of(rel, layout):
4121
+ """(feature, root) for a lane-relative path under `layout` (see ui_feature_layout), or
4122
+ None: a features/<Feature>/ folder's name verbatim, or a container's folder or page
4123
+ made PascalCase. `root` is where the feature was read from — `features/<Feature>/`,
4124
+ `app/(clinic)/orders/`, `views/OrdersView.vue`."""
4125
+ if layout == "features":
4126
+ m = UI_FEATURE_SEG_RE.search(rel)
4127
+ return (m.group(1), f"features/{m.group(1)}/") if m else None
4128
+ hit = _ui_container_hit(rel, layout) if layout else None
4129
+ return (_pascal(hit[0]), hit[1]) if hit else None
4130
+
4131
+
4132
+ def _ui_where(roots):
4133
+ """How the proposal names where a UI feature was read from: its folder or page, the
4134
+ first of several with a count of the rest."""
4135
+ first = (roots or [""])[0]
4136
+ more = f" (+{len(roots) - 1} more)" if len(roots) > 1 else ""
4137
+ return f"{'UI feature folder' if first.endswith('/') else 'UI page'} {first}{more}"
4138
+
4139
+
4140
+ def ui_features(root, fe_files, ui_prefix, declared=False):
4141
+ """The CAPABILITIES the UI lane records:
4142
+ {Feature: {"files", "roots", "calls", "routes", "imports", "idents"}}. `roots` is
4143
+ where each was read from (see ui_feature_of).
2855
4144
 
2856
4145
  The frontend rules make features/<Feature>/ the spec feature's name verbatim —
2857
4146
  "that match is what makes a slice traceable in both lanes". It is therefore the
2858
- only place in code where a feature, as distinct from an aggregate or an
2859
- operation, is written down. `calls` are the API-client functions the feature
2860
- imports (the operations it invokes, by name); `routes` are the path literals it
2861
- builds, which name the aggregates it reads even where it imports nothing.
2862
-
2863
- Runs only under a DECLARED UI root: without one scope_to_lanes hands back the
4147
+ first place a feature, as distinct from an aggregate or an operation, is read.
4148
+ `calls` are the API-client functions the feature imports (the operations it
4149
+ invokes, by name); `routes` are the path literals it builds, which name the
4150
+ aggregates it reads even where it imports nothing; `imports` is every name it
4151
+ brings in — imported, injected or taken as a constructor parameter; `idents` is
4152
+ every name its code uses, which says which methods of an imported client it calls.
4153
+
4154
+ A frontend not built to that rule keeps its features elsewhere. With no
4155
+ features/ folder anywhere in the lane, the first container kind of
4156
+ UI_FEATURE_CONTAINERS that holds any feature is read instead, each folder name
4157
+ made PascalCase — those are route segments and module folders, not spec names.
4158
+
4159
+ Runs only under a DECLARED UI root — `declared` says so for a lane at the repo
4160
+ root, whose prefix is empty. Without a declaration scope_to_lanes hands back the
2864
4161
  whole repo, and a backend's Features/ folders are not capabilities."""
2865
- if not ui_prefix:
4162
+ if not ui_prefix and not declared:
2866
4163
  return {}
4164
+ src = [(f, f[len(ui_prefix):]) for f in fe_files
4165
+ if f.startswith(ui_prefix) and UI_SOURCE_RE.search(f)]
4166
+ layout = ui_feature_layout(src)
4167
+ owner = {}
4168
+ for f, rel in src:
4169
+ hit = ui_feature_of(rel, layout)
4170
+ if hit and (layout == "features" or not UI_TEST_FILE_RE.search(f)):
4171
+ owner[f] = hit
2867
4172
  feats = {}
2868
- for f in fe_files:
2869
- if not f.startswith(ui_prefix) or not UI_SOURCE_RE.search(f):
4173
+ for f, _rel in src:
4174
+ if f not in owner:
2870
4175
  continue
2871
- m = UI_FEATURE_SEG_RE.search(f[len(ui_prefix) - 1:])
2872
- if not m:
2873
- continue
2874
- rec = feats.setdefault(m.group(1), {"files": [], "calls": set(), "routes": set()})
4176
+ name, where = owner[f]
4177
+ rec = feats.setdefault(name, {"files": [], "roots": [], "calls": set(), "routes": set(),
4178
+ "imports": set(), "idents": set()})
2875
4179
  rec["files"].append(f)
4180
+ if where not in rec["roots"]:
4181
+ rec["roots"].append(where)
2876
4182
  body = _read_source(root, f)
4183
+ rec["idents"].update(IDENT_RE.findall(body))
2877
4184
  for names, mod in API_IMPORT_RE.findall(body):
4185
+ named = {n.strip().split(" as ")[0].strip() for n in names.split(",") if n.strip()}
4186
+ rec["imports"].update(named)
2878
4187
  if API_MODULE_RE.search(mod):
2879
- rec["calls"].update(n.strip().split(" as ")[0].strip()
2880
- for n in names.split(",") if n.strip())
4188
+ rec["calls"].update(named)
2881
4189
  for ns, mod in API_NS_IMPORT_RE.findall(body):
2882
4190
  if API_MODULE_RE.search(mod):
2883
4191
  rec["calls"].update(re.findall(rf"\b{re.escape(ns)}\.(\w+)\s*\(", body))
4192
+ rec["imports"].update(UI_DEFAULT_IMPORT_RE.findall(body))
4193
+ rec["imports"].update(UI_INJECT_RE.findall(body))
4194
+ rec["imports"].update(UI_CTOR_TYPE_RE.findall(body))
2884
4195
  rec["routes"].update(UI_ROUTE_LITERAL_RE.findall(body))
2885
4196
  return feats
2886
4197
 
2887
4198
 
4199
+ # ═══════════════════════════════════════════════════════════════════════════
4200
+ # A project with NO service lane — its model is read from the calls its UI makes.
4201
+ # ═══════════════════════════════════════════════════════════════════════════
4202
+ #
4203
+ # A front end whose API lives in another repository still works on aggregates: the
4204
+ # resources it calls. Each route family the lane calls (/care/encounters/… →
4205
+ # Encounter) is the UI's view of an aggregate another system owns; each distinct call
4206
+ # is an operation; the leading route segment is a bounded context where the API
4207
+ # carries one (/care/encounters, /care/medication-orders → Care), and one context named
4208
+ # after the project holds the rest. A feature acts on what it calls — in its own files,
4209
+ # or through a function, client or service it imports or is injected with.
4210
+
4211
+ # Outgoing HTTP calls, as each frontend stack writes them.
4212
+ UI_CALL_RES = (
4213
+ # client.get("/x"), axios.post(`/x`), this.http.get<T>("/x"), api.GET("/x"), ky.put(…)
4214
+ (re.compile(r"\b(\w+)\s*\.\s*(get|post|put|patch|delete|GET|POST|PUT|PATCH|DELETE)\s*"
4215
+ r"(?:<[^()]*?>)?\s*\(\s*([\"'`])([^\"'`\n]*)\3"), "member"),
4216
+ # fetch("/x", { method: "POST" }) and the wrappers that keep its signature
4217
+ (re.compile(r"(?<![\w.$])(?:fetch|\$fetch|useFetch|ofetch)\s*\(\s*([\"'`])([^\"'`\n]*)\1"), "fetch"),
4218
+ # .NET HttpClient (Blazor): Http.GetFromJsonAsync<T>("api/x"), PostAsJsonAsync, DeleteAsync
4219
+ (re.compile(r"\b(Get|Post|Put|Patch|Delete)(?:FromJson|AsJson)?Async\s*(?:<[^()]*?>)?"
4220
+ r"\s*\(\s*\$?\"([^\"]*)\""), "dotnet"),
4221
+ )
4222
+ # A member call's receiver that is a map, a URL or a router rather than an HTTP client.
4223
+ UI_RECV_SKIP_RE = re.compile(
4224
+ r"^(?:map|params|searchparams|headers|cookies?|storage|localstorage|sessionstorage|"
4225
+ r"cache|store|formdata|url|query|router|route|res|req|response|config|env|object|"
4226
+ r"reflect|weakmap|set|i18n|translate|translations?)$", re.IGNORECASE)
4227
+ UI_TEST_FILE_RE = re.compile(
4228
+ r"\.(?:spec|test|stories|e2e)\.[cm]?[jt]sx?$|(?:^|/)(?:__)?mocks?(?:__)?/", re.IGNORECASE)
4229
+ # Where a call sits: the function or method enclosing it, for a feature that imports it.
4230
+ UI_FN_DEF_RES = (
4231
+ re.compile(r"\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)\s*\("),
4232
+ re.compile(r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?"
4233
+ r"(?:function\b|\([^)]*\)\s*(?::[^=;{]+)?=>|[A-Za-z_$][\w$]*\s*=>)"),
4234
+ re.compile(r"^[ \t]*(?:(?:public|private|protected|internal|static|async|readonly|override|"
4235
+ r"virtual)\s+)*(?:[\w<>\[\],.?]+\s+)?([A-Za-z_]\w*)\s*(?:<[^<>()]*>)?\s*\([^)]*\)"
4236
+ r"\s*(?::\s*[^{;=]+)?\{", re.M),
4237
+ # C# expression-bodied members: public Task<X> ListAsync() => Http.Get…
4238
+ re.compile(r"^[ \t]*(?:(?:public|private|protected|internal|static|async|override|virtual)"
4239
+ r"\s+)+[\w<>\[\],.?]+\s+([A-Za-z_]\w*)\s*\([^)]*\)\s*=>", re.M),
4240
+ )
4241
+ UI_OBJECT_DECL_RE = re.compile(
4242
+ r"^export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=\s*\{", re.M)
4243
+ # Function names that say nothing about the call: the verb and the resource name it.
4244
+ # A React hook (useEncounters) is one of them — it says what a component reads, not
4245
+ # what the API does.
4246
+ UI_HOOK_RE = re.compile(r"^use[A-Z]")
4247
+ UI_GENERIC_FN = GENERIC_HANDLER | frozenset((
4248
+ "request", "fetcher", "send", "api", "http", "client", "get", "post", "put", "patch",
4249
+ "delete", "load", "fetch", "query", "mutate", "mutation", "queryfn", "mutationfn",
4250
+ "default", "init", "ngoninit", "useeffect", "oninitializedasync", "oninitialized",
4251
+ "onparameterssetasync", "onsubmit", "submit", "save", "setup", "mounted", "created"))
4252
+
4253
+
4254
+ def _ui_code(f):
4255
+ return bool((_lang_of(f) or f.lower().endswith((".razor", ".cshtml")))
4256
+ and not TEST_PATH_RE.search(f) and not UI_TEST_FILE_RE.search(f))
4257
+
4258
+
4259
+ def _ui_api_path(raw):
4260
+ """A called URL as a route template: base-URL variables and query strings dropped,
4261
+ template-literal parameters made {name}. "" when it is no API path — a translation
4262
+ key, an asset, a page."""
4263
+ p = (raw or "").strip()
4264
+ if "/" not in p:
4265
+ return ""
4266
+ p = re.sub(r"^\$\{[^}]*\}", "", p)
4267
+ p = re.sub(r"^https?://[^/]+", "", p)
4268
+ p = re.split(r"[?#]", p)[0]
4269
+ p = re.sub(r"\$\{\s*(?:[\w$]+\.)*([\w$]+)\s*\}", r"{\1}", p)
4270
+ if not p or not re.fullmatch(r"[\w\-{}/.:~]+", p):
4271
+ return ""
4272
+ if re.search(r"\.[A-Za-z]{2,5}$", p.rsplit("/", 1)[-1]):
4273
+ return ""
4274
+ return p if p.startswith("/") else "/" + p
4275
+
4276
+
4277
+ def _api_segments(path):
4278
+ """A called route's segments with the api/version lead dropped, parameters kept."""
4279
+ out = []
4280
+ for s in [s for s in path.strip("/").split("/") if s]:
4281
+ if not out and (s.lower() in ("api", "rest", "graphql", "internal", "public")
4282
+ or re.fullmatch(r"v\d+", s.lower())):
4283
+ continue
4284
+ out.append(s)
4285
+ return out
4286
+
4287
+
4288
+ # Route words that name an endpoint serving the platform, not the domain: health,
4289
+ # liveness and readiness probes, a ping, a status or version report. `metrics` is not
4290
+ # here — an analytics product's /metrics is a domain resource.
4291
+ INFRA_ROUTE_WORDS = frozenset((
4292
+ "health", "healthz", "healthcheck", "health-check", "health_check", "live", "livez",
4293
+ "liveness", "ready", "readyz", "readiness", "ping", "status", "version"))
4294
+
4295
+
4296
+ def _infra_route(path):
4297
+ """Whether a route is an infrastructure endpoint: every segment below the api/version
4298
+ lead is an INFRA_ROUTE_WORDS word (/healthz, /api/health, /health/ready). A domain
4299
+ resource's own /status is not: /care/encounters/{id}/status has other segments."""
4300
+ segs = _api_segments(path or "")
4301
+ return bool(segs) and all(s.lower() in INFRA_ROUTE_WORDS for s in segs)
4302
+
4303
+
4304
+ def _is_param(seg):
4305
+ return seg.startswith(("{", ":", "[", "<", "$"))
4306
+
4307
+
4308
+ def _route_key(verb, path):
4309
+ """A route as a UI call and an endpoint both write it: the verb, and the path's
4310
+ segments lower-cased with the api/version lead dropped and every parameter one `{}`
4311
+ — `GET /api/care/encounters/${id}` and `GET /care/encounters/{id:guid}` are one
4312
+ route."""
4313
+ return ((verb or "").upper(),
4314
+ "/".join("{}" if _is_param(s) else s.lower() for s in _api_segments(path)))
4315
+
4316
+
4317
+ def _ui_reached(calls, rec):
4318
+ """The calls a UI feature makes: in its own files, through a function it imports,
4319
+ or through a client or service it imports or is injected with. Where the feature's
4320
+ code names methods of that client, only those count, so a generated client holding
4321
+ every call of the API does not hand all of them to each feature using it; where it
4322
+ names none (they are called from a template the scan does not read), all do."""
4323
+ mine, used = set(rec["files"]), rec.get("idents", set())
4324
+ named = {c["cls"] for c in calls if c["cls"] in rec["imports"] and c["fn"] in used}
4325
+ return [c for c in calls if c["file"] in mine
4326
+ or (c["fn"] and c["fn"] in rec["imports"])
4327
+ or (c["cls"] and c["cls"] in rec["imports"]
4328
+ and (c["cls"] not in named or not c["fn"] or c["fn"] in used))]
4329
+
4330
+
4331
+ def ui_api_calls(root, fe_files):
4332
+ """Every API call the UI lane makes: [{file, verb, path, fn, cls}]."""
4333
+ calls = []
4334
+ for f in fe_files:
4335
+ if not _ui_code(f):
4336
+ continue
4337
+ body = _code_only(f, _read_source(root, f))
4338
+ if not body:
4339
+ continue
4340
+ # What a call is a member of: a class (an Angular or Blazor service) or an
4341
+ # exported object of functions (export const encounterApi = { list: … }).
4342
+ classes = sorted([(m.start(), m.group(1)) for m in CLASS_DECL_RE.finditer(body)] +
4343
+ [(m.start(), m.group(1)) for m in UI_OBJECT_DECL_RE.finditer(body)])
4344
+ # A class with a primary constructor reads like a method; it is not one.
4345
+ cnames = {n for _p, n in classes}
4346
+ defs = sorted((m.start(), m.group(1)) for rx in UI_FN_DEF_RES for m in rx.finditer(body)
4347
+ if m.group(1) not in _HANDLER_SKIP and m.group(1) not in cnames)
4348
+ for rx, role in UI_CALL_RES:
4349
+ for m in rx.finditer(body):
4350
+ if role == "member":
4351
+ if UI_RECV_SKIP_RE.match(m.group(1)):
4352
+ continue
4353
+ verb, raw = m.group(2).upper(), m.group(4)
4354
+ elif role == "fetch":
4355
+ opt = re.match(r"[^)]{0,300}?\bmethod\s*:\s*[\"'`](\w+)[\"'`]", body[m.end():])
4356
+ verb, raw = (opt.group(1).upper() if opt else "GET"), m.group(2)
4357
+ else:
4358
+ verb, raw = m.group(1).upper(), m.group(2)
4359
+ path = _ui_api_path(raw)
4360
+ # A health or ping poll calls the platform, not an operation.
4361
+ if not path or _infra_route(path):
4362
+ continue
4363
+ calls.append({
4364
+ "file": f, "verb": verb, "path": path,
4365
+ "fn": next((n for p, n in reversed(defs) if p < m.start()), ""),
4366
+ "cls": next((n for p, n in reversed(classes) if p < m.start()), ""),
4367
+ })
4368
+ return calls
4369
+
4370
+
4371
+ def ui_model(root, fe_files, ufeats, churn_map):
4372
+ """The map of a project with no service lane, from its UI lane alone:
4373
+ (candidates, operations). Rewrites each feature's `calls` to the operations it
4374
+ reaches, which is how propose_features ties a feature to what it acts on.
4375
+
4376
+ A call whose route carries no context segment (/api/encounters) belongs to `Core`,
4377
+ the name every strategy gives a tree with no context level — never the checkout
4378
+ folder's name, which differs from one clone to the next."""
4379
+ calls = ui_api_calls(root, fe_files)
4380
+ by_first = {}
4381
+ for c in calls:
4382
+ segs = _api_segments(c["path"])
4383
+ if segs and not _is_param(segs[0]):
4384
+ by_first.setdefault(segs[0].lower(), []).append(segs)
4385
+ # A leading segment is a CONTEXT when every route under it goes on to a literal
4386
+ # resource name: /care/encounters/{id} — never /encounters/{id}.
4387
+ prefixes = {s for s, lists in by_first.items()
4388
+ if all(len(x) > 1 and not _is_param(x[1]) for x in lists)}
4389
+
4390
+ ops, cands = {}, {}
4391
+ for c in calls:
4392
+ segs = _api_segments(c["path"])
4393
+ if not segs or _is_param(segs[0]):
4394
+ continue
4395
+ ctx_seg = segs[0] if segs[0].lower() in prefixes else ""
4396
+ res = segs[1] if ctx_seg else segs[0]
4397
+ agg = _pascal(singularize(res))
4398
+ ctx = _pascal(ctx_seg) if ctx_seg else "Core"
4399
+ if not agg:
4400
+ continue
4401
+ routes = [(c["verb"], c["path"])]
4402
+ fn = c["fn"]
4403
+ name = (_endpoint_name(fn, agg, routes)
4404
+ if fn and fn.lower() not in UI_GENERIC_FN and not UI_HOOK_RE.match(fn)
4405
+ and _ui_norm(agg) in _ui_norm(fn)
4406
+ else _endpoint_name(None, agg, routes))
4407
+ op = ops.setdefault(name, {"name": name, "context": ctx, "module": "", "files": [],
4408
+ "acts_on": [agg], "routes": [], "kind": ""})
4409
+ if c["file"] not in op["files"]:
4410
+ op["files"].append(c["file"])
4411
+ if routes[0] not in op["routes"]:
4412
+ op["routes"].append(routes[0])
4413
+ cand = cands.setdefault(agg, dict(_blank_candidate(c["file"], agg), context=ctx,
4414
+ confidence="called", strategy="ui"))
4415
+ if c["file"] not in cand["api_files"]:
4416
+ cand["api_files"].append(c["file"])
4417
+ cand["churn"] += churn_map.get(c["file"], 0)
4418
+ c["op"] = name
4419
+ for op in ops.values():
4420
+ op["kind"] = _verb_kind(op["routes"]) or "query"
4421
+ op["routes"] = sorted(op["routes"])
4422
+ op["files"] = sorted(op["files"])
4423
+
4424
+ for rec in ufeats.values():
4425
+ rec["calls"] = {c["op"] for c in _ui_reached(calls, rec) if "op" in c}
4426
+ out = []
4427
+ for c in cands.values():
4428
+ files = sorted(set(c["api_files"]))
4429
+ c["api_files"] = files[:CAP]
4430
+ c.update(raw_file_count=len(files), covered_code=files, file_count=len(c["api_files"]),
4431
+ evidence_count=len(files), entities=[])
4432
+ out.append(c)
4433
+ out.sort(key=lambda c: (c["evidence_count"], c["churn"]), reverse=True)
4434
+ return out, sorted(ops.values(), key=lambda o: (o["context"], o["name"]))
4435
+
4436
+
4437
+ def ui_reading(root, fe_files, ui_prefix, churn_map):
4438
+ """A project with no service lane, read from its UI lane: (feature folders,
4439
+ candidates, operations). propose draws the map from it and scan hands onboard the
4440
+ same candidates, so both name the project's contexts and aggregates alike."""
4441
+ ufeats = ui_features(root, fe_files, ui_prefix, declared=True)
4442
+ cands, ops = ui_model(root, fe_files, ufeats, churn_map)
4443
+ return ufeats, cands, ops
4444
+
4445
+
2888
4446
  def detect_operations(root, files, candidates):
2889
4447
  """Operations for a repo whose code declares no aggregate root: the slices under a
2890
- module's feature layer, and CQRS handler classes wherever they are. Each acts on
2891
- the candidate that owns most of its files — the only link available without a
2892
- declared model."""
4448
+ module's feature layer, CQRS handler classes wherever they are, use cases and
4449
+ endpoints (see _published_operations). Each acts on the candidate that owns most of
4450
+ its files — the only link available without a declared model; an operation only an
4451
+ endpoint declares is kept when that link exists."""
2893
4452
  children = _child_dirs(files)
2894
4453
  owner = _candidate_of_file(candidates)
4454
+ text = {f: _code_only(f, _read_source(root, f)) for f in files
4455
+ if _lang_of(f) and not TEST_PATH_RE.search(f)}
2895
4456
  op_files = {}
2896
4457
  for f in files:
2897
- if not _lang_of(f) or TEST_PATH_RE.search(f):
4458
+ if f not in text:
2898
4459
  continue
2899
4460
  parts = f.split("/")
2900
4461
  _i, op = _feature_layer_op(parts[:-1], children, os.path.splitext(parts[-1])[0])
2901
4462
  if op:
2902
4463
  op_files.setdefault(op, []).append(f)
2903
4464
  continue
2904
- for m in HANDLER_CLASS_RE.finditer(_code_only(f, _read_source(root, f)) if f.endswith(
4465
+ for m in HANDLER_CLASS_RE.finditer(text[f] if f.endswith(
2905
4466
  (".cs", ".java", ".kt", ".ts", ".py")) else ""):
2906
4467
  op_files.setdefault(m.group(1), []).append(f)
4468
+ pub = _published_operations(text, children)
4469
+ for op, fs in pub["usecases"].items():
4470
+ lst = op_files.setdefault(op, [])
4471
+ lst += [f for f in fs if f not in lst]
2907
4472
  ops = []
2908
- for op, fs in sorted(op_files.items()):
4473
+ for op in sorted(set(op_files) | set(pub["endpoints"])):
4474
+ fs = op_files.get(op, [])
4475
+ served = pub["endpoints"].get(op, {})
4476
+ shared = set(served.get("files", ()))
4477
+ allf = sorted(set(fs) | shared)
2909
4478
  votes = {}
2910
- for f in fs:
4479
+ for f in allf:
2911
4480
  if owner.get(f):
2912
4481
  votes[owner[f]] = votes.get(owner[f], 0) + 1
4482
+ if not fs and not votes:
4483
+ continue
2913
4484
  routes = []
2914
4485
  for f in fs:
2915
- routes.extend(_routes_in(_read_source(root, f)))
2916
- ops.append({"name": op, "context": "", "module": "", "files": sorted(set(fs)),
4486
+ if f not in shared:
4487
+ routes.extend(_routes_in(_read_source(root, f)))
4488
+ routes += list(served.get("routes", ()))
4489
+ ids = list(served.get("ids", ()))
4490
+ ids += [i for i in _declared_ids([(f, text[f]) for f in fs if f not in shared])
4491
+ if i not in ids]
4492
+ ops.append({"name": op, "context": "", "module": "", "files": allf,
2917
4493
  "acts_on": [max(sorted(votes), key=votes.get)] if votes else [],
2918
- "kind": "query" if QUERY_NAME_RE.match(op) else "command",
2919
- "routes": sorted(set(routes))})
4494
+ "kind": _verb_kind(served.get("routes", ())) or
4495
+ ("query" if QUERY_NAME_RE.match(op) else "command"),
4496
+ "routes": sorted(set(routes)), "operation_ids": ids})
2920
4497
  return ops
2921
4498
 
2922
4499
 
@@ -2930,11 +4507,14 @@ def attribute_ui_files(signals, fe_files, churn_map, ui_roots, feature_aggs=None
2930
4507
  frontend lane's design has nothing to be derived from.
2931
4508
 
2932
4509
  THE CONFIRMED MAP FIRST. `feature_aggs` is {feature: [aggregates it acts on]} from
2933
- the feature map the operator confirmed. A file under a UI `features/<Feature>/`
2934
- folder the map names serves exactly those aggregates — every one of them, since a
2935
- feature may act on several — whatever the file is called. A capability is named for
2936
- what the user does (RecordEncounter, PlaceMedicationOrder), so matching its folder
2937
- against aggregate names alone leaves the lane's own feature folders unattributed.
4510
+ the feature map the operator confirmed. A file in a UI feature the map names serves
4511
+ exactly those aggregates — every one of them, since a feature may act on several —
4512
+ whatever the file is called. A file belongs to the feature ui_features read it into:
4513
+ its `features/<Feature>/` folder, or in a frontend without one its module, route or
4514
+ page folder (`views/OrdersView.vue` → Orders). Every source file of such a feature
4515
+ counts, its hooks, services and loaders included. A capability is named for what the
4516
+ user does (RecordEncounter, PlaceMedicationOrder), so matching its folder against
4517
+ aggregate names alone leaves the lane's own feature folders unattributed.
2938
4518
 
2939
4519
  Everything else is matched by NAME with both sides normalised: a DIRECTORY SEGMENT named for the
2940
4520
  concept (the strong signal — a feature folder), or a filename whose concept stem
@@ -2966,10 +4546,34 @@ def attribute_ui_files(signals, fe_files, churn_map, ui_roots, feature_aggs=None
2966
4546
  prefixes = [] if any(r in ("./", "/") for r in roots) else roots
2967
4547
  in_lane = fe_files if not prefixes else [
2968
4548
  f for f in fe_files if any(f.startswith(p) for p in prefixes)]
4549
+
4550
+ # {normalised feature: [candidates]} — only features whose aggregates the scan found.
4551
+ by_name = {c["name"]: c for c in signals}
4552
+ served_by = {}
4553
+ for feat, aggs in (feature_aggs or {}).items():
4554
+ cs = [by_name[a] for a in aggs if a in by_name]
4555
+ if cs:
4556
+ served_by[_ui_norm(feat)] = cs
4557
+
4558
+ # Which feature a file belongs to, by the rule ui_features read the lane with: its
4559
+ # features/<Feature>/ folder, or the module, route or page folder of a frontend
4560
+ # without one. Decided per search root, from that root's source files.
4561
+ lane_src = [f for f in in_lane if UI_SOURCE_RE.search(f)]
4562
+ layouts = {p: ui_feature_layout([(f, f[len(p):]) for f in lane_src if f.startswith(p)])
4563
+ for p in (prefixes or [""])}
4564
+
4565
+ def mapped(f):
4566
+ p = next((p for p in prefixes if f.startswith(p)), "")
4567
+ hit = ui_feature_of(f[len(p):], layouts.get(p, ""))
4568
+ return served_by.get(_ui_norm(hit[0])) if hit else None
4569
+
4570
+ # The UI files: the lane's components, markup, styles, routes and stores, and every
4571
+ # source file of a feature the map names — its hooks, services, loaders and script
4572
+ # components are that feature's surface as much as its templates.
2969
4573
  ui_files = sorted(set(
2970
4574
  matching(in_lane, UI_COMPONENT_RE) + matching(in_lane, UI_MARKUP_RE)
2971
4575
  + matching(in_lane, UI_STYLE_RE) + matching(in_lane, UI_ROUTE_RE)
2972
- + matching(in_lane, UI_STORE_RE)
4576
+ + matching(in_lane, UI_STORE_RE) + [f for f in lane_src if mapped(f)]
2973
4577
  ))
2974
4578
  if not ui_files:
2975
4579
  return {"attributed_file_count": 0, "aggregates_with_ui": 0,
@@ -2981,19 +4585,6 @@ def attribute_ui_files(signals, fe_files, churn_map, ui_roots, feature_aggs=None
2981
4585
  for c, _ in token_map:
2982
4586
  c.setdefault("ui_files", [])
2983
4587
 
2984
- # {normalised feature: [candidates]} — only features whose aggregates the scan found.
2985
- by_name = {c["name"]: c for c in signals}
2986
- served_by = {}
2987
- for feat, aggs in (feature_aggs or {}).items():
2988
- cs = [by_name[a] for a in aggs if a in by_name]
2989
- if cs:
2990
- served_by[_ui_norm(feat)] = cs
2991
-
2992
- def mapped(f):
2993
- p = next((p for p in prefixes if f.startswith(p)), "")
2994
- m = UI_FEATURE_SEG_RE.search(f[len(p):])
2995
- return served_by.get(_ui_norm(m.group(1))) if m else None
2996
-
2997
4588
  unattributed, attributed_files, by_map = [], set(), 0
2998
4589
  for f in ui_files:
2999
4590
  owners = mapped(f)
@@ -3046,6 +4637,16 @@ def _feature_aggregates(map_path):
3046
4637
  return out
3047
4638
 
3048
4639
 
4640
+ def _map_strategy(map_path):
4641
+ """The Pass A strategy a feature map was built from, or "" when there is no map."""
4642
+ try:
4643
+ with open(map_path, encoding="utf-8") as fh:
4644
+ m = json.load(fh)
4645
+ except (OSError, ValueError):
4646
+ return ""
4647
+ return (m.get("strategy") or "") if isinstance(m, dict) else ""
4648
+
4649
+
3049
4650
  # What each lane's steps do not need from the substrate. The service view drops the UI
3050
4651
  # lane's signals; the UI view drops the backend's aggregates and its API/data listings.
3051
4652
  _SERVICE_ONLY_KEYS = ("aggregate_signals", "route_like_files", "data_layer_files")
@@ -3134,13 +4735,32 @@ def scope_to_lanes(files, service_root, ui_root):
3134
4735
  return be, fe, svc, ui
3135
4736
 
3136
4737
 
4738
+ def built_lanes(root, service_root, ui_root):
4739
+ """The lane roots to read: (service_root, ui_root, unbuilt).
4740
+
4741
+ A lane can be declared before it is built — a frontend planned beside a service that
4742
+ exists, or the reverse — and its lane root folder holds nothing to read until then.
4743
+ While another declared lane is on disk, a lane root folder that is not is read as
4744
+ undeclared, and `unbuilt` names it as (kind, folder) so what was not read is said.
4745
+ When no declared lane is on disk the roots are returned as declared, so the scan reads
4746
+ nothing rather than the whole repository."""
4747
+ svc, ui = (service_root or "").strip(), (ui_root or "").strip()
4748
+ missing = [(kind, p) for kind, p in (("service", _lane_prefix(svc)), ("UI", _lane_prefix(ui)))
4749
+ if p and not os.path.isdir(os.path.join(root, p))]
4750
+ if len(missing) == bool(svc) + bool(ui):
4751
+ return svc, ui, []
4752
+ gone = {kind for kind, _ in missing}
4753
+ return ("" if "service" in gone else svc), ("" if "UI" in gone else ui), missing
4754
+
4755
+
3137
4756
  def cmd_scan(args):
3138
4757
  root = os.path.abspath(args.root or os.getcwd())
3139
4758
  config_excludes = load_config_excludes(args.config) + kit_excludes(root)
3140
4759
  out = os.path.abspath(args.out) if args.out else os.path.join(root, ".appweaver", "inventory", "inventory.json")
3141
4760
  bundle_dir = os.path.abspath(args.bundle) if args.bundle else os.path.join(root, "knowledge")
3142
4761
 
3143
- files = list_files(root, config_excludes, (args.service_root, args.ui_root))
4762
+ service_root, ui_root, _ = built_lanes(root, args.service_root, args.ui_root)
4763
+ files = list_files(root, config_excludes, (service_root, ui_root))
3144
4764
  churn_list, commits_scanned = churn(root, config_excludes)
3145
4765
  churn_map = {c["file"]: c["commits"] for c in churn_list}
3146
4766
 
@@ -3177,9 +4797,23 @@ def cmd_scan(args):
3177
4797
  "config_files": category(CONFIGS_RE),
3178
4798
  "adr_docs": category(ADR_RE),
3179
4799
  }
3180
- be_files, fe_files, svc_prefix, ui_prefix = scope_to_lanes(
3181
- files, args.service_root, args.ui_root)
3182
- signals = aggregate_signals(be_files, churn_map, root)
4800
+ be_files, fe_files, svc_prefix, ui_prefix = scope_to_lanes(files, service_root, ui_root)
4801
+ # A lane declared at the repo root has the empty prefix and is declared all the same;
4802
+ # its lane root folder is reported as `./`.
4803
+ svc_shown = svc_prefix or ("./" if service_root else "")
4804
+ ui_shown = ui_prefix or ("./" if ui_root else "")
4805
+ mp = os.path.abspath(args.map) if args.map else os.path.join(
4806
+ root, ".appweaver", "inventory", "feature-map.json")
4807
+ # A UI lane and no service lane is read the way propose reads it: the candidates are
4808
+ # the API resources the UI calls, and there is no backend. A UI lane at the repo root
4809
+ # would otherwise have its components read as service code. A service lane is read
4810
+ # with the strategy its map was built from, so the substrate names the aggregates
4811
+ # the map does.
4812
+ if ui_root and not service_root:
4813
+ be_files = []
4814
+ _, signals, _ = ui_reading(root, fe_files, ui_prefix, churn_map)
4815
+ else:
4816
+ signals = aggregate_signals(be_files, churn_map, root, _map_strategy(mp))
3183
4817
  fsig = frontend_signals(fe_files, churn_map)
3184
4818
 
3185
4819
  # A DECLARED UI lane is a lane. The strength test below UI_STRONG_MIN exists to
@@ -3188,13 +4822,13 @@ def cmd_scan(args):
3188
4822
  # evidence still decides is what goes IN the governance — and a declared root
3189
4823
  # holding no recognisable UI files is reported as exactly that, so onboard can
3190
4824
  # say so rather than quietly authoring a UI half out of nothing.
3191
- if ui_prefix:
3192
- fsig["declared_root"] = ui_prefix
4825
+ if ui_root:
4826
+ fsig["declared_root"] = ui_shown
3193
4827
  fsig["ui_evidence_found"] = bool(fsig.get("roots"))
3194
4828
  fsig["present"] = True
3195
4829
  fsig["separable"] = ui_prefix not in ("", "./") and not svc_prefix.startswith(ui_prefix)
3196
4830
  if not fsig.get("roots"):
3197
- fsig["roots"] = [{"root": ui_prefix, "kind": "declared", "confidence": "declared",
4831
+ fsig["roots"] = [{"root": ui_shown, "kind": "declared", "confidence": "declared",
3198
4832
  "counts": {"component": 0}, "framework_markers": [],
3199
4833
  "component_files": [], "style_files": [], "markup_files": [],
3200
4834
  "build_configs": [], "route_files": [], "store_files": []}]
@@ -3204,14 +4838,12 @@ def cmd_scan(args):
3204
4838
  # declaration the roots the scan actually found stand in, which keeps a repo whose
3205
4839
  # UI is server-rendered inside the service tree covered. With neither, this repo
3206
4840
  # has no frontend and the pass does not run at all.
3207
- if ui_prefix:
3208
- ui_search_roots = [ui_prefix]
4841
+ if ui_root:
4842
+ ui_search_roots = [ui_shown]
3209
4843
  elif fsig.get("present"):
3210
4844
  ui_search_roots = [r.get("root", "") for r in (fsig.get("roots") or [])]
3211
4845
  else:
3212
4846
  ui_search_roots = []
3213
- mp = os.path.abspath(args.map) if args.map else os.path.join(
3214
- root, ".appweaver", "inventory", "feature-map.json")
3215
4847
  ui_attr = attribute_ui_files(signals, fe_files, churn_map, ui_search_roots,
3216
4848
  _feature_aggregates(mp))
3217
4849
 
@@ -3234,8 +4866,8 @@ def cmd_scan(args):
3234
4866
  "cap": CAP,
3235
4867
  # The lane scoping this substrate was built under, so a reader can tell a
3236
4868
  # repo with no backend aggregates from one whose backend was scoped away.
3237
- "declared_service_root": svc_prefix,
3238
- "declared_ui_root": ui_prefix,
4869
+ "declared_service_root": svc_shown,
4870
+ "declared_ui_root": ui_shown,
3239
4871
  "scoped_file_counts": {"backend": len(be_files), "frontend": len(fe_files),
3240
4872
  "repo": len(files)},
3241
4873
  "language_histogram": lang_hist,
@@ -3320,8 +4952,8 @@ def cmd_scan(args):
3320
4952
  # actually held any UI. A declared lane stays a lane either way; this is how
3321
4953
  # onboard tells "reverse-engineered from real UI source" from "declared, but
3322
4954
  # there is nothing here to reverse".
3323
- "declared_service_root": svc_prefix,
3324
- "declared_ui_root": ui_prefix,
4955
+ "declared_service_root": svc_shown,
4956
+ "declared_ui_root": ui_shown,
3325
4957
  "ui_evidence_found": "true" if fsig.get("ui_evidence_found", bool(fsig["roots"])) else "false",
3326
4958
  "backend_file_count": len(be_files),
3327
4959
  "frontend_file_count": len(fe_files),
@@ -3435,16 +5067,22 @@ def _names_preview(cands, limit=4):
3435
5067
  return ", ".join(names[:limit]) + (" …" if len(names) > limit else "")
3436
5068
 
3437
5069
 
3438
- def _scanned_line(files, be, fe, svc, ui):
3439
- """What the scan read, lane by lane. The repository's own tooling, docs and kit files
5070
+ def _scanned_line(files, be, fe, svc, ui, ui_only=False, unbuilt=()):
5071
+ """What the scan read, lane by lane, and each declared lane it did not read because its
5072
+ lane root folder does not exist yet. The repository's own tooling, docs and kit files
3440
5073
  sit outside both lanes and are read by nothing here, so a repository-wide file count
3441
5074
  would overstate the scan."""
5075
+ later = "".join(f" · {kind} lane `{p}` is declared and has no folder yet"
5076
+ for kind, p in unbuilt)
5077
+ if ui_only:
5078
+ return (f"UI lane `{ui or './'}` — {len(fe)} files, {sum(1 for f in fe if _ui_code(f))} "
5079
+ f"source" + (later or " · no service lane is declared"))
3442
5080
  be_src = sum(1 for f in be if CODE_EXT_RE.search(f))
3443
5081
  if not svc and not ui:
3444
- return f"the whole repository — {len(files)} files, {be_src} source"
5082
+ return f"the whole repository — {len(files)} files, {be_src} source" + later
3445
5083
  head = (f"service lane `{svc}` — {len(be)} files, {be_src} source" if svc else
3446
5084
  f"everything outside `{ui}` — {len(be)} files, {be_src} source")
3447
- return head + (f" · UI lane `{ui}` — {len(fe)} files" if ui else "")
5085
+ return head + (f" · UI lane `{ui}` — {len(fe)} files" if ui else "") + later
3448
5086
 
3449
5087
 
3450
5088
  def _write_proposal(path, meta, contexts, features, waves, cycles, probe_rows,
@@ -3528,21 +5166,24 @@ def _write_proposal(path, meta, contexts, features, waves, cycles, probe_rows,
3528
5166
  operation_table = ""
3529
5167
  if operations:
3530
5168
  operation_table = _md_table(
3531
- ["Operation", "Kind", "Acts on", "Route", "Feature(s)"],
5169
+ ["Operation", "Kind", "Acts on", "Route", "Published id", "Feature(s)"],
3532
5170
  [[o["name"], o["kind"], ", ".join(o["acts_on"]) or "—",
3533
5171
  " · ".join(f"{v} {p}".strip() for v, p in o["routes"][:2]) or "—",
5172
+ ", ".join(f"`{i}`" for i in o.get("operation_ids") or []) or "—",
3534
5173
  ", ".join(o.get("features", [])) or "—"]
3535
5174
  for o in sorted(operations, key=lambda o: (o["context"], (o["acts_on"] or [""])[0],
3536
5175
  o["name"]))])
3537
5176
  else:
3538
- operation_table = ("_No operations were found — no feature layer of slices and no "
3539
- "command/query handler classes. Features below are one per "
3540
- "aggregate by default._")
5177
+ operation_table = ("_No operations were found — no feature layer of slices, no "
5178
+ "command/query handler classes, no use cases and no endpoints "
5179
+ "acting on an aggregate. Features below are one per aggregate "
5180
+ "by default._")
3541
5181
 
3542
5182
  review_section = ""
3543
5183
  orphaned = (notes or {}).get("orphaned") or []
3544
5184
  unlinked = (notes or {}).get("unlinked_ui") or []
3545
- if orphaned or unlinked:
5185
+ unserved = (notes or {}).get("unserved_ui") or []
5186
+ if orphaned or unlinked or unserved:
3546
5187
  lines = ["\n## Needs a decision\n"]
3547
5188
  if orphaned:
3548
5189
  lines.append("These commands act on an aggregate that has capability features, "
@@ -3552,9 +5193,27 @@ def _write_proposal(path, meta, contexts, features, waves, cycles, probe_rows,
3552
5193
  "it is.\n")
3553
5194
  lines.append(_md_table(["Default feature", "Operation"], orphaned) + "\n")
3554
5195
  if unlinked:
3555
- lines.append("These UI feature folders call no operation the scan could tie to an "
3556
- "aggregate, so no feature was proposed for them: "
3557
- + ", ".join(f"`features/{u}/`" for u in unlinked) + ".\n")
5196
+ folders = all(r.endswith("/") for roots in unlinked for r in roots)
5197
+ lines.append(f"These UI {'feature folders' if folders else 'features'} call no "
5198
+ "operation the scan could tie to an aggregate, so no feature was "
5199
+ "proposed for them: "
5200
+ + ", ".join(f"`{roots[0]}`" + (f" (+{len(roots) - 1} more)"
5201
+ if len(roots) > 1 else "")
5202
+ for roots in unlinked) + ".\n")
5203
+ if unserved:
5204
+ lines.append("These calls the UI lane makes match no route the service lane "
5205
+ "declares, so no operation was linked through them. A gateway or "
5206
+ "proxy prefix the scan did not strip, an endpoint not built yet, or "
5207
+ "an API served from somewhere else all look like this. Where a call "
5208
+ "does reach an operation above that its feature does not list yet, "
5209
+ "add the operation to that feature's Operations cell.\n")
5210
+ lines.append(_md_table(
5211
+ ["UI call", "Feature(s)", "Called in"],
5212
+ [[f"`{call}`", ", ".join(fs) or "—",
5213
+ ", ".join(f"`{f}`" for f in files[:2]) + (" …" if len(files) > 2 else "")]
5214
+ for call, fs, files in unserved[:UNPLACED_ROWS]])
5215
+ + (f"\n\n…and {len(unserved) - UNPLACED_ROWS} more."
5216
+ if len(unserved) > UNPLACED_ROWS else "") + "\n")
3558
5217
  review_section = "\n".join(lines)
3559
5218
 
3560
5219
  # Both trailing sections are conditional and carry their own leading blank line,
@@ -3639,62 +5298,173 @@ def _pascal(s):
3639
5298
  return "".join(w[:1].upper() + w[1:] for w in words if w)
3640
5299
 
3641
5300
 
3642
- def _read_frontmatter(path):
3643
- """Return the concept's frontmatter dict (title/type/source_files), best-effort
3644
- without a YAML dependency. Only the fields adopt needs."""
3645
- try:
3646
- with open(path, encoding="utf-8") as f:
3647
- text = f.read()
3648
- except OSError:
3649
- return {}
5301
+ FM_KEY_RE = re.compile(r"^([A-Za-z0-9_]+):\s*(.*)$")
5302
+ FM_ITEM_RE = re.compile(r"^\s*-\s+")
5303
+
5304
+
5305
+ def _flow_list(text):
5306
+ """The items of a YAML flow sequence (`[a, "b, c"]`, `[]`), or None when `text` is not
5307
+ one whole sequence — `[draft] notes` is text, and so is a `[` that never closes. Items
5308
+ are split on the commas outside quotes and unquoted; a trailing `# comment` after the
5309
+ closing bracket is ignored."""
5310
+ if not text.startswith("["):
5311
+ return None
5312
+ items, cur, quote, i = [], "", "", 1
5313
+ while i < len(text):
5314
+ ch = text[i]
5315
+ if quote:
5316
+ if ch == "\\" and quote == '"' and i + 1 < len(text):
5317
+ cur, i = cur + text[i + 1], i + 2
5318
+ continue
5319
+ if ch == quote:
5320
+ quote = ""
5321
+ else:
5322
+ cur += ch
5323
+ elif ch in "'\"":
5324
+ quote = ch
5325
+ elif ch == ",":
5326
+ items.append(cur.strip())
5327
+ cur = ""
5328
+ elif ch == "]":
5329
+ rest = text[i + 1:].strip()
5330
+ if rest and not rest.startswith("#"):
5331
+ return None
5332
+ items.append(cur.strip())
5333
+ return [s for s in items if s]
5334
+ else:
5335
+ cur += ch
5336
+ i += 1
5337
+ return None
5338
+
5339
+
5340
+ def parse_frontmatter(text):
5341
+ """(frontmatter dict, body) of a concept, without a YAML dependency. The one reader
5342
+ every script that reads a concept uses (aw-xref loads it from here), so a concept's
5343
+ lists read the same everywhere.
5344
+
5345
+ A list is read in either YAML form: block — `key:` then ` - item` lines, with the
5346
+ `# group` comment lines between items skipped — or flow — `key: [a, b]`, `key: []`,
5347
+ which may run over several lines to its closing `]`. Any other value is text with its
5348
+ quotes removed, and a key with nothing after it is an empty list."""
3650
5349
  if not text.startswith("---"):
3651
- return {}
5350
+ return {}, text
3652
5351
  lines = text.splitlines()
3653
5352
  end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
3654
5353
  if end is None:
3655
- return {}
3656
- data, key = {}, None
3657
- for ln in lines[1:end]:
3658
- m = re.match(r"^([A-Za-z0-9_]+):\s*(.*)$", ln)
5354
+ return {}, text
5355
+ fm_lines = lines[1:end]
5356
+ data, key, i = {}, None, 0
5357
+ while i < len(fm_lines):
5358
+ ln = fm_lines[i]
5359
+ i += 1
5360
+ m = FM_KEY_RE.match(ln)
3659
5361
  if m:
3660
5362
  key = m.group(1)
3661
5363
  val = m.group(2).strip()
5364
+ if val.startswith("["):
5365
+ joined, j = val, i
5366
+ while (_flow_list(joined) is None and "]" not in joined.split("#")[0]
5367
+ and j < len(fm_lines) and not FM_KEY_RE.match(fm_lines[j])):
5368
+ joined, j = joined + " " + fm_lines[j].strip(), j + 1
5369
+ items = _flow_list(joined)
5370
+ if items is not None:
5371
+ data[key], i = items, j
5372
+ continue
3662
5373
  data[key] = val.strip("'\"") if val else []
3663
- elif key and re.match(r"^\s*-\s+", ln): # a list item under the last key
5374
+ elif key and FM_ITEM_RE.match(ln): # a list item under the last key
3664
5375
  if not isinstance(data.get(key), list):
3665
5376
  data[key] = []
3666
- data[key].append(re.sub(r"^\s*-\s+", "", ln).strip().strip("'\""))
3667
- return data
5377
+ data[key].append(FM_ITEM_RE.sub("", ln).strip().strip("'\""))
5378
+ return data, "\n".join(lines[end + 1:])
5379
+
5380
+
5381
+ def fm_list(value):
5382
+ """A frontmatter list field as its non-empty strings. A single value where a list
5383
+ belongs (`source_files: src/a.cs`) is a one-item list, never its characters."""
5384
+ if isinstance(value, list):
5385
+ return [s for s in value if isinstance(s, str) and s.strip()]
5386
+ return [value] if isinstance(value, str) and value.strip() else []
5387
+
5388
+
5389
+ def _read_frontmatter(path):
5390
+ """A concept file's frontmatter dict (see parse_frontmatter), {} when unreadable."""
5391
+ try:
5392
+ with open(path, encoding="utf-8") as f:
5393
+ return parse_frontmatter(f.read())[0]
5394
+ except OSError:
5395
+ return {}
3668
5396
 
3669
5397
 
3670
5398
  def cmd_propose(args):
3671
5399
  """Pass A + B + C over the repo: write the editable boundary map, and the
3672
- machine map onboard reads until the operator edits the first one."""
5400
+ machine map onboard reads until the operator edits the first one.
5401
+
5402
+ A project that declares a UI lane and no service lane — a front end whose API
5403
+ lives in another repository — is read from the UI lane alone (`ui`, see
5404
+ ui_model): there is no service source for Pass A to read, and reading the UI's
5405
+ source as if it were would draw aggregates out of components. A declared lane whose
5406
+ lane root folder does not exist yet is read as undeclared beside one that does (see
5407
+ built_lanes), and the proposal names it."""
3673
5408
  root = os.path.abspath(args.root or os.getcwd())
3674
5409
  excludes = load_config_excludes(args.config) + kit_excludes(root)
3675
- files = list_files(root, excludes, (args.service_root, args.ui_root))
3676
- be_files, fe_files, svc_prefix, ui_prefix = scope_to_lanes(
3677
- files, args.service_root, args.ui_root)
5410
+ service_root, ui_root, unbuilt = built_lanes(root, args.service_root, args.ui_root)
5411
+ files = list_files(root, excludes, (service_root, ui_root))
5412
+ be_files, fe_files, svc_prefix, ui_prefix = scope_to_lanes(files, service_root, ui_root)
3678
5413
  churn_list, _ = churn(root, excludes)
3679
5414
  churn_map = {c["file"]: c["commits"] for c in churn_list}
3680
-
3681
- results, avail, graph, gstats, ents, model = run_probe(root, be_files, churn_map)
3682
- # The menu hands back the whole option line, numbers and all, so the first
3683
- # token is the strategy and the rest is what the operator was shown.
3684
- wanted = (args.strategy or "").strip().split()[0].strip("`*_-").lower() if args.strategy else ""
5415
+ ui_only = bool(ui_root) and not service_root
5416
+ no_service = next((f"the service lane `{p}` has no folder yet" for k, p in unbuilt
5417
+ if k == "service"), "no service lane is declared")
5418
+
5419
+ if ui_only:
5420
+ ufeats, ui_cands, ui_ops = ui_reading(root, fe_files, ui_prefix, churn_map)
5421
+ ui_code = [f for f in fe_files if _ui_code(f)]
5422
+ covered = {f for r in ufeats.values() for f in r["files"]} | \
5423
+ {f for c in ui_cands for f in c["covered_code"]}
5424
+ why_not = f"{no_service} — the map is read from the UI lane"
5425
+ avail = {n: {"available": False, "reason": why_not} for n in STRATEGY_PREFERENCE}
5426
+ avail["ui"] = {"available": bool(ui_cands), "reason":
5427
+ f"{no_service}: the resources the UI lane calls are the "
5428
+ "aggregates it works on, its feature folders the capabilities"}
5429
+ results = {"ui": {
5430
+ "candidates": ui_cands, "count": len(ui_cands),
5431
+ "coverage_pct": round(100.0 * len(covered & set(ui_code)) / max(1, len(ui_code)), 1),
5432
+ "singletons": sum(1 for c in ui_cands if c["raw_file_count"] <= 1),
5433
+ "available": True, "reason": avail["ui"]["reason"], "operations": len(ui_ops),
5434
+ }} if ui_cands else {}
5435
+ graph, gstats, model = {}, {}, None
5436
+ else:
5437
+ results, avail, graph, gstats, ents, model = run_probe(root, be_files, churn_map)
5438
+ # --strategy carries the operator's request (`persistence focus on billing`), or the
5439
+ # menu's whole option line, numbers and all. Its first word forces a strategy when it
5440
+ # names one; any other first word forces nothing, and the strategy is chosen as if
5441
+ # none were asked for.
5442
+ words = (args.strategy or "").split()
5443
+ wanted = words[0].strip("`*_-").lower() if words else ""
5444
+ if wanted not in STRATEGY_PREFERENCE + ("ui",):
5445
+ wanted = ""
3685
5446
  if wanted:
3686
5447
  if wanted not in results:
3687
- reason = avail.get(wanted, {}).get("reason", "not a known strategy")
5448
+ # Every strategy but `ui` has an availability reason of its own.
5449
+ reason = avail.get(wanted, {}).get("reason", "it reads a project with no service lane")
3688
5450
  print(json.dumps({"ok": "false", "error":
3689
5451
  f"strategy '{wanted}' is not available here — {reason}"}))
3690
5452
  return 1
3691
5453
  picked = wanted
5454
+ elif ui_only:
5455
+ picked = "ui" if results else ""
3692
5456
  else:
3693
5457
  picked = choose_strategy(results)
3694
5458
 
3695
5459
  md = os.path.abspath(args.out) if args.out else os.path.join(root, "specs", "proposed-inventory.md")
3696
5460
  mp = os.path.abspath(args.map) if args.map else os.path.join(root, ".appweaver", "inventory", "feature-map.json")
3697
- code_total = len([f for f in be_files if CODE_EXT_RE.search(f)])
5461
+ code_total = len(ui_code) if ui_only else len([f for f in be_files if CODE_EXT_RE.search(f)])
5462
+ if not picked and ui_only:
5463
+ return _nothing_to_propose(
5464
+ root, mp, f"{no_service[:1].upper()}{no_service[1:]}, and the UI lane '{ui_prefix or './'}' "
5465
+ f"({len(fe_files)} file(s), {code_total} of them source) calls no API "
5466
+ f"route the scan could read, so it names no aggregate to propose "
5467
+ f"({len(ufeats)} feature folder(s) found).")
3698
5468
  if not picked:
3699
5469
  why = "; ".join(f"{n}: {avail[n]['reason']}" for n in STRATEGY_PREFERENCE)
3700
5470
  return _nothing_to_propose(
@@ -3703,20 +5473,40 @@ def cmd_propose(args):
3703
5473
  f"source) — {why}.")
3704
5474
 
3705
5475
  candidates = results[picked]["candidates"]
3706
- contexts = derive_contexts(candidates, be_files)
3707
- # Declared aggregates are ordered by what their types reference, not by imports: in
3708
- # a namespace language every file of one Domain folder shares a namespace, so the
3709
- # import graph links each root to every other and reads as one cycle.
3710
- edges = (model["root_edges"] if picked == "domain"
3711
- else candidate_edges(candidates, graph))
3712
- seams = seam_overlay(root, be_files, candidates, churn_map, contexts=contexts)
3713
- operations = (model["operations"] if picked == "domain"
3714
- else detect_operations(root, be_files, candidates))
5476
+ if ui_only:
5477
+ contexts = derive_contexts(candidates, fe_files)
5478
+ edges, seams, operations = {}, {}, ui_ops
5479
+ else:
5480
+ contexts = derive_contexts(candidates, be_files)
5481
+ # Declared aggregates are ordered by what their types reference, not by imports:
5482
+ # in a namespace language every file of one Domain folder shares a namespace, so
5483
+ # the import graph links each root to every other and reads as one cycle.
5484
+ edges = (model["root_edges"] if picked == "domain"
5485
+ else candidate_edges(candidates, graph))
5486
+ seams = seam_overlay(root, be_files, candidates, churn_map, contexts=contexts)
5487
+ operations = (model["operations"] if picked == "domain"
5488
+ else detect_operations(root, be_files, candidates))
5489
+ # Ids the lane publishes by route fill an operation whose endpoint declares none:
5490
+ # its doc comments first, then the API documents it keeps. The code is what the
5491
+ # service runs, and a checked-in document can be stale; documents that disagree
5492
+ # with each other are all kept, so the conflict shows.
5493
+ in_code, in_docs = published_ids(root, be_files)
5494
+ for o in operations:
5495
+ ids = o.setdefault("operation_ids", [])
5496
+ for source in (in_code, in_docs):
5497
+ if ids:
5498
+ break
5499
+ for v, p in o["routes"]:
5500
+ ids += [i for i in source.get(_route_key(v, p), ()) if i not in ids]
3715
5501
  ctx_of = {n: ctx for ctx, names in contexts.items() for n in names}
3716
5502
  for o in operations:
3717
5503
  o["context"] = o["context"] or ctx_of.get((o["acts_on"] or [""])[0], "")
3718
- ufeats = ui_features(root, fe_files, ui_prefix)
3719
- features, notes = propose_features(candidates, seams, edges, contexts, operations, ufeats)
5504
+ ui_calls = None
5505
+ if not ui_only:
5506
+ ufeats = ui_features(root, fe_files, ui_prefix)
5507
+ ui_calls = ui_api_calls(root, fe_files) if ui_prefix else []
5508
+ features, notes = propose_features(candidates, seams, edges, contexts, operations, ufeats,
5509
+ ui_calls)
3720
5510
  for o in operations:
3721
5511
  o["features"] = [f["feature"] for f in features if o["name"] in f["operations"]]
3722
5512
  if not features:
@@ -3731,21 +5521,28 @@ def cmd_propose(args):
3731
5521
  wave_of = {m: w["wave"] for w in waves for m in w["members"]}
3732
5522
  features.sort(key=lambda f: (wave_of.get(f["feature"], 99), f["context"], f["feature"]))
3733
5523
 
3734
- lane_files = len(set(be_files) | set(fe_files)) if (svc_prefix or ui_prefix) else len(files)
5524
+ lane_files = len(fe_files) if ui_only else \
5525
+ len(set(be_files) | set(fe_files)) if (svc_prefix or ui_prefix) else len(files)
3735
5526
  meta = {"root": root, "head": git(root, "rev-parse", "--short", "HEAD") or "-",
3736
5527
  "date": datetime.date.today().isoformat(), "strategy": picked,
3737
5528
  "reason": results[picked]["reason"], "files": lane_files,
3738
5529
  "code": code_total, "coverage": results[picked]["coverage_pct"],
3739
- "scanned": _scanned_line(files, be_files, fe_files, svc_prefix, ui_prefix)}
5530
+ "scanned": _scanned_line(files, be_files, fe_files, svc_prefix, ui_prefix, ui_only,
5531
+ unbuilt)}
5532
+ shown = STRATEGY_PREFERENCE + (("ui",) if ui_only else ())
3740
5533
  probe_rows = [[n, results[n]["count"], _names_preview(results[n]["candidates"]),
3741
5534
  f"{results[n]['coverage_pct']}%",
3742
5535
  results[n]["singletons"], "**yes**" if n == picked else "",
3743
5536
  results[n]["reason"]] if n in results else
3744
5537
  [n, "—", "—", "—", "—", "", f"unavailable — {avail[n]['reason']}"]
3745
- for n in STRATEGY_PREFERENCE]
5538
+ for n in shown]
3746
5539
  covered = {f for c in candidates for f in c.get("covered_code", [])}
3747
- unplaced = sorted(f for f in be_files if CODE_EXT_RE.search(f) and f not in covered
3748
- and not TEST_PATH_RE.search(f))
5540
+ if ui_only:
5541
+ covered |= {f for r in ufeats.values() for f in r["files"]}
5542
+ unplaced = sorted(f for f in ui_code if f not in covered)
5543
+ else:
5544
+ unplaced = sorted(f for f in be_files if CODE_EXT_RE.search(f) and f not in covered
5545
+ and not TEST_PATH_RE.search(f))
3749
5546
  aggregate_detail = [
3750
5547
  {"name": c["name"], "context": ctx_of.get(c["name"], ""),
3751
5548
  "root_file": c.get("root_file", ""), "declared": picked == "domain",
@@ -3759,6 +5556,20 @@ def cmd_propose(args):
3759
5556
  _write_feature_map(mp, meta, contexts, features, waves, cycles, results, avail, gstats,
3760
5557
  operations=operations, aggregate_detail=aggregate_detail)
3761
5558
 
5559
+ # One option line per AVAILABLE strategy, each carrying its own numbers so the pick is
5560
+ # made against evidence, the strategy this run used first: a run that cannot ask takes
5561
+ # the first option, which is the proposal. A strategy that would return nothing is never
5562
+ # offered — an empty pick is the zero-candidate stop by choice. A strategy the request
5563
+ # named is also returned on its own, as `forced_option`, for the menu to take unasked.
5564
+ options = [
5565
+ f"{n} — {results[n]['count']} aggregates ({_names_preview(results[n]['candidates'])}), "
5566
+ f"{results[n]['coverage_pct']}% coverage, {results[n]['singletons']} singleton(s)"
5567
+ + (" [declared by the code]" if n == "domain" else "")
5568
+ + (" [owns/references available]" if n == "persistence" else "")
5569
+ + (" [called by the UI lane]" if n == "ui" else "")
5570
+ for n in sorted((n for n in shown if n in results), key=lambda n: n != picked)
5571
+ ]
5572
+
3762
5573
  print(f"Proposed inventory written to {md}", file=sys.stderr)
3763
5574
  print(f" strategy: {picked} contexts: {len(contexts)} aggregates: {len(candidates)} "
3764
5575
  f"features: {len(features)} operations: {len(operations)} waves: {len(waves)}",
@@ -3773,17 +5584,9 @@ def cmd_propose(args):
3773
5584
  "contexts_ordered": ", ".join(_ordered_contexts(contexts, features, waves)),
3774
5585
  "coverage_pct": results[picked]["coverage_pct"],
3775
5586
  "split_candidates": sum(1 for f in features if f["split_proposed"]),
3776
- # One option line per AVAILABLE strategy, each carrying its own numbers so
3777
- # the pick is made against evidence. A strategy that would return nothing
3778
- # is never offered — an empty pick is the zero-candidate stop by choice.
3779
5587
  "unplaced_count": len(unplaced),
3780
- "strategy_options": [
3781
- f"{n} — {results[n]['count']} aggregates ({_names_preview(results[n]['candidates'])}), "
3782
- f"{results[n]['coverage_pct']}% coverage, {results[n]['singletons']} singleton(s)"
3783
- + (" [declared by the code]" if n == "domain" else "")
3784
- + (" [owns/references available]" if n == "persistence" else "")
3785
- for n in STRATEGY_PREFERENCE if n in results
3786
- ],
5588
+ "strategy_options": options,
5589
+ "forced_option": options[0] if wanted else "",
3787
5590
  }))
3788
5591
  return 0
3789
5592
 
@@ -3854,6 +5657,7 @@ def _write_feature_map(path, meta, contexts, features, waves, cycles,
3854
5657
  "routes": [f"{v} {p}".strip() for v, p in o.get("routes", [])]
3855
5658
  if o.get("routes") and isinstance(o["routes"][0], (list, tuple))
3856
5659
  else list(o.get("routes", [])),
5660
+ "operation_ids": list(o.get("operation_ids") or []),
3857
5661
  "files": len(o["files"]) if isinstance(o.get("files"), list) else o.get("files", 0),
3858
5662
  "features": [f["feature"] for f in features if o["name"] in f.get("operations", [])]}
3859
5663
  for o in operations]
@@ -4333,8 +6137,7 @@ def cmd_concept(args):
4333
6137
  if not is_match:
4334
6138
  continue
4335
6139
  typ = fm.get("type") if isinstance(fm.get("type"), str) else ""
4336
- sf = [s for s in (fm.get("source_files") or []) if isinstance(s, str) and s.strip()]
4337
- usf = [s for s in (fm.get("ui_source_files") or []) if isinstance(s, str) and s.strip()]
6140
+ sf, usf = fm_list(fm.get("source_files")), fm_list(fm.get("ui_source_files"))
4338
6141
  score = (2 if "aggregate" in typ.lower() else 0) + (1 if _slug(base) == want else 0)
4339
6142
  cand = {"concept_path": os.path.relpath(path, os.getcwd()),
4340
6143
  "title": title or base, "type": typ, "source_files": sf,
@@ -4407,12 +6210,11 @@ def cmd_list_aggregates(args):
4407
6210
  name = _pascal(title or base)
4408
6211
  if not name:
4409
6212
  continue
4410
- sf = [s for s in (fm.get("source_files") or []) if isinstance(s, str) and s.strip()]
6213
+ sf = fm_list(fm.get("source_files"))
4411
6214
  bc = fm.get("bounded_context") if isinstance(fm.get("bounded_context"), str) else ""
4412
6215
  if want_ctx and bc and re.sub(r"[^a-z0-9]", "", bc.lower()) != want_ctx:
4413
6216
  continue
4414
- listed = [s for s in (fm.get("features") or []) if isinstance(s, str) and s.strip()] \
4415
- if isinstance(fm.get("features"), list) else []
6217
+ listed = fm_list(fm.get("features"))
4416
6218
  cand = {
4417
6219
  "name": name,
4418
6220
  "title": title or base,
@@ -4539,7 +6341,8 @@ def main():
4539
6341
  help="The declared lane root folder of the UI lane. Frontend signals are derived "
4540
6342
  "from this subtree only, and it is excluded from the backend's — "
4541
6343
  "so neither lane's substrate carries the other's files.")
4542
- ps.add_argument("--map", help="Machine map whose features attribute UI feature folders "
6344
+ ps.add_argument("--map", help="Machine map whose Pass A strategy the aggregate signals are "
6345
+ "read with, and whose features attribute UI feature folders "
4543
6346
  "(default: <root>/.appweaver/inventory/feature-map.json, "
4544
6347
  "when present).")
4545
6348
  ps.set_defaults(func=cmd_scan)
@@ -4552,9 +6355,11 @@ def main():
4552
6355
  pp.add_argument("--ui-root", dest="ui_root", default="",
4553
6356
  help="The declared lane root folder of the UI lane.")
4554
6357
  pp.add_argument("--strategy", default="",
4555
- help="Pass A strategy: structure | reference | persistence. "
4556
- "Default: the highest-coverage AVAILABLE strategy, ties "
4557
- "broken by fewest singletons.")
6358
+ help="The request: a first word naming a Pass A strategy (domain | "
6359
+ "structure | reference | persistence | ui) forces it; any other "
6360
+ "first word forces nothing. Default: declared roots that "
6361
+ "operations act on, else the highest-coverage AVAILABLE "
6362
+ "strategy, ties broken by fewest singletons.")
4558
6363
  pp.add_argument("--out", help="Proposal markdown (default: <root>/specs/proposed-inventory.md).")
4559
6364
  pp.add_argument("--map", help="Machine map (default: <root>/.appweaver/inventory/feature-map.json).")
4560
6365
  pp.set_defaults(func=cmd_propose)