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