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