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