aw-spec-cli 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/assets/.appweaver/DESIGN-FORMAT.md +17 -9
  2. package/assets/.appweaver/ORCH-DIALECT.md +8 -3
  3. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +26 -3
  4. package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
  5. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +1 -1
  6. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +4 -2
  7. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +1 -1
  8. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +1 -1
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +121 -42
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +7 -7
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +145 -52
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +18 -10
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +306 -28
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +1 -1
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +1 -1
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +1 -1
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +1 -1
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +114 -30
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +385 -61
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +217 -174
  23. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +407 -107
  24. package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +1 -1
  25. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +49 -11
  26. package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +1 -1
  27. package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +1 -1
  28. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +29 -23
  29. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
  30. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +19 -11
  31. package/assets/.appweaver/scripts/bash/aw-inventory.sh +15 -0
  32. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +683 -92
  33. package/assets/.appweaver/scripts/bash/aw-xref.sh +3 -0
  34. package/assets/.appweaver/scripts/powershell/aw-inventory.ps1 +15 -0
  35. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +625 -64
  36. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +3 -0
  37. package/assets/.appweaver/scripts/python/aw-crstate.py +194 -0
  38. package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
  39. package/assets/.appweaver/scripts/python/aw-inventory.py +1829 -63
  40. package/assets/.appweaver/scripts/python/aw-payload-parity.py +3 -0
  41. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +423 -0
  42. package/assets/.appweaver/scripts/python/aw-stack.py +251 -28
  43. package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
  44. package/assets/.appweaver/scripts/python/aw-xref.py +500 -81
  45. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +1 -0
  46. package/assets/.appweaver/stack-profiles/java-spring.json +1 -0
  47. package/assets/.appweaver/stack-profiles/node-express.json +1 -0
  48. package/assets/.appweaver/stack-profiles/python-fastapi.json +1 -0
  49. package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
  50. package/assets/.appweaver/templates/delta-design-template.md +1 -1
  51. package/assets/.appweaver/templates/delta-domain-model-template.md +1 -1
  52. package/assets/.appweaver/templates/delta-spec-template.md +4 -3
  53. package/assets/.appweaver/templates/design-template.md +16 -11
  54. package/assets/.appweaver/templates/okf-concept-template.md +11 -1
  55. package/assets/.appweaver/templates/proposed-inventory-template.md +127 -0
  56. package/assets/.appweaver/templates/source-map-template.md +5 -3
  57. package/assets/.appweaver/templates/spec-template.md +17 -17
  58. package/assets/.appweaver/templates/ui-delta-design-template.md +1 -1
  59. package/assets/.appweaver/templates/ui-design-template.md +4 -4
  60. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +4489 -361
  61. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +2274 -125
  62. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
  63. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
  64. package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
  65. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
  66. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
  67. package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
  68. package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
  69. package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
  70. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +70 -0
  71. package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
  72. package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
  73. package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
  74. package/package.json +1 -1
  75. package/assets/.appweaver/orchestrator/.state/smoke.state.json +0 -32
@@ -56,6 +56,7 @@ Usage: aw-inventory.py <scan|concept|list-aggregates|validate> [options]
56
56
  """
57
57
 
58
58
  import argparse
59
+ import datetime
59
60
  import json
60
61
  import os
61
62
  import re
@@ -367,10 +368,20 @@ CODE_EXT_RE = re.compile(r"\.(py|js|ts|go|rs|rb|java|cs|php|kt|scala|sql|prisma)
367
368
 
368
369
  def singularize(name):
369
370
  """Light singularization for a module dir name (MedicinalProducts ->
370
- MedicinalProduct). Signal-grade only — never grammar-perfect."""
371
- if len(name) > 4 and name.endswith("s") and not name.lower().endswith(("ss", "us", "is", "as")):
372
- return name[:-1]
373
- return name
371
+ MedicinalProduct, Pharmacies -> Pharmacy). Signal-grade only — never
372
+ grammar-perfect. The name reaches a person in the proposal document, so the
373
+ -ies and -es endings are worth carrying: Pharmacie is a visible defect in a
374
+ file an operator is asked to review."""
375
+ if len(name) <= 4 or not name.endswith(("s", "S")):
376
+ return name
377
+ low = name.lower()
378
+ if low.endswith(("ss", "us", "is", "as")):
379
+ return name
380
+ if low.endswith("ies"):
381
+ return name[:-3] + "y"
382
+ if low.endswith(("ses", "xes", "zes", "ches", "shes")):
383
+ return name[:-2]
384
+ return name[:-1]
374
385
 
375
386
 
376
387
  # --- glob-based config excludes (best-effort, mirrors okf-inventory.sh) -----
@@ -495,85 +506,1103 @@ def _entity_noun(path):
495
506
  return base if base and base[:1].isalpha() and len(base) > 2 else None
496
507
 
497
508
 
498
- def aggregate_signals(files, churn_map):
499
- """Derive CANDIDATE aggregate roots — one per vertical slice, the base unit
500
- the /appweaver inventory workflow turns into a specs/<Feature>/ slice.
509
+ # ═══════════════════════════════════════════════════════════════════════════
510
+ # PASS A — aggregate identification: what must stay consistent together.
511
+ #
512
+ # Three strategies share one interface — (root, files, churn_map, graph) ->
513
+ # [candidate] — and emit the candidate shape aggregate_signals() has always
514
+ # emitted, plus `strategy`, `confidence` and (persistence only) `owns` /
515
+ # `references_by_id`.
516
+ #
517
+ # S1 is not a peer of the other two. S2 returns unnamed file clusters and S3
518
+ # returns entity roots; both need S1's axis detection to turn a cluster into a
519
+ # module_dir and a human name, so S1 always runs underneath as the naming layer.
520
+ # ═══════════════════════════════════════════════════════════════════════════
521
+
522
+ # Path segments that organise code without naming a domain module. The parent
523
+ # of a layer folder decides whether that layer is a module boundary: in
524
+ # src/Features/Patients/Domain/X.cs the module is Patients, and `Features` says
525
+ # only that a vertical-slice layout is in use. `features?` is itself a member of
526
+ # LAYER_DIR_RE, so without this list the walk stops at the container and names
527
+ # the module `src`.
528
+ CONTAINER_DIR_RE = re.compile(
529
+ r"^(src|source|sources|lib|libs|app|apps|pkg|packages?|projects?|modules?|"
530
+ r"slices?|features?|areas?|components?|services?|internal|cmd|main|java|"
531
+ r"kotlin|resources|test|tests|spec|specs|__tests__)$",
532
+ re.IGNORECASE,
533
+ )
534
+ # A project segment carrying its layer as a name SUFFIX (Acme.Patient.Domain).
535
+ # LAYER_DIR_RE is anchored ^…$ and cannot see these, which is why a repo laid
536
+ # out one-project-per-layer yields nothing from the folder walk alone.
537
+ DOTTED_SEG_RE = re.compile(r"^[A-Za-z_]\w*(\.[A-Za-z_]\w*)+$")
538
+ # Organisation/product prefixes that lead a dotted project name without naming
539
+ # a module. Dropped from the left before the module segment is read.
540
+ VENDOR_PREFIX_RE = re.compile(r"^(com|org|net|io|co|inc|ltd)$", re.IGNORECASE)
541
+
542
+
543
+ # Folders that hold ONE vertical slice per child. These are also members of
544
+ # LAYER_DIR_RE, and reading them as layers is what collapses a vertical-slice
545
+ # repo onto its container: in <Project>/Features/Orders/ the module is Orders,
546
+ # and `Features` only says which layout is in use. Checked before the layer
547
+ # vocabulary, and independently of what the parent segment is — the container
548
+ # can sit under src/ or directly under a project folder.
549
+ SLICE_CONTAINER_RE = re.compile(
550
+ r"^(features?|modules?|slices?|areas?|use[-_]?cases?|capabilit(y|ies))$",
551
+ re.IGNORECASE,
552
+ )
553
+ # A second layer vocabulary, consulted only to BUCKET a file once its module is
554
+ # already known. These names are too generic to mark a module boundary — every
555
+ # project has a Services/ — but they do say what the file is once you are inside
556
+ # one, which is what keeps the layer mix of a candidate meaningful.
557
+ SOFT_LAYER_KIND = {
558
+ "services": "feature", "service": "feature", "commands": "feature",
559
+ "queries": "feature", "validators": "feature", "behaviors": "feature",
560
+ "interfaces": "domain", "abstractions": "domain", "events": "domain",
561
+ "exceptions": "domain", "specifications": "domain", "valueobjects": "domain",
562
+ "enums": "domain", "stores": "infra", "publishers": "infra",
563
+ "interceptors": "infra", "composition": "infra", "migrations": "infra",
564
+ "configurations": "infra", "contexts": "infra", "middleware": "api",
565
+ "filters": "api", "views": "api", "pages": "api", "components": "api",
566
+ "extensions": "other", "utilities": "other", "utils": "other",
567
+ "helpers": "other", "common": "other", "mappers": "other",
568
+ }
569
+ # Trailing parts of a dotted project name that say what the project IS rather
570
+ # than which module it serves (AppWeaver.VerticalSlice.Service -> VerticalSlice).
571
+ PROJECT_ROLE_RE = re.compile(
572
+ r"^(service|services|web|webapi|api|apis|apphost|host|hosting|"
573
+ r"servicedefaults|defaults|worker|workers|console|cli|functions|"
574
+ r"tests?|unittests?|integrationtests?|testing|benchmarks?|samples?|"
575
+ r"client|clients|server|sdk|lib|library|app|blazor|razor|mvc|grpc)$",
576
+ re.IGNORECASE,
577
+ )
501
578
 
502
- PRIMARY signal (low-noise): a MODULE DIRECTORY that owns layer folders
503
- (Domain / Features / Persistence / Api …). Its parent segment, singularized,
504
- is the aggregate (…/MedicinalProducts/Domain/… -> MedicinalProduct); every
505
- file under the module is grouped by layer. FALLBACK (flat repos with no
506
- layer folders): cluster domain/route/api files on the basename noun.
507
579
 
508
- Signals only — the workflow's agent draws the real boundaries. Empty for a
509
- repo with no recognizable domain structure."""
510
- modules = {} # module_dir -> candidate
580
+ def _layer_of(seg, soft=False):
581
+ """The layer bucket a path segment names, or None when it names no layer.
582
+
583
+ `soft` widens the vocabulary to the generic folder names that describe a
584
+ file without bounding a module."""
585
+ s = (seg or "").lower()
586
+ if LAYER_DIR_RE.match(seg or ""):
587
+ return LAYER_KIND.get(s, "other")
588
+ return SOFT_LAYER_KIND.get(s) if soft else None
589
+
590
+
591
+ def _project_roots(files):
592
+ """Directories a build manifest calls a project. This is the one module
593
+ boundary in a repo that is EVIDENCED rather than inferred from a folder
594
+ name, so it carries the fallback when no layer vocabulary appears at all —
595
+ which is the common case in a library-shaped tree whose folders are
596
+ Services/ and Extensions/."""
597
+ roots = set()
598
+ for f in files:
599
+ if MANIFESTS_RE.search(f) and "/" in f:
600
+ roots.add(f.rsplit("/", 1)[0])
601
+ return roots
511
602
 
512
- def module_candidate(module_dir, name):
513
- return modules.setdefault(module_dir, {
514
- "name": name, "module_dir": module_dir,
515
- "domain_files": [], "feature_files": [], "infra_files": [],
516
- "api_files": [], "other_files": [], "entities": set(), "churn": 0,
517
- })
518
603
 
519
- # PRIMARY: walk each file's path for a layer folder; its parent is the module.
520
- module_files = {} # module_dir -> [(file, kind)]
604
+ def _product_prefix(files):
605
+ """The dotted prefix this repo puts in front of most project names, measured
606
+ rather than assumed. `AppWeaver` leads nearly every project here and names no
607
+ module, so carrying it into every candidate would make the map unreadable."""
608
+ heads = {}
609
+ for f in files:
610
+ for seg in f.split("/")[:-1]:
611
+ if DOTTED_SEG_RE.match(seg):
612
+ h = seg.split(".")[0]
613
+ heads[h] = heads.get(h, 0) + 1
614
+ if not heads:
615
+ return ""
616
+ top, n = max(heads.items(), key=lambda kv: kv[1])
617
+ return top if n >= 3 and n >= 0.5 * sum(heads.values()) else ""
618
+
619
+
620
+ def _clean_project_name(seg, product):
621
+ """A dotted project segment reduced to the module it serves: the measured
622
+ product prefix dropped from the left, project-role words from the right."""
623
+ parts = [p for p in (seg or "").split(".") if p]
624
+ if product and len(parts) > 1 and parts[0].lower() == product.lower():
625
+ parts = parts[1:]
626
+ while len(parts) > 1 and PROJECT_ROLE_RE.match(parts[-1]):
627
+ parts = parts[:-1]
628
+ while len(parts) > 1 and VENDOR_PREFIX_RE.match(parts[0]):
629
+ parts = parts[1:]
630
+ name = "".join(p[:1].upper() + p[1:] for p in parts)
631
+ return singularize(name) or name
632
+
633
+
634
+ def _dotted_module(seg):
635
+ """Split a dotted project segment into (module, layer_kind), or (None, None).
636
+
637
+ Acme.Patient.Domain -> (Patient, domain); Patient.Api -> (Patient, api).
638
+ The layer is the LAST dot-part; everything between the vendor prefix and it
639
+ is the module, so Acme.Billing.Invoices.Domain -> BillingInvoices."""
640
+ if not DOTTED_SEG_RE.match(seg or ""):
641
+ return None, None
642
+ parts = seg.split(".")
643
+ kind = _layer_of(parts[-1])
644
+ if not kind or len(parts) < 2:
645
+ return None, None
646
+ mid = parts[:-1]
647
+ while len(mid) > 1 and VENDOR_PREFIX_RE.match(mid[0]):
648
+ mid = mid[1:]
649
+ # A single leading segment is the product name, not a module, once another
650
+ # segment follows it (Acme.Patient -> Patient).
651
+ if len(mid) > 1:
652
+ mid = mid[1:]
653
+ name = "".join(p[:1].upper() + p[1:] for p in mid)
654
+ return (singularize(name) or name, kind) if name else (None, None)
655
+
656
+
657
+ # How much a rule's naming is trusted when two rules land files in one group.
658
+ AXIS_RANK = {"slice-major": 0, "project-major": 1, "module-major": 2,
659
+ "layer-major": 3, "project-root": 4, "flat": 5}
660
+
661
+
662
+ def _structure_rows(files):
663
+ """Classify each file onto the repo's organising axis, yielding
664
+ (group_key, module_name, layer_kind, file, axis).
665
+
666
+ Five idioms, tried in order of how specific their evidence is:
667
+
668
+ slice-major <Project>/Features/Orders/… an explicit vertical slice
669
+ project-major src/Acme.Patient.Domain/X.cs layer carried as a name suffix
670
+ module-major src/Patients/Domain/X.cs layer folder under the module
671
+ layer-major src/Domain/Patient.cs layer at the top; the module
672
+ exists only as a repeated noun
673
+ project-root AppWeaver.AI/Services/X.cs no layer vocabulary at all;
674
+ the build manifest bounds it
675
+
676
+ layer-major groups on the role-stripped basename rather than on a folder,
677
+ which is the only thing that unifies Patient.cs, PatientService.cs and
678
+ PatientController.cs across three sibling tier folders. project-root catches
679
+ the library-shaped tree whose folders (Services/, Extensions/) describe files
680
+ without bounding modules — without it those files reach no candidate at all."""
681
+ proj_roots = _project_roots(files)
682
+ product = _product_prefix(files)
683
+
521
684
  for f in files:
522
685
  parts = f.split("/")
523
- for i in range(1, len(parts) - 1): # a layer folder needs a parent + a child
524
- if LAYER_DIR_RE.match(parts[i]):
525
- module_dir = "/".join(parts[:i])
526
- kind = LAYER_KIND.get(parts[i].lower(), "other")
527
- module_files.setdefault(module_dir, []).append((f, kind))
686
+ if len(parts) < 2:
687
+ continue
688
+ dirs = parts[:-1]
689
+ hit = None
690
+
691
+ # slice-major — a slice container names the module in its CHILD.
692
+ for i, seg in enumerate(dirs):
693
+ if not SLICE_CONTAINER_RE.match(seg):
694
+ continue
695
+ nxt = dirs[i + 1] if i + 1 < len(dirs) else ""
696
+ if nxt and not _layer_of(nxt) and not SLICE_CONTAINER_RE.match(nxt):
697
+ kind = next((_layer_of(p, soft=True) for p in dirs[i + 2:]
698
+ if _layer_of(p, soft=True)), "feature")
699
+ name = singularize(nxt) or nxt
700
+ hit = ("/".join(dirs[:i + 2]), name, kind, "slice-major")
701
+ break
702
+
703
+ # project-major — a dotted segment whose last dot-part is the layer.
704
+ if not hit:
705
+ for i, seg in enumerate(dirs):
706
+ mod, kind = _dotted_module(seg)
707
+ if mod:
708
+ hit = ("/".join(dirs[:i] + [mod]), mod, kind, "project-major")
709
+ break
710
+
711
+ # module-major / layer-major — a layer FOLDER, read through its parent.
712
+ if not hit:
713
+ for i, seg in enumerate(dirs):
714
+ kind = _layer_of(seg)
715
+ if not kind:
716
+ continue
717
+ parent = dirs[i - 1] if i else ""
718
+ if i and not CONTAINER_DIR_RE.match(parent):
719
+ name = (_clean_project_name(parent, product)
720
+ if DOTTED_SEG_RE.match(parent) else singularize(parent) or parent)
721
+ hit = ("/".join(dirs[:i]), name, kind, "module-major")
722
+ else:
723
+ noun = _entity_noun(f)
724
+ if noun:
725
+ hit = ("/".join(dirs[:i] + [noun]), noun, kind, "layer-major")
528
726
  break
529
727
 
530
- for module_dir, entries in module_files.items():
531
- name = singularize(os.path.basename(module_dir)) or os.path.basename(module_dir)
532
- c = module_candidate(module_dir, name)
533
- for f, kind in entries:
534
- c[kind + "_files"].append(f)
535
- c["churn"] += churn_map.get(f, 0)
536
- if kind == "domain":
537
- n = _entity_noun(f)
538
- if n:
539
- c["entities"].add(n)
728
+ # A dotted project segment bounds a module even with no layer anywhere in
729
+ # it and no manifest tracked beside it — which is how a test project that
730
+ # mirrors its subject (Acme.Billing.Tests) reaches the same candidate.
731
+ if not hit:
732
+ for i, seg in enumerate(dirs):
733
+ if DOTTED_SEG_RE.match(seg):
734
+ name = _clean_project_name(seg, product)
735
+ if name:
736
+ kind = next((_layer_of(p, soft=True) for p in dirs[i + 1:]
737
+ if _layer_of(p, soft=True)), "other")
738
+ hit = ("/".join(dirs[:i] + [name]), name, kind, "project-major")
739
+ break
740
+
741
+ # project-root — the build manifest is the boundary of last resort.
742
+ if not hit:
743
+ for i in range(len(dirs), 0, -1):
744
+ d = "/".join(dirs[:i])
745
+ if d in proj_roots:
746
+ kind = next((_layer_of(p, soft=True) for p in dirs[i:]
747
+ if _layer_of(p, soft=True)), "other")
748
+ hit = (d, _clean_project_name(dirs[i - 1], product), kind, "project-root")
749
+ break
750
+
751
+ if hit and hit[1]:
752
+ yield hit[0], hit[1], hit[2], f, hit[3]
753
+
754
+
755
+ def _blank_candidate(group_key, name):
756
+ return {"name": name, "module_dir": group_key,
757
+ "domain_files": [], "feature_files": [], "infra_files": [],
758
+ "api_files": [], "other_files": [], "entities": set(), "churn": 0}
759
+
540
760
 
541
- sig = []
542
- for c in modules.values():
543
- layers = ("domain_files", "feature_files", "infra_files", "api_files", "other_files")
544
- # Drop pure-doc/config false positives (e.g. docs/domain/): a real
545
- # aggregate module owns at least one source file.
761
+ LAYER_KEYS = ("domain_files", "feature_files", "infra_files", "api_files", "other_files")
762
+ # Suffixes that make a project a VARIANT of a concern rather than a concern of
763
+ # its own — the EF adapter, the abstractions package, the in-memory double. They
764
+ # share the concern's lifecycle, so they belong to its aggregate.
765
+ VARIANT_SUFFIX_RE = re.compile(
766
+ r"(EfCore|EntityFramework|Ef|Dapper|Sql|SqlServer|Postgres|Mongo|Redis|"
767
+ r"InMemory|Abstractions?|Contracts?|Core|Shared|Common|Adapter|Provider|"
768
+ r"Extensions?|Integration|Http|Grpc|Rest)$")
769
+
770
+
771
+ def _consolidate(cands):
772
+ """Fold candidates that name the same concern into one.
773
+
774
+ A module, its mirror test project and its adapter variants are one unit that
775
+ must stay consistent together — AppWeaver.Auditing, AppWeaver.Auditing.Tests
776
+ and AppWeaver.Auditing.EfCore are the Auditing aggregate, not three. Without
777
+ this a project-per-layer repo reports one candidate per assembly and the map
778
+ becomes a build listing."""
779
+ by_name = {}
780
+ for c in cands.values():
781
+ by_name.setdefault(c["name"].lower(), []).append(c)
782
+
783
+ # A variant folds into its base only when that base is a candidate already,
784
+ # so a repo whose only Auditing project IS AppWeaver.Auditing.EfCore keeps it.
785
+ canonical = {k: v[0]["name"] for k, v in by_name.items()}
786
+ for key in sorted(by_name, key=len, reverse=True):
787
+ if key not in by_name:
788
+ continue
789
+ stem = VARIANT_SUFFIX_RE.sub("", by_name[key][0]["name"])
790
+ if stem and stem.lower() != key and stem.lower() in by_name:
791
+ by_name[stem.lower()].extend(by_name.pop(key))
792
+
793
+ merged = {}
794
+ for key, group in by_name.items():
795
+ # The concern names the merged unit, never the largest variant of it:
796
+ # Auditing absorbing Auditing.EfCore is still Auditing.
797
+ lead = max(group, key=lambda c: sum(len(c[k]) for k in LAYER_KEYS))
798
+ lead["name"] = canonical.get(key, lead["name"])
799
+ for c in group:
800
+ if c is lead:
801
+ continue
802
+ for k in LAYER_KEYS:
803
+ lead[k].extend(c[k])
804
+ lead["churn"] += c["churn"]
805
+ lead["entities"] = set(lead["entities"]) | set(c["entities"])
806
+ lead.setdefault("merged_dirs", []).append(c["module_dir"])
807
+ # Keyed by the concern, not by module_dir: two clusters can legitimately
808
+ # share a directory prefix, and keying on the path silently drops one.
809
+ merged[key] = lead
810
+ return merged
811
+
812
+
813
+ def _finish_candidates(cands, churn_map, strategy, min_files=1):
814
+ """Apply the shared candidate contract: drop doc/config-only groups, cap the
815
+ file lists, and rank by evidence then churn — identical for every strategy
816
+ so the probe compares like with like."""
817
+ layers = LAYER_KEYS
818
+ out = []
819
+ for c in _consolidate(cands).values():
546
820
  if not any(CODE_EXT_RE.search(f) for k in layers for f in c[k]):
547
821
  continue
822
+ # Counted before the payload cap: CAP bounds what the substrate CARRIES,
823
+ # and reporting coverage from the capped lists would understate a
824
+ # strategy on a large repo purely because its clusters are big.
825
+ c["raw_file_count"] = len({f for k in layers for f in c[k]})
826
+ c["covered_code"] = sorted({f for k in layers for f in c[k]
827
+ if CODE_EXT_RE.search(f)})
548
828
  for k in layers:
549
829
  c[k] = sorted(set(c[k]))[:CAP]
550
- c["entities"] = sorted(c["entities"])[:CAP]
830
+ c["entities"] = sorted(c["entities"])[:CAP] if isinstance(c["entities"], set) else c["entities"]
551
831
  c["file_count"] = sum(len(c[k]) for k in layers)
832
+ if c["file_count"] < min_files:
833
+ continue
552
834
  c["evidence_count"] = c["file_count"]
553
- sig.append(c)
835
+ c["strategy"] = strategy
836
+ c.setdefault("confidence", "medium")
837
+ out.append(c)
838
+ out.sort(key=lambda c: (c["evidence_count"], c["churn"]), reverse=True)
839
+ return out
554
840
 
555
- # FALLBACK: only if the module walk found nothing (flat repo, no layer dirs).
556
- if not sig:
557
- domain = matching(files, DB_FILES_RE) + [f for f in files if DOMAIN_DIR_RE.search(f)]
558
- flat = {}
559
- for f in domain + matching(files, ROUTES_RE) + matching(files, API_DEFS_RE):
841
+
842
+ def strategy_structure(root, files, churn_map, graph=None):
843
+ """S1 — cluster on the repo's own organising axis.
844
+
845
+ Always available: every repo has a layout. Where no layer vocabulary appears
846
+ at all, the noun fallback still clusters the domain/route/api files on their
847
+ basename, so a flat repo is not a zero-candidate repo."""
848
+ cands, axes, naming = {}, {}, {}
849
+ for key, name, kind, f, axis in _structure_rows(files):
850
+ axes[axis] = axes.get(axis, 0) + 1
851
+ c = cands.setdefault(key, _blank_candidate(key, name))
852
+ # Two rules can feed one group — a project-root file and a module-major
853
+ # file under the same project. The more specific rule names it.
854
+ rank = AXIS_RANK.get(axis, 9)
855
+ if rank < naming.get(key, (9, "", ""))[0]:
856
+ naming[key] = (rank, name, axis)
857
+ c["name"], c["axis"] = name, axis
858
+ c[(kind or "other") + "_files"].append(f)
859
+ c["churn"] += churn_map.get(f, 0)
860
+ if kind == "domain":
861
+ n = _entity_noun(f)
862
+ if n:
863
+ c["entities"].add(n)
864
+
865
+ if not cands:
866
+ for f in (matching(files, DB_FILES_RE) + [x for x in files if DOMAIN_DIR_RE.search(x)]
867
+ + matching(files, ROUTES_RE) + matching(files, API_DEFS_RE)):
560
868
  noun = _entity_noun(f)
561
869
  if not noun:
562
870
  continue
563
- c = flat.setdefault(noun.lower(), {
564
- "name": noun, "module_dir": "", "domain_files": [], "feature_files": [],
565
- "infra_files": [], "api_files": [], "other_files": [], "entities": [], "churn": 0})
871
+ c = cands.setdefault(noun.lower(), _blank_candidate("", noun))
566
872
  c["other_files"].append(f)
567
873
  c["churn"] += churn_map.get(f, 0)
568
- for c in flat.values():
569
- c["other_files"] = sorted(set(c["other_files"]))[:CAP]
570
- c["file_count"] = len(c["other_files"])
571
- c["evidence_count"] = c["file_count"]
572
- if c["file_count"] >= 2:
573
- sig.append(c)
874
+ axes["flat"] = len(cands)
875
+ return _finish_candidates(cands, churn_map, "structure", min_files=2)
876
+
877
+ out = _finish_candidates(cands, churn_map, "structure")
878
+ primary = max(axes, key=axes.get) if axes else "flat"
879
+ for c in out:
880
+ c.setdefault("axis", primary)
881
+ c["repo_axis"] = primary
882
+ return out
883
+
884
+
885
+ # ── The reference graph ────────────────────────────────────────────────────
886
+ # Built for EVERY run, not only when S2 is the chosen strategy: the same edges
887
+ # that cluster files are what order the proposal by dependency, and an S1 or S3
888
+ # run needs that order just as much. Only the CLUSTERING is single-strategy.
889
+
890
+ READ_CAP_BYTES = 400_000
891
+ READ_CAP_FILES = 20_000
892
+ # What counts as shared kernel in the import graph. A file this many others
893
+ # import is infrastructure by usage, whatever its folder says.
894
+ HUB_MIN_INDEGREE = 8
895
+ HUB_PERCENTILE = 0.05
896
+ NS_DECL_RE = {
897
+ "cs": re.compile(r"^\s*namespace\s+([A-Za-z_][\w.]*)", re.M),
898
+ "java": re.compile(r"^\s*package\s+([A-Za-z_][\w.]*)\s*;", re.M),
899
+ "kt": re.compile(r"^\s*package\s+([A-Za-z_][\w.]*)", re.M),
900
+ "scala": re.compile(r"^\s*package\s+([A-Za-z_][\w.]*)", re.M),
901
+ }
902
+ IMPORT_RE = {
903
+ "cs": re.compile(r"^\s*(?:global\s+)?using\s+(?:static\s+)?(?:\w+\s*=\s*)?([A-Za-z_][\w.]*)\s*;", re.M),
904
+ "java": re.compile(r"^\s*import\s+(?:static\s+)?([A-Za-z_][\w.]*)", re.M),
905
+ "kt": re.compile(r"^\s*import\s+([A-Za-z_][\w.]*)", re.M),
906
+ "scala": re.compile(r"^\s*import\s+([A-Za-z_][\w.]*)", re.M),
907
+ "py": re.compile(r"^\s*(?:from\s+([.\w]+)\s+import|import\s+([\w.]+))", re.M),
908
+ "js": re.compile(r"""(?:from\s*|require\(\s*|import\(\s*)['"]([^'"\n]+)['"]""", re.M),
909
+ "go": re.compile(r"""^\s*(?:[\w.]+\s+)?"([^"\n]+)"\s*$""", re.M),
910
+ "rb": re.compile(r"""^\s*require(?:_relative)?\s+['"]([^'"\n]+)['"]""", re.M),
911
+ "php": re.compile(r"^\s*use\s+([A-Za-z_\\][\w\\]*)", re.M),
912
+ "rs": re.compile(r"^\s*use\s+(?:crate::)?([\w:]+)", re.M),
913
+ }
914
+ _EXT_LANG = {"cs": "cs", "java": "java", "kt": "kt", "kts": "kt", "scala": "scala",
915
+ "py": "py", "go": "go", "rb": "rb", "php": "php", "rs": "rs"}
916
+ for _e in ("ts", "tsx", "js", "jsx", "mjs", "cjs", "vue", "svelte"):
917
+ _EXT_LANG[_e] = "js"
918
+ _JS_TRY = ("", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte",
919
+ "/index.ts", "/index.tsx", "/index.js", "/index.jsx")
920
+
921
+
922
+ def _lang_of(path):
923
+ return _EXT_LANG.get(os.path.splitext(path)[1].lstrip(".").lower())
924
+
925
+
926
+ def _read_source(root, rel):
927
+ try:
928
+ p = os.path.join(root, rel)
929
+ if os.path.getsize(p) > READ_CAP_BYTES:
930
+ return ""
931
+ with open(p, "r", encoding="utf-8", errors="replace") as fh:
932
+ return fh.read()
933
+ except Exception:
934
+ return ""
935
+
936
+
937
+ def build_reference_graph(root, files):
938
+ """file -> set(file), from each source file's own import statements.
939
+
940
+ Two resolution currencies, because the language families name a dependency
941
+ differently. Namespace languages (C#, Java, Kotlin, Scala) name a NAMESPACE,
942
+ resolved through an index of which files declare it; path languages (TS/JS,
943
+ Python, Go, Ruby) name a PATH, resolved against the importing file and then
944
+ against the file list. Anything that resolves outside the repo is a
945
+ third-party package and is dropped — an edge only means something here when
946
+ both ends are files this scan can see."""
947
+ srcs = [f for f in files if _lang_of(f)][:READ_CAP_FILES]
948
+ text = {f: _read_source(root, f) for f in srcs}
949
+
950
+ ns_index, by_noext, by_suffix = {}, {}, {}
951
+ for f in srcs:
952
+ lang = _lang_of(f)
953
+ if lang in NS_DECL_RE:
954
+ m = NS_DECL_RE[lang].search(text[f])
955
+ if m:
956
+ ns_index.setdefault(m.group(1), set()).add(f)
957
+ noext = os.path.splitext(f)[0]
958
+ by_noext.setdefault(noext, set()).add(f)
959
+ by_suffix.setdefault(noext.rsplit("/", 1)[-1], set()).add(f)
960
+ if "/" in noext:
961
+ by_suffix.setdefault("/".join(noext.rsplit("/", 2)[-2:]), set()).add(f)
962
+
963
+ edges, resolved, seen = {}, 0, 0
964
+ for f in srcs:
965
+ lang, body = _lang_of(f), text[f]
966
+ if not body:
967
+ continue
968
+ here = os.path.dirname(f)
969
+ targets = set()
970
+ for m in IMPORT_RE[lang].finditer(body):
971
+ raw = next((g for g in m.groups() if g), "")
972
+ if not raw:
973
+ continue
974
+ seen += 1
975
+ hits = set()
976
+ if lang in NS_DECL_RE:
977
+ hits = ns_index.get(raw.replace("\\", "."), set())
978
+ elif lang == "js":
979
+ if raw.startswith("."):
980
+ base = os.path.normpath(os.path.join(here, raw)).replace(os.sep, "/")
981
+ for suf in _JS_TRY:
982
+ cand = (base + suf) if suf.startswith("/") or not suf else base + suf
983
+ hits = by_noext.get(os.path.splitext(cand)[0], set())
984
+ if hits:
985
+ break
986
+ elif lang == "py":
987
+ dots = len(raw) - len(raw.lstrip("."))
988
+ rel = raw.lstrip(".").replace(".", "/")
989
+ base = here
990
+ for _ in range(max(0, dots - 1)):
991
+ base = os.path.dirname(base)
992
+ cand = (base + "/" + rel).lstrip("/") if dots else rel
993
+ hits = by_noext.get(cand, set()) or by_noext.get(cand + "/__init__", set())
994
+ else:
995
+ key = raw.strip("/").replace("\\", "/")
996
+ hits = by_suffix.get(key.rsplit("/", 1)[-1], set()) if "/" not in key else \
997
+ by_suffix.get("/".join(key.rsplit("/", 2)[-2:]), set())
998
+ hits = {h for h in hits if h != f}
999
+ if hits:
1000
+ resolved += 1
1001
+ targets |= hits
1002
+ if targets:
1003
+ edges[f] = targets
1004
+
1005
+ return edges, {"parseable_files": len(srcs), "imports_seen": seen,
1006
+ "imports_resolved": resolved,
1007
+ "truncated": len([f for f in files if _lang_of(f)]) > READ_CAP_FILES}
1008
+
1009
+
1010
+ # Keys a candidate carries for the passes' own use and must NOT carry into the
1011
+ # substrate file. `covered_code` is the uncapped membership coverage and edge
1012
+ # ownership are measured from; serialized, it would duplicate the layer lists CAP
1013
+ # exists to bound, and this substrate is inlined verbatim into every agent
1014
+ # request a run makes.
1015
+ INTERNAL_CANDIDATE_KEYS = ("covered_code",)
1016
+
1017
+
1018
+ def strip_internal(cands):
1019
+ """The candidate list as the substrate should carry it."""
1020
+ return [{k: v for k, v in c.items() if k not in INTERNAL_CANDIDATE_KEYS}
1021
+ for c in cands]
1022
+
1023
+
1024
+ def _candidate_of_file(cands):
1025
+ """file -> candidate name, the lookup that turns file edges into module edges.
1026
+
1027
+ Reads the UNCAPPED membership where it exists: CAP bounds what the substrate
1028
+ carries, and resolving ownership from the capped lists silently drops every
1029
+ edge whose endpoint sits past the cap."""
1030
+ owner = {}
1031
+ for c in cands:
1032
+ for f in c.get("covered_code") or []:
1033
+ owner[f] = c["name"]
1034
+ for k in LAYER_KEYS:
1035
+ for f in c[k]:
1036
+ owner.setdefault(f, c["name"])
1037
+ return owner
1038
+
1039
+
1040
+ def candidate_edges(cands, graph):
1041
+ """Module-level dependency edges: {name: {name, …}}, self-edges dropped.
1042
+
1043
+ This is the graph the proposal is ordered by, and the reason the reference
1044
+ parse runs regardless of which strategy drew the boundaries."""
1045
+ owner = _candidate_of_file(cands)
1046
+ out = {c["name"]: set() for c in cands}
1047
+ for src, dsts in graph.items():
1048
+ a = owner.get(src)
1049
+ if not a:
1050
+ continue
1051
+ for d in dsts:
1052
+ b = owner.get(d)
1053
+ if b and b != a:
1054
+ out[a].add(b)
1055
+ return out
1056
+
1057
+
1058
+ def strategy_reference(root, files, churn_map, graph=None):
1059
+ """S2 — cluster on the import graph by label propagation.
1060
+
1061
+ Language-agnostic and close to complete coverage where the languages parse,
1062
+ and it separates a shared kernel from a feature by shape rather than by name:
1063
+ a node everything imports gathers no label of its own and falls out as the
1064
+ high-fan-in cluster it is. Clusters arrive UNNAMED — S1 names them, which is
1065
+ why it is the layer underneath rather than a competing option."""
1066
+ graph = graph if graph is not None else build_reference_graph(root, files)[0]
1067
+ named = strategy_structure(root, files, churn_map)
1068
+ owner = _candidate_of_file(named)
1069
+
1070
+ # In-degree first: the files everything imports are the shared kernel, and
1071
+ # they have to come OUT of the graph before it is cut. Left in, they are the
1072
+ # bridge that joins every slice to every other, and any clustering run over
1073
+ # them returns one component containing the whole repository.
1074
+ indeg = {}
1075
+ for bs in graph.values():
1076
+ for b in bs:
1077
+ indeg[b] = indeg.get(b, 0) + 1
1078
+ ranked = sorted(indeg.values(), reverse=True)
1079
+ cut = max(HUB_MIN_INDEGREE,
1080
+ ranked[max(0, int(len(ranked) * HUB_PERCENTILE) - 1)] if ranked else 0)
1081
+ hubs = {f for f, d in indeg.items() if d >= cut}
1082
+
1083
+ undirected = {}
1084
+ for a, bs in graph.items():
1085
+ if a in hubs:
1086
+ continue
1087
+ for b in bs:
1088
+ if b in hubs:
1089
+ continue
1090
+ undirected.setdefault(a, set()).add(b)
1091
+ undirected.setdefault(b, set()).add(a)
1092
+
1093
+ # What is left separates into genuine components — the slices that do not
1094
+ # reach each other except through the kernel just removed.
1095
+ clusters, seen_nodes = {}, set()
1096
+ for start in sorted(undirected):
1097
+ if start in seen_nodes:
1098
+ continue
1099
+ stack, comp = [start], []
1100
+ seen_nodes.add(start)
1101
+ while stack:
1102
+ n = stack.pop()
1103
+ comp.append(n)
1104
+ for m in undirected[n]:
1105
+ if m not in seen_nodes:
1106
+ seen_nodes.add(m)
1107
+ stack.append(m)
1108
+ clusters[start] = comp
1109
+ if hubs:
1110
+ clusters["\x00shared-kernel"] = sorted(hubs)
1111
+
1112
+ cands = {}
1113
+ for lab, group in clusters.items():
1114
+ # The cluster's name is the S1 module most of its files already belong to.
1115
+ votes = {}
1116
+ for f in group:
1117
+ n = owner.get(f)
1118
+ if n:
1119
+ votes[n] = votes.get(n, 0) + 1
1120
+ if lab == "\x00shared-kernel":
1121
+ name = "SharedKernel"
1122
+ else:
1123
+ name = max(sorted(votes), key=votes.get) if votes else _entity_noun(lab)
1124
+ if not name:
1125
+ continue
1126
+ key = os.path.commonprefix([os.path.dirname(f) + "/" for f in group]).rstrip("/")
1127
+ c = cands.setdefault(name.lower(), _blank_candidate(key, name))
1128
+ for f in group:
1129
+ kind = next((_layer_of(p, soft=True) for p in f.split("/")[:-1]
1130
+ if _layer_of(p, soft=True)), "other")
1131
+ c[kind + "_files"].append(f)
1132
+ c["churn"] += churn_map.get(f, 0)
1133
+ if kind == "domain":
1134
+ n = _entity_noun(f)
1135
+ if n:
1136
+ c["entities"].add(n)
574
1137
 
575
- sig.sort(key=lambda c: (c["evidence_count"], c["churn"]), reverse=True)
576
- return sig
1138
+ # Files no import touches are still part of the repo; S1 places them so the
1139
+ # strategy's coverage is comparable with the others'.
1140
+ placed = {f for grp in clusters.values() for f in grp}
1141
+ for c in named:
1142
+ for k in LAYER_KEYS:
1143
+ for f in c[k]:
1144
+ if f not in placed:
1145
+ tgt = cands.setdefault(c["name"].lower(), _blank_candidate(c["module_dir"], c["name"]))
1146
+ tgt[k].append(f)
1147
+ return _finish_candidates(cands, churn_map, "reference")
1148
+
1149
+
1150
+ # ── S3: persistence ────────────────────────────────────────────────────────
1151
+ # The only strategy whose output IS a domain model. Roots are derived by
1152
+ # OWNERSHIP rather than by folder: an entity that owns other entities' lifecycles
1153
+ # and is referred to from outside by id is an aggregate root, which is the
1154
+ # definition the model document needs anyway.
1155
+
1156
+ EF_DBSET_RE = re.compile(r"DbSet\s*<\s*([A-Za-z_]\w*)\s*>")
1157
+ EF_TABLE_RE = re.compile(r"\[Table\s*\(\s*\"([^\"]+)\"")
1158
+ EF_COLLECTION_RE = re.compile(
1159
+ r"(?:ICollection|IList|List|IEnumerable|HashSet|ISet)\s*<\s*([A-Za-z_]\w*)\s*>")
1160
+ EF_FK_RE = re.compile(r"^\s*public\s+[\w?<>]+\s+([A-Za-z_]\w*)Id\s*\{", re.M)
1161
+ CSHARP_CLASS_RE = re.compile(r"^\s*(?:public|internal)\s+(?:abstract\s+|sealed\s+|partial\s+)*class\s+([A-Za-z_]\w*)", re.M)
1162
+ JPA_ENTITY_RE = re.compile(r"@Entity\b")
1163
+ JPA_ONE_MANY_RE = re.compile(r"@OneToMany[\s\S]{0,200}?(?:ICollection|List|Set)\s*<\s*([A-Za-z_]\w*)\s*>")
1164
+ JPA_MANY_ONE_RE = re.compile(r"@ManyToOne[\s\S]{0,200}?(?:private|public|protected)\s+([A-Za-z_]\w*)\s+")
1165
+ JAVA_CLASS_RE = re.compile(r"^\s*(?:public\s+)?class\s+([A-Za-z_]\w*)", re.M)
1166
+ PRISMA_MODEL_RE = re.compile(r"^\s*model\s+([A-Za-z_]\w*)\s*\{([^}]*)\}", re.M)
1167
+ PRISMA_FIELD_RE = re.compile(r"^\s*(\w+)\s+([A-Za-z_]\w*)(\[\])?", re.M)
1168
+ SQL_TABLE_RE = re.compile(r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"\[]?(\w+)", re.I)
1169
+ SQL_FK_RE = re.compile(r"REFERENCES\s+[`\"\[]?(\w+)", re.I)
1170
+ ORM_EVIDENCE_RE = re.compile(
1171
+ r"(DbSet\s*<|@Entity\b|schema\.prisma$|\.prisma$|CREATE\s+TABLE|"
1172
+ r"DbContext|JpaRepository|Migrations?/)", re.I)
1173
+
1174
+
1175
+ def _pascal_name(n):
1176
+ return (n or "")[:1].upper() + (n or "")[1:]
1177
+
1178
+
1179
+ def persistence_entities(root, files):
1180
+ """Parse the schema into {entity: {"owns": set, "refs": set, "files": set}}.
1181
+
1182
+ Four dialects, one shape. `owns` is a lifecycle edge (a collection navigation,
1183
+ a one-to-many, a list relation, a foreign key pointing IN); `refs` is a
1184
+ by-id reference that crosses an aggregate boundary without owning anything."""
1185
+ ents = {}
1186
+
1187
+ def ent(name):
1188
+ return ents.setdefault(_pascal_name(name),
1189
+ {"owns": set(), "refs": set(), "files": set()})
1190
+
1191
+ for f in files:
1192
+ low = f.lower()
1193
+ lang = _lang_of(f)
1194
+ if lang not in ("cs", "java", "kt") and not low.endswith((".prisma", ".sql")):
1195
+ continue
1196
+ body = _read_source(root, f)
1197
+ if not body:
1198
+ continue
1199
+
1200
+ if low.endswith(".prisma"):
1201
+ for m in PRISMA_MODEL_RE.finditer(body):
1202
+ name, block = m.group(1), m.group(2)
1203
+ e = ent(name)
1204
+ e["files"].add(f)
1205
+ for fm in PRISMA_FIELD_RE.finditer(block):
1206
+ typ, arr = fm.group(2), fm.group(3)
1207
+ if typ and typ[:1].isupper() and typ != name:
1208
+ (e["owns"] if arr else e["refs"]).add(_pascal_name(typ))
1209
+ elif low.endswith(".sql"):
1210
+ for m in SQL_TABLE_RE.finditer(body):
1211
+ e = ent(singularize(m.group(1)))
1212
+ e["files"].add(f)
1213
+ tables = [singularize(m.group(1)) for m in SQL_TABLE_RE.finditer(body)]
1214
+ if tables:
1215
+ owner = ent(tables[0])
1216
+ for m in SQL_FK_RE.finditer(body):
1217
+ tgt = _pascal_name(singularize(m.group(1)))
1218
+ if tgt != _pascal_name(tables[0]):
1219
+ ents[_pascal_name(tgt)] = ents.get(_pascal_name(tgt), {"owns": set(), "refs": set(), "files": set()})
1220
+ ents[_pascal_name(tgt)]["owns"].add(_pascal_name(tables[0]))
1221
+ owner["refs"].add(tgt)
1222
+ elif lang == "cs":
1223
+ for m in EF_DBSET_RE.finditer(body):
1224
+ ent(m.group(1))["files"].add(f)
1225
+ cls = CSHARP_CLASS_RE.search(body)
1226
+ if cls and (EF_TABLE_RE.search(body) or EF_COLLECTION_RE.search(body)
1227
+ or EF_FK_RE.search(body) or DOMAIN_DIR_RE.search(f)):
1228
+ e = ent(cls.group(1))
1229
+ e["files"].add(f)
1230
+ for m in EF_COLLECTION_RE.finditer(body):
1231
+ if m.group(1) != cls.group(1):
1232
+ e["owns"].add(_pascal_name(m.group(1)))
1233
+ for m in EF_FK_RE.finditer(body):
1234
+ if m.group(1) != cls.group(1):
1235
+ e["refs"].add(_pascal_name(m.group(1)))
1236
+ elif lang in ("java", "kt") and JPA_ENTITY_RE.search(body):
1237
+ cls = JAVA_CLASS_RE.search(body)
1238
+ if cls:
1239
+ e = ent(cls.group(1))
1240
+ e["files"].add(f)
1241
+ for m in JPA_ONE_MANY_RE.finditer(body):
1242
+ e["owns"].add(_pascal_name(m.group(1)))
1243
+ for m in JPA_MANY_ONE_RE.finditer(body):
1244
+ e["refs"].add(_pascal_name(m.group(1)))
1245
+ return ents
1246
+
1247
+
1248
+ def strategy_persistence(root, files, churn_map, graph=None):
1249
+ """S3 — derive roots from what the schema says owns what.
1250
+
1251
+ An entity owned by another entity is never a root; what is left owns at least
1252
+ one lifecycle, or stands alone as its own. Each root's aggregate is its
1253
+ transitive ownership closure, and the files of every entity in that closure."""
1254
+ ents = persistence_entities(root, files)
1255
+ if not ents:
1256
+ return []
1257
+
1258
+ owned = {t for e in ents.values() for t in e["owns"] if t in ents}
1259
+ roots = [n for n in sorted(ents) if n not in owned]
1260
+
1261
+ named = strategy_structure(root, files, churn_map)
1262
+ owner = _candidate_of_file(named)
1263
+ cands = {}
1264
+ for r in roots:
1265
+ closure, stack = set(), [r]
1266
+ while stack:
1267
+ n = stack.pop()
1268
+ if n in closure or n not in ents:
1269
+ continue
1270
+ closure.add(n)
1271
+ stack.extend(ents[n]["owns"])
1272
+ member_files = {f for n in closure for f in ents[n]["files"]}
1273
+ if not member_files:
1274
+ continue
1275
+ c = cands.setdefault(r.lower(), _blank_candidate(
1276
+ os.path.commonprefix([os.path.dirname(f) + "/" for f in member_files]).rstrip("/"), r))
1277
+ c["entities"] = set(closure)
1278
+ c["owns"] = sorted(closure - {r})
1279
+ c["references_by_id"] = sorted({t for n in closure for t in ents[n]["refs"]
1280
+ if t in ents and t not in closure})
1281
+ c["confidence"] = "high"
1282
+ for f in sorted(member_files):
1283
+ kind = next((_layer_of(p, soft=True) for p in f.split("/")[:-1]
1284
+ if _layer_of(p, soft=True)), "domain")
1285
+ c[kind + "_files"].append(f)
1286
+ c["churn"] += churn_map.get(f, 0)
1287
+
1288
+ # Application and API files that belong to a root's module still belong to
1289
+ # its aggregate — the schema names the root, the structure says what serves it.
1290
+ for c in named:
1291
+ if c["name"].lower() in cands:
1292
+ tgt = cands[c["name"].lower()]
1293
+ for k in LAYER_KEYS:
1294
+ tgt[k].extend(c[k])
1295
+ return _finish_candidates(cands, churn_map, "persistence")
1296
+
1297
+
1298
+ # ── Availability + the probe ───────────────────────────────────────────────
1299
+ # A strategy that would return nothing must never be offered: an empty pick is
1300
+ # the zero-candidate hard stop at require_aggregates, arrived at by choice.
1301
+
1302
+ STRATEGIES = {
1303
+ "structure": strategy_structure,
1304
+ "reference": strategy_reference,
1305
+ "persistence": strategy_persistence,
1306
+ }
1307
+
1308
+
1309
+ def strategy_availability(root, files, graph_stats, ents_found):
1310
+ """(name -> {available, reason}) — measured, never assumed."""
1311
+ parseable = graph_stats.get("parseable_files", 0)
1312
+ return {
1313
+ "structure": {"available": True,
1314
+ "reason": "every repository has a layout"},
1315
+ "reference": {"available": parseable >= 10,
1316
+ "reason": f"{parseable} import-parseable source file(s)"},
1317
+ "persistence": {"available": ents_found > 0,
1318
+ "reason": f"{ents_found} ORM/schema entit(y/ies) found"},
1319
+ }
1320
+
1321
+
1322
+ def run_probe(root, files, churn_map):
1323
+ """Run every AVAILABLE strategy's clustering and report each one's own
1324
+ numbers, so the pick is made against evidence rather than against a name.
1325
+
1326
+ Clustering is cheap; the expensive half is authoring, which still runs once
1327
+ on the chosen result."""
1328
+ graph, gstats = build_reference_graph(root, files)
1329
+ ents = persistence_entities(root, files)
1330
+ avail = strategy_availability(root, files, gstats, len(ents))
1331
+ code_total = len([f for f in files if CODE_EXT_RE.search(f)])
1332
+
1333
+ results = {}
1334
+ for name, fn in STRATEGIES.items():
1335
+ if not avail[name]["available"]:
1336
+ continue
1337
+ cands = fn(root, files, churn_map, graph)
1338
+ covered = {f for c in cands for f in c.get("covered_code", [])}
1339
+ results[name] = {
1340
+ "candidates": cands,
1341
+ "count": len(cands),
1342
+ "coverage_pct": round(100.0 * len(covered) / max(1, code_total), 1),
1343
+ "singletons": sum(1 for c in cands if c.get("raw_file_count", 0) <= 1),
1344
+ "available": True,
1345
+ "reason": avail[name]["reason"],
1346
+ }
1347
+ return results, avail, graph, gstats, ents
1348
+
1349
+
1350
+ # Preference when the measurements tie. Structure leads because it is the layer
1351
+ # the other two are named by, so a tied structure result is the one an operator
1352
+ # can predict and re-derive by eye from the folder tree.
1353
+ STRATEGY_PREFERENCE = ("structure", "persistence", "reference")
1354
+
1355
+
1356
+ def choose_strategy(results):
1357
+ """The non-interactive default: highest coverage, ties broken by fewest
1358
+ singletons, then by the standing preference so a re-run of the same tree
1359
+ picks the same strategy."""
1360
+ if not results:
1361
+ return ""
1362
+ return sorted(results, key=lambda n: (-results[n]["coverage_pct"],
1363
+ results[n]["singletons"],
1364
+ STRATEGY_PREFERENCE.index(n)))[0]
1365
+
1366
+
1367
+ # ═══════════════════════════════════════════════════════════════════════════
1368
+ # PASS B — the seam overlay: what can be adopted, shipped and cut over together.
1369
+ #
1370
+ # Always runs, whichever Pass A strategy was chosen, because it is not a
1371
+ # validator of Pass A — it is the pass that answers the FEATURE question at all.
1372
+ # Reads application / API / UI only, never Domain, and emits signals rather than
1373
+ # decisions.
1374
+ #
1375
+ # Of the five identification tests, only three are computable: one cutover seam,
1376
+ # stable file ownership, one bounded context. "One business need" and
1377
+ # "independently demonstrable" are judgement, and this pass does not pretend to
1378
+ # them.
1379
+ # ═══════════════════════════════════════════════════════════════════════════
1380
+
1381
+ ROUTE_ATTR_RE = re.compile(
1382
+ r"""(?:\[Route\s*\(\s*["']([^"']+)|"""
1383
+ r"""@(?:Request|Get|Post|Put|Delete|Patch)Mapping\s*\(\s*(?:value\s*=\s*)?["']([^"']+)|"""
1384
+ r"""@(?:app|router)\.(?:get|post|put|delete|patch)\s*\(\s*["']([^"']+)|"""
1385
+ r"""(?:app|router)\.(?:get|post|put|delete|patch)\s*\(\s*["']([^"']+)|"""
1386
+ r"""Map(?:Get|Post|Put|Delete|Patch)\s*\(\s*["']([^"']+))""",
1387
+ re.IGNORECASE)
1388
+ HANDLER_NAME_RE = re.compile(r"(Command|Query)Handler$|^Handle")
1389
+
1390
+
1391
+ def _route_family(path_literal):
1392
+ """The first meaningful segment of a route — the family a cutover switches.
1393
+
1394
+ /api/v2/orders/{id}/lines -> orders. Version and api prefixes are carried by
1395
+ every route in the service and separate nothing."""
1396
+ parts = [p for p in re.split(r"[/\\]", (path_literal or "").strip("/"))
1397
+ if p and not p.startswith(("{", ":", "[", "<"))]
1398
+ for p in parts:
1399
+ low = p.lower()
1400
+ if low in ("api", "rest", "graphql", "v1", "v2", "v3", "internal", "public"):
1401
+ continue
1402
+ if re.fullmatch(r"v\d+", low):
1403
+ continue
1404
+ return singularize(p)
1405
+ return ""
1406
+
1407
+
1408
+ def seam_overlay(root, files, candidates, churn_map, ui_attribution=None):
1409
+ """Per-candidate seam signals. Two distinct route families on one aggregate
1410
+ is the split candidate the feature proposal will surface with its evidence."""
1411
+ owner = _candidate_of_file(candidates)
1412
+ by_name = {c["name"]: c for c in candidates}
1413
+ out = {c["name"]: {"route_families": {}, "command_groups": set(),
1414
+ "ui_segments": set(), "churn": 0, "api_files": 0}
1415
+ for c in candidates}
1416
+
1417
+ for f in files:
1418
+ name = owner.get(f)
1419
+ if not name or name not in out:
1420
+ continue
1421
+ # Application / API / UI only. Domain files say what must stay
1422
+ # consistent; they never say where traffic can be cut over.
1423
+ kind = next((_layer_of(p, soft=True) for p in f.split("/")[:-1]
1424
+ if _layer_of(p, soft=True)), "other")
1425
+ if kind == "domain":
1426
+ continue
1427
+ out[name]["churn"] += churn_map.get(f, 0)
1428
+ stem = os.path.splitext(os.path.basename(f))[0]
1429
+ if HANDLER_NAME_RE.search(stem):
1430
+ out[name]["command_groups"].add(_entity_noun(f) or stem)
1431
+ if not (_lang_of(f) or f.lower().endswith((".cshtml", ".razor"))):
1432
+ continue
1433
+ body = _read_source(root, f)
1434
+ if not body:
1435
+ continue
1436
+ for m in ROUTE_ATTR_RE.finditer(body):
1437
+ fam = _route_family(next((g for g in m.groups() if g), ""))
1438
+ if fam:
1439
+ out[name]["route_families"][fam] = out[name]["route_families"].get(fam, 0) + 1
1440
+ out[name]["api_files"] += 1
1441
+
1442
+ for name, ui_files in (ui_attribution or {}).items():
1443
+ if name in out:
1444
+ for f in (ui_files if isinstance(ui_files, list) else []):
1445
+ seg = _ui_stem(f) if callable(globals().get("_ui_stem")) else ""
1446
+ if seg:
1447
+ out[name]["ui_segments"].add(seg)
1448
+
1449
+ for name, sig in out.items():
1450
+ fams = sorted(sig["route_families"], key=sig["route_families"].get, reverse=True)
1451
+ sig["route_families"] = fams[:CAP]
1452
+ sig["command_groups"] = sorted(sig["command_groups"])[:CAP]
1453
+ sig["ui_segments"] = sorted(sig["ui_segments"])[:CAP]
1454
+ # Test 3 — one cutover seam. Two families means two places traffic could
1455
+ # be switched, which is the evidence a split needs; it is not the
1456
+ # decision to split, which stays with the operator.
1457
+ sig["seam_count"] = len(fams)
1458
+ sig["split_candidate"] = len(fams) >= 2
1459
+ sig["seam"] = fams[0] if fams else ""
1460
+ # Test 4 — stable file ownership. A candidate whose files are also
1461
+ # claimed by another candidate cannot own its own cutover.
1462
+ sig["owns_all_files"] = True
1463
+ sig["evidence"] = ("route families: " + ", ".join(fams[:4])) if fams else \
1464
+ ("command groups: " + ", ".join(sig["command_groups"][:4]) if sig["command_groups"]
1465
+ else "no application/API surface found")
1466
+ return out
1467
+
1468
+
1469
+ # ═══════════════════════════════════════════════════════════════════════════
1470
+ # PASS C — the feature proposal, and the bounded contexts above it.
1471
+ # ═══════════════════════════════════════════════════════════════════════════
1472
+
1473
+ def _capability_name(aggregate, seam):
1474
+ """Name a proposed split from the CAPABILITY, not from a second copy of the
1475
+ noun: Patient + the `patient-merge` route family is PatientMerge, never
1476
+ PatientPatient-merge. A seam that already leads with the aggregate's name
1477
+ supplies the whole name on its own."""
1478
+ words = [w for w in re.split(r"[^A-Za-z0-9]+", seam or "") if w]
1479
+ pascal = "".join(_pascal_name(w) for w in words)
1480
+ if not pascal:
1481
+ return _pascal_name(aggregate)
1482
+ if pascal.lower().startswith(aggregate.lower()):
1483
+ return pascal
1484
+ return _pascal_name(aggregate) + pascal
1485
+
1486
+
1487
+ def derive_contexts(candidates, files):
1488
+ """Group candidates into bounded contexts, primed by the highest directory
1489
+ level that actually separates them.
1490
+
1491
+ A context is a grouping of aggregates that share a model. The top-level
1492
+ source directory is the best available prior — in a component monorepo those
1493
+ directories ARE the contexts — and the operator confirms or rewrites it.
1494
+ Where the tree has no such level, the whole repo is one context."""
1495
+ def context_segment(path):
1496
+ """The first path segment that separates one context from another."""
1497
+ for part in (path or "").split("/"):
1498
+ if not part or CONTAINER_DIR_RE.match(part):
1499
+ continue
1500
+ if DOTTED_SEG_RE.match(part):
1501
+ bits = part.split(".")
1502
+ return bits[1] if len(bits) > 2 else bits[0]
1503
+ return part
1504
+ return ""
1505
+
1506
+ groups = {}
1507
+ for c in candidates:
1508
+ # Voted across the candidate's own files rather than read off module_dir:
1509
+ # a cluster drawn from the import graph spans directories, so its common
1510
+ # prefix is often the repo root and would put every such cluster in one
1511
+ # bucket regardless of where its code actually lives.
1512
+ votes = {}
1513
+ for k in LAYER_KEYS:
1514
+ for f in c[k]:
1515
+ seg = context_segment(f)
1516
+ if seg:
1517
+ votes[seg] = votes.get(seg, 0) + 1
1518
+ seg = max(sorted(votes), key=votes.get) if votes else context_segment(c.get("module_dir"))
1519
+ groups.setdefault(_pascal_name(singularize(seg)) or "Core", []).append(c["name"])
1520
+
1521
+ # A context holding one aggregate in a repo that has several is usually the
1522
+ # directory being incidental rather than a real model boundary; fold those
1523
+ # together rather than proposing a context per aggregate.
1524
+ if len(groups) > 1:
1525
+ singles = [k for k, v in groups.items() if len(v) == 1]
1526
+ if len(singles) > 1:
1527
+ merged = []
1528
+ for k in singles:
1529
+ merged.extend(groups.pop(k))
1530
+ groups.setdefault("Core", []).extend(merged)
1531
+ return {k: sorted(v) for k, v in sorted(groups.items())}
1532
+
1533
+
1534
+ def dependency_waves(nodes, edges):
1535
+ """Topological wave order over the module graph, with cycles trapped rather
1536
+ than silently broken.
1537
+
1538
+ Wave 0 depends on nothing and can be onboarded first; each later wave
1539
+ depends only on waves already placed. A cycle cannot be ordered, so it is
1540
+ reported as one wave flagged for a seam decision instead of being cut
1541
+ arbitrarily."""
1542
+ remaining = set(nodes)
1543
+ deps = {n: {d for d in edges.get(n, set()) if d in remaining and d != n}
1544
+ for n in remaining}
1545
+ waves, placed, cycles = [], set(), []
1546
+ while remaining:
1547
+ ready = sorted(n for n in remaining if deps[n] <= placed)
1548
+ if not ready:
1549
+ cycles.append(sorted(remaining))
1550
+ waves.append({"wave": len(waves), "members": sorted(remaining), "cycle": True})
1551
+ break
1552
+ waves.append({"wave": len(waves), "members": ready, "cycle": False})
1553
+ placed |= set(ready)
1554
+ remaining -= set(ready)
1555
+ return waves, cycles
1556
+
1557
+
1558
+ def propose_features(candidates, seams, edges, contexts):
1559
+ """Default 1:1 — one aggregate, one feature, same name.
1560
+
1561
+ A split is PROPOSED where Pass B found two or more distinct seams on one
1562
+ aggregate, carrying the evidence that suggested it, and is never applied:
1563
+ the operator confirms it by editing the proposal. Where a split is taken the
1564
+ feature is named from the capability, not from a second copy of the noun."""
1565
+ ctx_of = {name: ctx for ctx, names in contexts.items() for name in names}
1566
+ out = []
1567
+ for c in candidates:
1568
+ sig = seams.get(c["name"], {})
1569
+ fams = sig.get("route_families", [])
1570
+ feature = {
1571
+ "feature": c["name"],
1572
+ "aggregate": c["name"],
1573
+ "context": ctx_of.get(c["name"], "Core"),
1574
+ "files": c.get("raw_file_count", c.get("file_count", 0)),
1575
+ "churn": c.get("churn", 0),
1576
+ "seam": sig.get("seam", ""),
1577
+ "seam_count": sig.get("seam_count", 0),
1578
+ "evidence": sig.get("evidence", ""),
1579
+ "depends_on": sorted(edges.get(c["name"], set())),
1580
+ "split_proposed": [],
1581
+ }
1582
+ if sig.get("split_candidate"):
1583
+ feature["split_proposed"] = [
1584
+ {"feature": _capability_name(c["name"], f),
1585
+ "seam": f,
1586
+ "why": f"a second route family `{f}` cuts over independently of `{fams[0]}`"}
1587
+ for f in fams[1:4]
1588
+ ]
1589
+ out.append(feature)
1590
+ out.sort(key=lambda x: (x["context"], -x["files"], x["feature"]))
1591
+ return out
1592
+
1593
+
1594
+ def aggregate_signals(files, churn_map):
1595
+ """Derive CANDIDATE aggregate roots — the base unit the inventory and onboard
1596
+ workflows turn into a specs/<Feature>/ slice.
1597
+
1598
+ Delegates to Pass A's structure strategy, which reads the repo's organising
1599
+ axis (module-major, project-major, vertical slice, n-tier, or the build
1600
+ manifest) instead of assuming one. Every caller of this function gets the
1601
+ same candidate shape it always did, with the strategy's extra keys added.
1602
+
1603
+ Signals only — the workflow's agent draws the real boundaries. Empty for a
1604
+ repo with no recognizable structure at all."""
1605
+ return strategy_structure(None, files, churn_map)
577
1606
 
578
1607
 
579
1608
  # A UI file wears its role in its NAME, and the vocabulary is not the backend's:
@@ -866,7 +1895,7 @@ def cmd_scan(args):
866
1895
  "language_histogram": lang_hist,
867
1896
  "top_level_dirs": top_level_dirs,
868
1897
  **{k: v["items"] for k, v in cats.items()},
869
- "aggregate_signals": signals,
1898
+ "aggregate_signals": strip_internal(signals),
870
1899
  "frontend_signals": fsig,
871
1900
  # Which UI files serve which aggregate, and which serve none. The second half
872
1901
  # matters as much as the first: `unattributed` is the shared shell, layout and
@@ -949,6 +1978,165 @@ def cmd_scan(args):
949
1978
  return 0
950
1979
 
951
1980
 
1981
+ # The last-resort proposal document, used only when neither a project override nor
1982
+ # the kit's own template can be read. It is deliberately minimal: the tables ARE the
1983
+ # deliverable, and a scan that cannot find its template must still hand over a file
1984
+ # the operator can edit and `apply` can parse, rather than stopping.
1985
+ PROPOSAL_FALLBACK = """# Proposed Inventory
1986
+
1987
+ **Repository:** `{root}` · **Commit:** `{head}` · **Generated:** {date}
1988
+ **Pass A strategy:** `{strategy}` — {reason}
1989
+ **Scanned:** {files} files, {code} source files · **Coverage:** {coverage}%
1990
+
1991
+ The boundary map for this codebase, and yours to change. Edit it, then run
1992
+ `/appweaver inventory apply` to validate the edits and regenerate the machine map
1993
+ onboard reads. The Aggregate column must name an aggregate the scan found;
1994
+ everything else is yours.
1995
+
1996
+ ## Strategy probe
1997
+
1998
+ {probe_table}
1999
+
2000
+ ## Bounded Contexts
2001
+
2002
+ {context_table}
2003
+
2004
+ ## Features
2005
+
2006
+ {feature_table}
2007
+ {splits_section}{cycles_section}"""
2008
+
2009
+ PROPOSAL_TEMPLATE_NAME = "proposed-inventory-template.md"
2010
+
2011
+
2012
+ def _proposal_template(root):
2013
+ """The document shape, resolved the way every other template in this kit is:
2014
+ a project override first, then the kit's own default, then the inline fallback.
2015
+
2016
+ Filled by SUBSTITUTION rather than str.format, because the values include
2017
+ generated markdown tables whose cells come from the repository's own
2018
+ identifiers — a feature or path carrying a brace would abort a format call.
2019
+
2020
+ A `<!-- TEMPLATE-NOTE … -->` block is guidance for whoever edits the template
2021
+ and is removed here, so it never reaches the document an operator reads."""
2022
+ for path in (os.path.join(root, ".appweaver", "templates", "overrides", PROPOSAL_TEMPLATE_NAME),
2023
+ os.path.join(HERE, "..", "..", "templates", PROPOSAL_TEMPLATE_NAME)):
2024
+ try:
2025
+ with open(os.path.abspath(path), encoding="utf-8") as fh:
2026
+ text = fh.read()
2027
+ except OSError:
2028
+ continue
2029
+ return re.sub(r"<!--\s*TEMPLATE-NOTE\b.*?-->\n?", "", text, flags=re.S)
2030
+ return PROPOSAL_FALLBACK
2031
+
2032
+
2033
+ def _md_table(headers, rows):
2034
+ out = ["| " + " | ".join(headers) + " |",
2035
+ "|" + "|".join("---" for _ in headers) + "|"]
2036
+ for r in rows:
2037
+ out.append("| " + " | ".join("" if v is None else str(v) for v in r) + " |")
2038
+ return "\n".join(out)
2039
+
2040
+
2041
+ def _parse_md_tables(text, section):
2042
+ """Every table row under a `## <section>` heading, as lists of cell strings.
2043
+
2044
+ Header and separator rows are dropped, so a developer may reformat the table
2045
+ (add spacing, reorder nothing) without breaking the parse."""
2046
+ lines, rows, inside = text.splitlines(), [], False
2047
+ for line in lines:
2048
+ if line.startswith("## "):
2049
+ inside = line[3:].strip().lower().startswith(section.lower())
2050
+ continue
2051
+ if not inside:
2052
+ continue
2053
+ s = line.strip()
2054
+ if not s.startswith("|"):
2055
+ continue
2056
+ cells = [c.strip() for c in s.strip("|").split("|")]
2057
+ if not cells or all(set(c) <= set("-: ") for c in cells):
2058
+ continue
2059
+ rows.append(cells)
2060
+ return rows
2061
+
2062
+
2063
+ def _write_proposal(path, meta, contexts, features, waves, cycles, probe_rows):
2064
+ ctx_files, ctx_deps = {}, {}
2065
+ fmap = {f["feature"]: f for f in features}
2066
+ for f in features:
2067
+ ctx_files[f["context"]] = ctx_files.get(f["context"], 0) + f["files"]
2068
+ for d in f["depends_on"]:
2069
+ dctx = fmap.get(d, {}).get("context")
2070
+ if dctx and dctx != f["context"]:
2071
+ ctx_deps.setdefault(f["context"], set()).add(dctx)
2072
+
2073
+ wave_of = {m: w["wave"] for w in waves for m in w["members"]}
2074
+ ctx_wave = {}
2075
+ for f in features:
2076
+ ctx_wave[f["context"]] = min(ctx_wave.get(f["context"], 99),
2077
+ wave_of.get(f["feature"], 99))
2078
+
2079
+ context_table = _md_table(
2080
+ ["Order", "Bounded Context", "Depends On", "Features", "Files"],
2081
+ [[i + 1, ctx, ", ".join(sorted(ctx_deps.get(ctx, set()))) or "—",
2082
+ len(names), ctx_files.get(ctx, 0)]
2083
+ for i, (ctx, names) in enumerate(
2084
+ sorted(contexts.items(), key=lambda kv: (ctx_wave.get(kv[0], 99), kv[0])))
2085
+ if names])
2086
+
2087
+ rows = []
2088
+ for f in sorted(features, key=lambda x: (wave_of.get(x["feature"], 99),
2089
+ x["context"], x["feature"])):
2090
+ rows.append([f["feature"], f["context"], f["aggregate"],
2091
+ wave_of.get(f["feature"], "?"),
2092
+ ", ".join(f["depends_on"]) or "—",
2093
+ f["files"], f["churn"], f["seam"] or "—"])
2094
+ feature_table = _md_table(
2095
+ ["Feature", "Bounded Context", "Aggregate", "Wave", "Depends On",
2096
+ "Files", "Churn", "Seam"], rows)
2097
+
2098
+ # Both trailing sections are conditional and carry their own leading blank line,
2099
+ # so the template can sit them side by side and neither leaves a gap when absent.
2100
+ splits = [f for f in features if f["split_proposed"]]
2101
+ splits_section = ""
2102
+ if splits:
2103
+ splits_section = (
2104
+ "\n## Proposed splits\n\n"
2105
+ "Pass B found more than one cutover seam on these aggregates. "
2106
+ "**Nothing here is applied.** To take a split, add its row to the "
2107
+ "Features table above with the same Aggregate.\n\n"
2108
+ + _md_table(["Aggregate", "Suggested feature", "Seam", "Why"],
2109
+ [[f["aggregate"], s["feature"], s["seam"], s["why"]]
2110
+ for f in splits for s in f["split_proposed"]]) + "\n")
2111
+
2112
+ cycles_section = ""
2113
+ if cycles:
2114
+ cycles_section = (
2115
+ "\n## Dependency cycles\n\n"
2116
+ "These features depend on each other and cannot be ordered. Break the "
2117
+ "cycle by introducing a seam, or accept them as one onboarding unit by "
2118
+ "merging them in the Features table.\n\n"
2119
+ + "\n".join(f"- {' → '.join(c)} → {c[0]}" for c in cycles) + "\n")
2120
+
2121
+ values = dict(meta)
2122
+ values.update({
2123
+ "probe_table": _md_table(
2124
+ ["Strategy", "Features", "Coverage", "Singletons", "Chosen", "Availability"],
2125
+ probe_rows),
2126
+ "context_table": context_table,
2127
+ "feature_table": feature_table,
2128
+ "splits_section": splits_section,
2129
+ "cycles_section": cycles_section,
2130
+ })
2131
+ text = _proposal_template(meta["root"])
2132
+ for k, v in values.items():
2133
+ text = text.replace("{" + k + "}", str(v))
2134
+
2135
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
2136
+ with open(path, "w", encoding="utf-8") as fh:
2137
+ fh.write(text.rstrip("\n") + "\n")
2138
+
2139
+
952
2140
  def _slug(s):
953
2141
  return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", (s or "").lower())).strip("-")
954
2142
 
@@ -989,6 +2177,482 @@ def _read_frontmatter(path):
989
2177
  return data
990
2178
 
991
2179
 
2180
+ def cmd_propose(args):
2181
+ """Pass A + B + C over the repo: write the editable boundary map, and the
2182
+ machine map onboard reads until the operator edits the first one."""
2183
+ root = os.path.abspath(args.root or os.getcwd())
2184
+ files = list_files(root, load_config_excludes(args.config))
2185
+ be_files, fe_files, svc_prefix, ui_prefix = scope_to_lanes(
2186
+ files, args.service_root, args.ui_root)
2187
+ churn_list, _ = churn(root, load_config_excludes(args.config))
2188
+ churn_map = {c["file"]: c["commits"] for c in churn_list}
2189
+
2190
+ results, avail, graph, gstats, ents = run_probe(root, be_files, churn_map)
2191
+ # The menu hands back the whole option line, numbers and all, so the first
2192
+ # token is the strategy and the rest is what the operator was shown.
2193
+ wanted = (args.strategy or "").strip().split()[0].strip("`*_-").lower() if args.strategy else ""
2194
+ if wanted:
2195
+ if wanted not in results:
2196
+ reason = avail.get(wanted, {}).get("reason", "not a known strategy")
2197
+ print(json.dumps({"ok": "false", "error":
2198
+ f"strategy '{wanted}' is not available here — {reason}"}))
2199
+ return 1
2200
+ picked = wanted
2201
+ else:
2202
+ picked = choose_strategy(results)
2203
+ if not picked:
2204
+ print(json.dumps({"ok": "false", "feature_count": 0, "error":
2205
+ "no Pass A strategy produced candidates"}))
2206
+ return 1
2207
+
2208
+ candidates = results[picked]["candidates"]
2209
+ edges = candidate_edges(candidates, graph)
2210
+ seams = seam_overlay(root, be_files, candidates, churn_map)
2211
+ contexts = derive_contexts(candidates, be_files)
2212
+ features = propose_features(candidates, seams, edges, contexts)
2213
+ waves, cycles = dependency_waves([f["feature"] for f in features],
2214
+ {f["feature"]: set(f["depends_on"]) for f in features})
2215
+
2216
+ md = os.path.abspath(args.out) if args.out else os.path.join(root, "specs", "proposed-inventory.md")
2217
+ mp = os.path.abspath(args.map) if args.map else os.path.join(root, ".appweaver", "inventory", "feature-map.json")
2218
+ code_total = len([f for f in be_files if CODE_EXT_RE.search(f)])
2219
+ meta = {"root": root, "head": git(root, "rev-parse", "--short", "HEAD") or "-",
2220
+ "date": datetime.date.today().isoformat(), "strategy": picked,
2221
+ "reason": results[picked]["reason"], "files": len(files),
2222
+ "code": code_total, "coverage": results[picked]["coverage_pct"]}
2223
+ probe_rows = [[n, results[n]["count"], f"{results[n]['coverage_pct']}%",
2224
+ results[n]["singletons"], "**yes**" if n == picked else "",
2225
+ results[n]["reason"]] if n in results else
2226
+ [n, "—", "—", "—", "", f"unavailable — {avail[n]['reason']}"]
2227
+ for n in STRATEGY_PREFERENCE]
2228
+ _write_proposal(md, meta, contexts, features, waves, cycles, probe_rows)
2229
+ _write_feature_map(mp, meta, contexts, features, waves, cycles, results, avail, gstats)
2230
+
2231
+ print(f"Proposed inventory written to {md}", file=sys.stderr)
2232
+ print(f" strategy: {picked} features: {len(features)} "
2233
+ f"contexts: {len(contexts)} waves: {len(waves)}", file=sys.stderr)
2234
+ print(json.dumps({
2235
+ "ok": "true", "proposal_md": md, "feature_map": mp, "root": root,
2236
+ "strategy": picked, "feature_count": len(features),
2237
+ "context_count": len(contexts), "wave_count": len(waves),
2238
+ "cycle_count": len(cycles),
2239
+ "contexts_ordered": ", ".join(_ordered_contexts(contexts, features, waves)),
2240
+ "coverage_pct": results[picked]["coverage_pct"],
2241
+ "split_candidates": sum(1 for f in features if f["split_proposed"]),
2242
+ # One option line per AVAILABLE strategy, each carrying its own numbers so
2243
+ # the pick is made against evidence. A strategy that would return nothing
2244
+ # is never offered — an empty pick is the zero-candidate stop by choice.
2245
+ "strategy_options": [
2246
+ f"{n} — {results[n]['count']} features, {results[n]['coverage_pct']}% coverage, "
2247
+ f"{results[n]['singletons']} singleton(s)"
2248
+ + (" [owns/references available]" if n == "persistence" else "")
2249
+ for n in STRATEGY_PREFERENCE if n in results
2250
+ ],
2251
+ }))
2252
+ return 0
2253
+
2254
+
2255
+ def _ordered_contexts(contexts, features, waves):
2256
+ wave_of = {m: w["wave"] for w in waves for m in w["members"]}
2257
+ first = {}
2258
+ for f in features:
2259
+ first[f["context"]] = min(first.get(f["context"], 99),
2260
+ wave_of.get(f["feature"], 99))
2261
+ return [c for c in sorted(contexts, key=lambda c: (first.get(c, 99), c)) if contexts[c]]
2262
+
2263
+
2264
+ def _write_feature_map(path, meta, contexts, features, waves, cycles,
2265
+ results=None, avail=None, gstats=None, aggregates=None):
2266
+ """The machine contract onboard reads. Regenerated from the markdown on every
2267
+ apply, so the document stays the thing a person edits and this stays derived.
2268
+ `aggregates` is the registry of aggregates the scan found — what the Aggregate
2269
+ column may name — and outlives any feature that stops listing one."""
2270
+ registry = aggregates if aggregates is not None else sorted(
2271
+ {a for f in features for a in (f.get("aggregates") or [f.get("aggregate")]) if a})
2272
+ payload = {
2273
+ "version": "1",
2274
+ "generated": meta["date"],
2275
+ "root": meta["root"],
2276
+ "commit": meta["head"],
2277
+ "strategy": meta["strategy"],
2278
+ "contexts": [
2279
+ {"name": c, "order": i,
2280
+ "features": [f["feature"] for f in features if f["context"] == c],
2281
+ "onboarded": False}
2282
+ for i, c in enumerate(_ordered_contexts(contexts, features, waves))
2283
+ ],
2284
+ "features": features,
2285
+ "aggregates": registry,
2286
+ "waves": waves,
2287
+ "cycles": cycles,
2288
+ }
2289
+ if results is not None:
2290
+ payload["probe"] = {
2291
+ n: {"count": results[n]["count"], "coverage_pct": results[n]["coverage_pct"],
2292
+ "singletons": results[n]["singletons"], "available": True}
2293
+ for n in results}
2294
+ for n, a in (avail or {}).items():
2295
+ payload["probe"].setdefault(n, {"available": False, "reason": a["reason"]})
2296
+ payload["reference_graph"] = gstats or {}
2297
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
2298
+ with open(path, "w", encoding="utf-8") as fh:
2299
+ json.dump(payload, fh, indent=2)
2300
+
2301
+
2302
+ def cmd_apply(args):
2303
+ """Parse the edited proposal back into the machine map, validating every
2304
+ edit and naming the row that failed rather than guessing at intent."""
2305
+ root = os.path.abspath(args.root or os.getcwd())
2306
+ md = os.path.abspath(args.proposal) if args.proposal else os.path.join(root, "specs", "proposed-inventory.md")
2307
+ mp = os.path.abspath(args.map) if args.map else os.path.join(root, ".appweaver", "inventory", "feature-map.json")
2308
+ if not os.path.isfile(md):
2309
+ print(json.dumps({"ok": "false", "error":
2310
+ f"no proposal at {md} — run /appweaver inventory first"}))
2311
+ return 1
2312
+ prior = {}
2313
+ if os.path.isfile(mp):
2314
+ try:
2315
+ prior = json.load(open(mp, encoding="utf-8"))
2316
+ except Exception:
2317
+ prior = {}
2318
+ # The aggregates the SCAN found, carried forward on every apply. Deriving them from the
2319
+ # features alone forgets an aggregate the moment no feature lists it first: merge
2320
+ # Inventory into Checkout and the next apply of the same file rejects Inventory.
2321
+ known_aggs = set(prior.get("aggregates") or [])
2322
+ for f in prior.get("features", []):
2323
+ known_aggs.update(a for a in [f.get("aggregate")] + list(f.get("aggregates") or []) if a)
2324
+
2325
+ text = open(md, encoding="utf-8").read()
2326
+ rows = _parse_md_tables(text, "Features")
2327
+ problems, features, seen = [], [], {}
2328
+ prior_by_feature = {f["feature"]: f for f in prior.get("features", [])}
2329
+ prior_by_agg = {}
2330
+ for f in prior.get("features", []):
2331
+ prior_by_agg.setdefault(f["aggregate"], f)
2332
+
2333
+ for cells in rows:
2334
+ if len(cells) < 3 or cells[0].lower() in ("feature", ""):
2335
+ continue
2336
+ name, ctx, agg = cells[0], cells[1], cells[2]
2337
+ deps = [d.strip() for d in re.split(r"[,;]", cells[4]) if d.strip() and d.strip() != "—"] \
2338
+ if len(cells) > 4 else []
2339
+ if not name or not ctx or not agg:
2340
+ problems.append(f"row '{' | '.join(cells[:3])}' is missing a Feature, "
2341
+ f"Bounded Context or Aggregate value")
2342
+ continue
2343
+ if known_aggs and agg not in known_aggs:
2344
+ problems.append(f"feature '{name}' names aggregate '{agg}', which the scan "
2345
+ f"did not find — use one of: {', '.join(sorted(known_aggs)[:8])}…")
2346
+ continue
2347
+ base = prior_by_feature.get(name) or prior_by_agg.get(agg) or {}
2348
+ if name in seen:
2349
+ # Same feature twice is ONE feature over several aggregates: combine them, the
2350
+ # first row's aggregate first — that order is which concept its files land on.
2351
+ # A feature lives in one context, so a second row naming another one is a
2352
+ # contradiction to report rather than a choice to guess at.
2353
+ prev = seen[name]
2354
+ if prev["context"] != ctx:
2355
+ problems.append(f"feature '{name}' is placed in both '{prev['context']}' "
2356
+ f"and '{ctx}' — a feature lives in exactly one bounded context")
2357
+ continue
2358
+ if agg not in prev["aggregates"]:
2359
+ prev["aggregates"].append(agg)
2360
+ prev["depends_on"] += [d for d in deps if d not in prev["depends_on"]]
2361
+ continue
2362
+ # The aggregates are what THIS file says, never what the previous map said: a row
2363
+ # removed here has to leave the feature, and a base borrowed from another feature
2364
+ # over the same aggregate must not hand this one its aggregate list.
2365
+ entry = dict(base)
2366
+ entry.update({"feature": name, "context": ctx, "aggregate": agg,
2367
+ "aggregates": [agg], "depends_on": deps})
2368
+ entry.setdefault("files", 0)
2369
+ entry.setdefault("churn", 0)
2370
+ entry.setdefault("seam", "")
2371
+ entry.setdefault("split_proposed", [])
2372
+ seen[name] = entry
2373
+ features.append(entry)
2374
+
2375
+ if not features:
2376
+ problems.append("the Features table has no data rows — every feature would "
2377
+ "be dropped, which would leave onboard nothing to do")
2378
+
2379
+ names = {f["feature"] for f in features}
2380
+ for f in features:
2381
+ missing = [d for d in f["depends_on"] if d not in names]
2382
+ if missing:
2383
+ problems.append(f"feature '{f['feature']}' depends on {', '.join(missing)}, "
2384
+ f"which is not a feature in this file")
2385
+ f["depends_on"] = [d for d in f["depends_on"] if d in names]
2386
+
2387
+ if problems:
2388
+ for p in problems:
2389
+ print(f" ! {p}", file=sys.stderr)
2390
+ print(json.dumps({"ok": "false", "problem_count": len(problems),
2391
+ "problems": " · ".join(problems[:6]),
2392
+ "feature_count": len(features)}))
2393
+ return 1
2394
+
2395
+ contexts = {}
2396
+ for f in features:
2397
+ contexts.setdefault(f["context"], []).append(f["feature"])
2398
+ waves, cycles = dependency_waves([f["feature"] for f in features],
2399
+ {f["feature"]: set(f["depends_on"]) for f in features})
2400
+ meta = {"date": datetime.date.today().isoformat(), "root": root,
2401
+ "head": prior.get("commit", "-"), "strategy": prior.get("strategy", "edited")}
2402
+ _write_feature_map(mp, meta, contexts, features, waves, cycles,
2403
+ aggregates=sorted(known_aggs) or None)
2404
+
2405
+ print(f"Applied {md} -> {mp}", file=sys.stderr)
2406
+ print(f" {len(features)} feature(s) across {len(contexts)} context(s), "
2407
+ f"{len(waves)} wave(s){', CYCLES PRESENT' if cycles else ''}", file=sys.stderr)
2408
+ print(json.dumps({
2409
+ "ok": "true", "feature_map": mp, "feature_count": len(features),
2410
+ "context_count": len(contexts), "wave_count": len(waves),
2411
+ "cycle_count": len(cycles),
2412
+ "contexts_ordered": ", ".join(_ordered_contexts(contexts, features, waves)),
2413
+ }))
2414
+ return 0
2415
+
2416
+
2417
+ def cmd_context_state(args):
2418
+ """What onboard needs to know before it touches a bounded context: is this a
2419
+ context the map knows, what does it hold, has it been onboarded already, and
2420
+ is everything it depends on onboarded first.
2421
+
2422
+ This is what makes `onboard <Context>` repeatable. The bundle-wide curation
2423
+ guard cannot answer it — a bundle holding context 1's concepts looks
2424
+ identical to a bundle that must not be overwritten, and stopping on that is
2425
+ what made onboarding a second context impossible."""
2426
+ root = os.path.abspath(args.root or os.getcwd())
2427
+ mp = os.path.abspath(args.map) if args.map else os.path.join(root, ".appweaver", "inventory", "feature-map.json")
2428
+ bundle = os.path.abspath(args.bundle) if args.bundle else os.path.join(root, "knowledge")
2429
+ if not os.path.isfile(mp):
2430
+ print(json.dumps({"ok": "false", "map_present": "false", "error":
2431
+ f"no feature map at {mp} — run /appweaver inventory, edit "
2432
+ f"specs/proposed-inventory.md, then /appweaver inventory apply"}))
2433
+ return 1
2434
+ try:
2435
+ m = json.load(open(mp, encoding="utf-8"))
2436
+ except Exception as e:
2437
+ print(json.dumps({"ok": "false", "map_present": "true",
2438
+ "error": f"feature map at {mp} is not readable JSON: {e}"}))
2439
+ return 1
2440
+
2441
+ contexts = m.get("contexts", [])
2442
+ ordered = [c["name"] for c in contexts]
2443
+ want = (args.context or "").strip()
2444
+ match = next((c for c in contexts if c["name"].lower() == want.lower()), None)
2445
+ if want and not match:
2446
+ print(json.dumps({"ok": "false", "map_present": "true", "context_known": "false",
2447
+ "contexts_ordered": ", ".join(ordered),
2448
+ "error": f"'{want}' is not a bounded context in the map — "
2449
+ f"pick one of: {', '.join(ordered)}"}))
2450
+ return 1
2451
+
2452
+ # Which concepts the bundle already holds, so "onboarded" is read off the
2453
+ # artefacts rather than off a flag that can drift from them.
2454
+ # Matched hyphen-insensitively, the way cmd_concept resolves a PascalCase
2455
+ # name onto a bundle file: the map says `PatientRegistration` and the bundle
2456
+ # holds `patient-registration.md`, and comparing the slugs directly would
2457
+ # read every onboarded context as still open.
2458
+ def key(n):
2459
+ return re.sub(r"[^a-z0-9]", "", (n or "").lower())
2460
+
2461
+ present = set()
2462
+ agg_dir = os.path.join(bundle, "aggregates")
2463
+ if os.path.isdir(agg_dir):
2464
+ for fn in os.listdir(agg_dir):
2465
+ if fn.endswith(".md") and fn != "index.md":
2466
+ present.add(key(os.path.splitext(fn)[0]))
2467
+
2468
+ # A context is onboarded when every AGGREGATE its features act on has a concept. The
2469
+ # bundle is the project's knowledge and a concept is an aggregate: a context whose map
2470
+ # splits Patient into PatientRegistration and PatientMerge is done once Patient's
2471
+ # concept exists, and never holds a concept per capability.
2472
+ aggs_of = {}
2473
+ for f in m.get("features", []):
2474
+ aggs_of[f["feature"]] = (f.get("aggregates")
2475
+ or ([f["aggregate"]] if f.get("aggregate") else [f["feature"]]))
2476
+
2477
+ def ctx_aggs(ctx):
2478
+ out = []
2479
+ for f in ctx.get("features", []):
2480
+ for a in aggs_of.get(f, [f]):
2481
+ if a not in out:
2482
+ out.append(a)
2483
+ return out
2484
+
2485
+ def done(ctx):
2486
+ aggs = ctx_aggs(ctx)
2487
+ return bool(aggs) and all(key(a) in present for a in aggs)
2488
+
2489
+ onboarded = [c["name"] for c in contexts if done(c)]
2490
+ remaining = [c["name"] for c in contexts if not done(c)]
2491
+ target = match or next((c for c in contexts if not done(c)), None)
2492
+ if not target:
2493
+ print(json.dumps({"ok": "false", "map_present": "true", "context_known": "true",
2494
+ "all_onboarded": "true", "contexts_ordered": ", ".join(ordered),
2495
+ "error": "every bounded context in the map is already onboarded"}))
2496
+ return 1
2497
+
2498
+ feats = target.get("features", [])
2499
+ t_aggs = ctx_aggs(target)
2500
+ missing_aggs = [a for a in t_aggs if key(a) not in present]
2501
+ missing = [f for f in feats if any(key(a) not in present for a in aggs_of.get(f, [f]))]
2502
+ # Blocked by the contexts this one actually DEPENDS on, not by every context
2503
+ # that happens to sort earlier. Most contexts in a wave are independent of
2504
+ # each other, and blocking on display order would serialise work that can be
2505
+ # done in any order — the map says which edges are real.
2506
+ ctx_of = {f["feature"]: f["context"] for f in m.get("features", [])}
2507
+ needs = set()
2508
+ for f in m.get("features", []):
2509
+ if f.get("context") != target["name"]:
2510
+ continue
2511
+ for d in f.get("depends_on", []):
2512
+ dctx = ctx_of.get(d)
2513
+ if dctx and dctx != target["name"]:
2514
+ needs.add(dctx)
2515
+ blocking = [n for n in ordered if n in needs and n not in onboarded]
2516
+
2517
+ # A context inside a dependency cycle can never clear its blockers, because
2518
+ # each is waiting on the other. Reporting that as "blocked" would deadlock
2519
+ # the sequence, so a cycle member is released with the cycle named: the
2520
+ # operator either breaks the seam or onboards the ring as one unit.
2521
+ ctx_needs = {}
2522
+ for f in m.get("features", []):
2523
+ src = f.get("context")
2524
+ for d in f.get("depends_on", []):
2525
+ dctx = ctx_of.get(d)
2526
+ if src and dctx and dctx != src:
2527
+ ctx_needs.setdefault(src, set()).add(dctx)
2528
+ ring, stack, seen_ctx = set(), list(needs), set()
2529
+ while stack:
2530
+ n = stack.pop()
2531
+ if n in seen_ctx:
2532
+ continue
2533
+ seen_ctx.add(n)
2534
+ if target["name"] in ctx_needs.get(n, set()):
2535
+ ring.add(n)
2536
+ stack.extend(ctx_needs.get(n, set()))
2537
+ if ring:
2538
+ blocking = [n for n in blocking if n not in ring]
2539
+
2540
+ print(f"Context {target['name']}: {len(feats)} feature(s) over {len(t_aggs)} "
2541
+ f"aggregate(s), {len(missing_aggs)} aggregate(s) without a concept", file=sys.stderr)
2542
+ print(json.dumps({
2543
+ "ok": "true", "map_present": "true", "context_known": "true",
2544
+ "context": target["name"], "context_slug": _slug(target["name"]),
2545
+ "feature_count": len(feats), "features": ", ".join(feats),
2546
+ "aggregate_count": len(t_aggs), "aggregates": ", ".join(t_aggs),
2547
+ "feature_aggregates": "; ".join(f"{f} → {', '.join(aggs_of.get(f, [f]))}" for f in feats),
2548
+ "pending_count": len(missing), "pending_features": ", ".join(missing),
2549
+ "pending_aggregates": ", ".join(missing_aggs),
2550
+ "already_onboarded": "true" if not missing_aggs else "false",
2551
+ "blocked_by": ", ".join(blocking),
2552
+ "is_blocked": "true" if blocking else "false",
2553
+ "in_cycle": "true" if ring else "false",
2554
+ "cycle_with": ", ".join(sorted(ring)),
2555
+ "is_first": "true" if not onboarded else "false",
2556
+ "onboarded_contexts": ", ".join(onboarded),
2557
+ "remaining_contexts": ", ".join(remaining),
2558
+ "contexts_ordered": ", ".join(ordered),
2559
+ "context_count": len(contexts),
2560
+ "feature_map": mp,
2561
+ "strategy": m.get("strategy", ""),
2562
+ }))
2563
+ return 0
2564
+
2565
+
2566
+ def cmd_scaffold_context(args):
2567
+ """Create the SPECS directories one bounded context's features will live in:
2568
+ specs/<Context>/<Feature>/ for every feature the map assigns to it.
2569
+
2570
+ onboard writes a context's OKF concepts, and until now that was the only
2571
+ trace a run left under a name a person could pick. select-feature merges the
2572
+ bundle's aggregates into its list precisely to cover that gap, but a feature
2573
+ that exists only as a concept has nowhere for `specify` or `adopt` to put a
2574
+ document, and `list-contexts` — which reads the tree, not the bundle —
2575
+ reports the context as holding nothing. Creating the directories at onboard
2576
+ makes the context's shape visible in the one place every later command looks.
2577
+
2578
+ EMPTY DIRECTORIES ON PURPOSE. No spec.md and no domain-model.md: adopt
2579
+ reverse-engineers the first and specify authors the second, and a placeholder
2580
+ for either reads as "the document exists" to every gate downstream. The
2581
+ directory says the feature is KNOWN; the documents say it is described.
2582
+
2583
+ Names are normalised the way set-feature and set-context normalise them
2584
+ (PascalCase, non-alphanumerics dropped), so a hand-edited "order management"
2585
+ row in the proposal and a later `select-feature OrderManagement` resolve to
2586
+ one directory rather than two."""
2587
+ root = os.path.abspath(args.root or os.getcwd())
2588
+ mp = os.path.abspath(args.map) if args.map else os.path.join(root, ".appweaver", "inventory", "feature-map.json")
2589
+ if not os.path.isfile(mp):
2590
+ print(json.dumps({"ok": "false", "error":
2591
+ f"no feature map at {mp} — run /appweaver inventory apply first"}))
2592
+ return 1
2593
+ try:
2594
+ m = json.load(open(mp, encoding="utf-8"))
2595
+ except Exception as e:
2596
+ print(json.dumps({"ok": "false", "error": f"feature map at {mp} is not readable JSON: {e}"}))
2597
+ return 1
2598
+
2599
+ want = (args.context or "").strip()
2600
+ contexts = m.get("contexts", [])
2601
+ match = next((c for c in contexts if c["name"].lower() == want.lower()), None)
2602
+ if not match:
2603
+ print(json.dumps({"ok": "false", "error":
2604
+ f"'{want}' is not a bounded context in {mp} — one of: "
2605
+ f"{', '.join(c['name'] for c in contexts)}"}))
2606
+ return 1
2607
+
2608
+ ctx = _pascal(match["name"])
2609
+ ctx_dir = os.path.join(root, "specs", ctx)
2610
+ # The same refusal set-context makes. specs/<Name>/spec.md means that name is
2611
+ # already a FEATURE in the flat pre-context layout, and nesting this context's
2612
+ # features under it would put them inside a directory another spec claims.
2613
+ if os.path.isfile(os.path.join(ctx_dir, "spec.md")):
2614
+ print(json.dumps({"ok": "false", "error":
2615
+ f"specs/{ctx} is a feature (it has its own spec.md), not a "
2616
+ f"bounded context — rename the context in "
2617
+ f"specs/proposed-inventory.md and re-run /appweaver inventory apply"}))
2618
+ return 1
2619
+
2620
+ created, existed, rejected = [], [], []
2621
+ os.makedirs(ctx_dir, exist_ok=True)
2622
+ for raw in match.get("features", []):
2623
+ name = _pascal(raw)
2624
+ if not re.match(r"^[A-Za-z][A-Za-z0-9]*$", name):
2625
+ rejected.append(raw)
2626
+ continue
2627
+ d = os.path.join(ctx_dir, name)
2628
+ if os.path.isdir(d):
2629
+ existed.append(name)
2630
+ else:
2631
+ os.makedirs(d)
2632
+ created.append(name)
2633
+
2634
+ rel = "specs/" + ctx
2635
+ print(f"Scaffolded {rel}/: {len(created)} created, {len(existed)} already there"
2636
+ + (f", {len(rejected)} rejected" if rejected else ""), file=sys.stderr)
2637
+ print(json.dumps({
2638
+ "ok": "true",
2639
+ "context": ctx,
2640
+ "context_dir": rel,
2641
+ "created": ", ".join(created),
2642
+ "created_count": len(created),
2643
+ "existed": ", ".join(existed),
2644
+ "existed_count": len(existed),
2645
+ "rejected": ", ".join(rejected),
2646
+ "rejected_count": len(rejected),
2647
+ # A separate boolean because the engine's conditions compare one variable
2648
+ # against one literal and cannot ask whether a string is empty.
2649
+ "rejected_present": "true" if rejected else "false",
2650
+ "feature_dirs": ", ".join(f"{rel}/{n}" for n in created + existed),
2651
+ "feature_count": len(created) + len(existed),
2652
+ }))
2653
+ return 0
2654
+
2655
+
992
2656
  def cmd_concept(args):
993
2657
  """Resolve the OKF bundle concept for an aggregate → its path + source_files
994
2658
  (the code that IS the slice). The index adopt reverse-engineers spec.md, the
@@ -1057,9 +2721,18 @@ def cmd_list_aggregates(args):
1057
2721
  set-feature would create), its title/concept_path, and source_file_count. The
1058
2722
  caller merges these with the specs/ list, deduping by name (case-insensitive).
1059
2723
  Empty {aggregates:[], count:0} on a greenfield repo (no bundle) — a no-op there.
2724
+
2725
+ SCOPED BY BOUNDED CONTEXT when `--context` names one. onboard runs once per
2726
+ context and stamps every concept it writes `bounded_context:`, so a bundle
2727
+ that has seen two runs holds two contexts' aggregates. Without the filter,
2728
+ select-feature standing inside one context offers the other's features and
2729
+ would scaffold them into the wrong tree. A concept carrying NO stamp is kept
2730
+ whatever the filter says — that is a bundle written before the key existed,
2731
+ and hiding those features would be a worse failure than listing them.
1060
2732
  """
1061
2733
  bundle = os.path.abspath(args.bundle)
1062
2734
  present = os.path.isdir(bundle)
2735
+ want_ctx = re.sub(r"[^a-z0-9]", "", (getattr(args, "context", "") or "").lower())
1063
2736
  by_name = {}
1064
2737
  if present:
1065
2738
  for dirpath, dirnames, filenames in os.walk(bundle):
@@ -1078,12 +2751,20 @@ def cmd_list_aggregates(args):
1078
2751
  if not name:
1079
2752
  continue
1080
2753
  sf = [s for s in (fm.get("source_files") or []) if isinstance(s, str) and s.strip()]
2754
+ bc = fm.get("bounded_context") if isinstance(fm.get("bounded_context"), str) else ""
2755
+ if want_ctx and bc and re.sub(r"[^a-z0-9]", "", bc.lower()) != want_ctx:
2756
+ continue
2757
+ listed = [s for s in (fm.get("features") or []) if isinstance(s, str) and s.strip()] \
2758
+ if isinstance(fm.get("features"), list) else []
1081
2759
  cand = {
1082
2760
  "name": name,
1083
2761
  "title": title or base,
1084
2762
  "slug": _slug(name),
1085
2763
  "concept_path": os.path.relpath(path, os.getcwd()),
1086
2764
  "type": typ,
2765
+ "bounded_context": bc,
2766
+ "origin": fm.get("origin") if isinstance(fm.get("origin"), str) else "",
2767
+ "features": listed,
1087
2768
  "source_file_count": len(sf),
1088
2769
  }
1089
2770
  # Dedup by the case-insensitive feature name; a filename-slug match
@@ -1094,11 +2775,55 @@ def cmd_list_aggregates(args):
1094
2775
  (_slug(base) == cand["slug"]) or cand["source_file_count"] > prev["source_file_count"]):
1095
2776
  by_name[key] = cand
1096
2777
  aggs = sorted(by_name.values(), key=lambda a: a["name"].lower())
2778
+
2779
+ # WHAT A PERSON PICKS IS A FEATURE, and the bundle's concepts are AGGREGATES. The two
2780
+ # coincide on a 1:1 map and differ where the map split an aggregate into capabilities:
2781
+ # offering Patient beside PatientRegistration and PatientMerge would let select-feature
2782
+ # scaffold a feature nobody defined. Each concept is offered as the features the
2783
+ # confirmed map puts on it — within the scoped context, when there is one — and as
2784
+ # itself only where no map names any, which is every concept on a greenfield repo.
2785
+ k = lambda s: re.sub(r"[^a-z0-9]", "", (s or "").lower())
2786
+ root = os.path.abspath(args.root) if getattr(args, "root", "") else os.getcwd()
2787
+ mp = getattr(args, "map", "") or os.path.join(root, ".appweaver", "inventory", "feature-map.json")
2788
+ feats_on = {}
2789
+ try:
2790
+ with open(mp, encoding="utf-8") as fh:
2791
+ mapped = json.load(fh).get("features") or []
2792
+ except (OSError, ValueError):
2793
+ mapped = []
2794
+ for f in mapped:
2795
+ if want_ctx and k(f.get("context")) != want_ctx:
2796
+ continue
2797
+ for a in (f.get("aggregates") or [f.get("aggregate")]):
2798
+ if a:
2799
+ feats_on.setdefault(k(a), []).append(_pascal(f.get("feature", "")))
2800
+ # Without a map entry, the concept's own `features:` list names them. A concept that a
2801
+ # greenfield `implement` wrote offers nothing: every feature that acts on it already
2802
+ # has its specs/ directory, and the concept's name is an aggregate, not a feature
2803
+ # anyone defined. Only a pre-map brownfield concept still falls back to its own name.
2804
+ def offered(a):
2805
+ if feats_on.get(k(a["name"])):
2806
+ return feats_on[k(a["name"])]
2807
+ if (a.get("origin") or "").lower() == "greenfield":
2808
+ return []
2809
+ return [_pascal(f) for f in a.get("features") or []] or [a["name"]]
2810
+
2811
+ pickable, seen_pick = [], set()
2812
+ for a in aggs:
2813
+ for name in offered(a):
2814
+ if name and name.lower() not in seen_pick:
2815
+ seen_pick.add(name.lower())
2816
+ pickable.append({"name": name, "aggregate": a["name"],
2817
+ "concept_path": a["concept_path"]})
2818
+
1097
2819
  print(f"OKF aggregates in {args.bundle}: {len(aggs)} "
1098
- f"({', '.join(a['name'] for a in aggs) or '-'})", file=sys.stderr)
2820
+ f"({', '.join(a['name'] for a in aggs) or '-'}); pickable features: "
2821
+ f"{', '.join(p['name'] for p in pickable) or '-'}", file=sys.stderr)
1099
2822
  print(json.dumps({
1100
2823
  "aggregates": aggs, "count": len(aggs),
2824
+ "pickable": pickable, "pickable_count": len(pickable),
1101
2825
  "bundle": args.bundle, "bundle_present": "true" if present else "false",
2826
+ "context": getattr(args, "context", "") or "",
1102
2827
  }))
1103
2828
  return 0
1104
2829
 
@@ -1159,6 +2884,40 @@ def main():
1159
2884
  "so neither lane's substrate carries the other's files.")
1160
2885
  ps.set_defaults(func=cmd_scan)
1161
2886
 
2887
+ pp = sub.add_parser("propose", help="Pass A+B+C: write the editable Feature / Bounded Context map.")
2888
+ pp.add_argument("--root", help="Codebase to scan (default: CWD).")
2889
+ pp.add_argument("--config", help="okf-config.yml whose exclude: list is honored.")
2890
+ pp.add_argument("--service-root", dest="service_root", default="",
2891
+ help="The service lane's declared source path.")
2892
+ pp.add_argument("--ui-root", dest="ui_root", default="",
2893
+ help="The UI lane's declared source path.")
2894
+ pp.add_argument("--strategy", default="",
2895
+ help="Pass A strategy: structure | reference | persistence. "
2896
+ "Default: the highest-coverage AVAILABLE strategy, ties "
2897
+ "broken by fewest singletons.")
2898
+ pp.add_argument("--out", help="Proposal markdown (default: <root>/specs/proposed-inventory.md).")
2899
+ pp.add_argument("--map", help="Machine map (default: <root>/.appweaver/inventory/feature-map.json).")
2900
+ pp.set_defaults(func=cmd_propose)
2901
+
2902
+ pa = sub.add_parser("apply", help="Validate the edited proposal and regenerate the machine map.")
2903
+ pa.add_argument("--root", help="Codebase root (default: CWD).")
2904
+ pa.add_argument("--proposal", help="Proposal markdown (default: <root>/specs/proposed-inventory.md).")
2905
+ pa.add_argument("--map", help="Machine map to write (default: <root>/.appweaver/inventory/feature-map.json).")
2906
+ pa.set_defaults(func=cmd_apply)
2907
+
2908
+ pcs = sub.add_parser("context-state", help="What onboard needs before touching a bounded context: its features, whether it is already onboarded, and what must be onboarded first.")
2909
+ pcs.add_argument("--root", help="Codebase root (default: CWD).")
2910
+ pcs.add_argument("--context", default="", help="Bounded context to report on (default: the first not yet onboarded).")
2911
+ pcs.add_argument("--map", help="Machine map (default: <root>/.appweaver/inventory/feature-map.json).")
2912
+ pcs.add_argument("--bundle", help="OKF bundle dir (default: <root>/knowledge).")
2913
+ pcs.set_defaults(func=cmd_context_state)
2914
+
2915
+ psc = sub.add_parser("scaffold-context", help="Create specs/<Context>/<Feature>/ for every feature the map assigns to one bounded context.")
2916
+ psc.add_argument("--root", help="Codebase root (default: CWD).")
2917
+ psc.add_argument("--context", required=True, help="Bounded context to scaffold.")
2918
+ psc.add_argument("--map", help="Machine map (default: <root>/.appweaver/inventory/feature-map.json).")
2919
+ psc.set_defaults(func=cmd_scaffold_context)
2920
+
1162
2921
  pc = sub.add_parser("concept", help="Resolve the OKF bundle concept + source_files for an aggregate.")
1163
2922
  pc.add_argument("--bundle", required=True, help="OKF bundle dir to search.")
1164
2923
  pc.add_argument("--aggregate", required=True, help="Aggregate name (e.g. MedicinalProduct).")
@@ -1166,6 +2925,13 @@ def main():
1166
2925
 
1167
2926
  pl = sub.add_parser("list-aggregates", help="List the OKF bundle's Aggregate concepts (the brownfield feature map).")
1168
2927
  pl.add_argument("--bundle", required=True, help="OKF bundle dir to enumerate.")
2928
+ pl.add_argument("--context", default="",
2929
+ help="Bounded context to scope the list to. Concepts stamped with a "
2930
+ "DIFFERENT context are dropped; unstamped ones are always kept.")
2931
+ pl.add_argument("--root", default="", help="Repo root, for the default map path (default: CWD).")
2932
+ pl.add_argument("--map", default="",
2933
+ help="Machine map whose features are offered in place of the aggregate "
2934
+ "they act on (default: <root>/.appweaver/inventory/feature-map.json).")
1169
2935
  pl.set_defaults(func=cmd_list_aggregates)
1170
2936
 
1171
2937
  pv = sub.add_parser("validate", help="Run the OKF v0.1 conformance checker on a bundle.")