aw-spec-cli 0.1.31 → 0.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/.appweaver/STACK-PROFILES.md +9 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +77 -26
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +81 -27
- package/assets/.appweaver/scripts/powershell/aw-inventory.ps1 +20 -5
- package/assets/.appweaver/scripts/python/aw-inventory.py +2064 -259
- package/assets/.appweaver/scripts/python/aw-stack.py +13 -4
- package/assets/.appweaver/scripts/python/aw-xref.py +63 -43
- package/assets/.appweaver/templates/okf-concept-template.md +3 -1
- package/assets/.appweaver/templates/proposed-inventory-template.md +52 -13
- package/assets/foundations/java/Hexagonal/unpack-foundation.ps1 +168 -0
- package/package.json +1 -1
|
@@ -1771,7 +1771,7 @@ def cmd_verify(args):
|
|
|
1771
1771
|
lanes = (data or {}).get("lanes") or []
|
|
1772
1772
|
required = [k for k in (args.require or []) if k in ("backend", "frontend")]
|
|
1773
1773
|
worked = working_lane(repo, lanes)["kinds"]
|
|
1774
|
-
issues = []
|
|
1774
|
+
issues, missing_roots = [], []
|
|
1775
1775
|
|
|
1776
1776
|
# An empty file and an absent one are the SAME state. The kit ships
|
|
1777
1777
|
# stacks.json with an empty `lanes` array so a project can fill it in by hand, so
|
|
@@ -1802,13 +1802,15 @@ def cmd_verify(args):
|
|
|
1802
1802
|
"subtree; without one nothing can be built, run or written into it. "
|
|
1803
1803
|
"Re-run `/appweaver select-stack` and give it a lane root folder."
|
|
1804
1804
|
% (lane_id, kind, lane.get("profile", "?")))
|
|
1805
|
-
elif
|
|
1806
|
-
|
|
1807
|
-
issues.append(
|
|
1805
|
+
elif not root_on_disk(repo, root):
|
|
1806
|
+
missing_roots.append(
|
|
1808
1807
|
"lane '%s' (%s, stack %s) declares lane root folder '%s', which is not a "
|
|
1809
1808
|
"directory in this repository. Re-run `/appweaver select-stack` and "
|
|
1810
1809
|
"point the lane at the directory that holds its source."
|
|
1811
1810
|
% (lane_id, kind, lane.get("profile", "?"), root))
|
|
1811
|
+
if (not args.allow_missing_root
|
|
1812
|
+
and (not args.working_only or kind in worked or kind in required)):
|
|
1813
|
+
issues.append(missing_roots[-1])
|
|
1812
1814
|
# `declare` refuses an overlap, so one can only arrive here through a
|
|
1813
1815
|
# hand-edited file — a supported way to fill this in, and the one path that
|
|
1814
1816
|
# does not pass the writer. The invariant is worth a second check anywhere:
|
|
@@ -1838,6 +1840,13 @@ def cmd_verify(args):
|
|
|
1838
1840
|
"kinds": sorted({l.get("kind", "") for l in lanes if l.get("kind")}),
|
|
1839
1841
|
"root_check": not args.allow_missing_root,
|
|
1840
1842
|
"root_check_working_only": bool(args.working_only),
|
|
1843
|
+
# The kinds whose lane root folder is on disk, and the issue each declared lane
|
|
1844
|
+
# without one would raise. Reported whether or not the check is on: a phase that
|
|
1845
|
+
# reads code with `--allow-missing-root` reads the lanes that exist, and stops only
|
|
1846
|
+
# when none does.
|
|
1847
|
+
"kinds_on_disk": sorted({l.get("kind") for l in lanes
|
|
1848
|
+
if l.get("kind") and root_on_disk(repo, l.get("root", ""))}),
|
|
1849
|
+
"missing_roots": " ".join(missing_roots),
|
|
1841
1850
|
"issues": issues,
|
|
1842
1851
|
"issue_count": len(issues),
|
|
1843
1852
|
# No lanes always carries an issue now, so a clean run always has lanes to name.
|
|
@@ -112,9 +112,9 @@ _IDENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._-]*$")
|
|
|
112
112
|
# ─────────────────── source_files architecture grouping ────────────────────
|
|
113
113
|
# `source_files` stays a FLAT YAML list — validate_okf (E2/W6), the aw-inventory
|
|
114
114
|
# concept resolver and the source-map fidelity check all key on that shape, and
|
|
115
|
-
#
|
|
116
|
-
# flat
|
|
117
|
-
# PyYAML and
|
|
115
|
+
# the toolchain's lenient frontmatter reader (aw-inventory's parse_frontmatter)
|
|
116
|
+
# understands scalars and flat lists only. So the grouping is emitted as YAML comment
|
|
117
|
+
# headers, which PyYAML and that reader skip: readable grouping, zero schema change, no
|
|
118
118
|
# duplicated path list. Regenerated on every write (comments are lost on read and
|
|
119
119
|
# re-derived deterministically), so it is idempotent.
|
|
120
120
|
#
|
|
@@ -426,27 +426,33 @@ def _render_impact_md(aggregate, users):
|
|
|
426
426
|
|
|
427
427
|
|
|
428
428
|
# ─────────────────────────── frontmatter I/O ───────────────────────────────
|
|
429
|
+
_INVENTORY = None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _inventory():
|
|
433
|
+
"""aw-inventory, loaded by path once — it owns the concept frontmatter reader, and a
|
|
434
|
+
second copy here is how two answers to what a concept lists start to drift."""
|
|
435
|
+
global _INVENTORY
|
|
436
|
+
if _INVENTORY is None:
|
|
437
|
+
import importlib.util
|
|
438
|
+
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "aw-inventory.py")
|
|
439
|
+
spec = importlib.util.spec_from_file_location("aw_inventory", p)
|
|
440
|
+
mod = importlib.util.module_from_spec(spec)
|
|
441
|
+
spec.loader.exec_module(mod)
|
|
442
|
+
_INVENTORY = mod
|
|
443
|
+
return _INVENTORY
|
|
444
|
+
|
|
445
|
+
|
|
429
446
|
def _read_fm(text):
|
|
430
|
-
"""Return (frontmatter dict, body)
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
for ln in lines[1:end]:
|
|
440
|
-
m = re.match(r"^([A-Za-z0-9_]+):\s*(.*)$", ln)
|
|
441
|
-
if m:
|
|
442
|
-
key = m.group(1)
|
|
443
|
-
val = m.group(2).strip()
|
|
444
|
-
fm[key] = val.strip("'\"") if val else []
|
|
445
|
-
elif key and re.match(r"^\s*-\s+", ln):
|
|
446
|
-
if not isinstance(fm.get(key), list):
|
|
447
|
-
fm[key] = []
|
|
448
|
-
fm[key].append(re.sub(r"^\s*-\s+", "", ln).strip().strip("'\""))
|
|
449
|
-
return fm, "\n".join(lines[end + 1:])
|
|
447
|
+
"""Return (frontmatter dict, body), read by aw-inventory's parse_frontmatter: scalars
|
|
448
|
+
are strings, and a list — block or flow — is a list."""
|
|
449
|
+
return _inventory().parse_frontmatter(text)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _fm_list(value):
|
|
453
|
+
"""A frontmatter list field as its non-empty strings (aw-inventory's fm_list): a
|
|
454
|
+
single value where a list belongs is a one-item list."""
|
|
455
|
+
return _inventory().fm_list(value)
|
|
450
456
|
|
|
451
457
|
|
|
452
458
|
def _render_fm(fm):
|
|
@@ -763,13 +769,7 @@ def _has_code(name, bundle_abs):
|
|
|
763
769
|
if not path:
|
|
764
770
|
return False
|
|
765
771
|
fm, _ = _read_fm(_read(path))
|
|
766
|
-
|
|
767
|
-
if isinstance(files, list):
|
|
768
|
-
return any(str(f).strip() for f in files)
|
|
769
|
-
# The front-matter reader returns a BLOCK list as a list and anything else as the raw
|
|
770
|
-
# scalar, so the `source_files: []` a stub carries arrives as the STRING "[]" — which
|
|
771
|
-
# is empty, and truthy to anyone who does not look inside it.
|
|
772
|
-
return bool(str(files or "").strip(" \t[]"))
|
|
772
|
+
return bool(_fm_list(fm.get("source_files")))
|
|
773
773
|
|
|
774
774
|
|
|
775
775
|
def _concept_title(path):
|
|
@@ -1087,8 +1087,7 @@ def cmd_concept_upsert(args):
|
|
|
1087
1087
|
if args.merge:
|
|
1088
1088
|
# CR mode: {{files}} is only the delta's changed files — UNION with the
|
|
1089
1089
|
# slice's existing source_files, never replace (that would drop the rest).
|
|
1090
|
-
|
|
1091
|
-
merged = list(existing)
|
|
1090
|
+
merged = _fm_list(fm.get("source_files"))
|
|
1092
1091
|
for s in source_files:
|
|
1093
1092
|
if s not in merged:
|
|
1094
1093
|
merged.append(s)
|
|
@@ -1131,7 +1130,7 @@ def _merge_features(existing, names):
|
|
|
1131
1130
|
"""`existing` plus each of `names` not already listed, compared case- and
|
|
1132
1131
|
punctuation-insensitively. Never drops an entry: a concept's feature list only grows,
|
|
1133
1132
|
the same as adopt extends a concept additively."""
|
|
1134
|
-
out =
|
|
1133
|
+
out = _fm_list(existing)
|
|
1135
1134
|
keys = {_key(x) for x in out}
|
|
1136
1135
|
for n in names:
|
|
1137
1136
|
if n and _key(n) not in keys:
|
|
@@ -1236,10 +1235,8 @@ def cmd_concept_paths(args):
|
|
|
1236
1235
|
declared, existing, missing = [], [], []
|
|
1237
1236
|
if path:
|
|
1238
1237
|
fm, _ = _read_fm(_read(path))
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
# group headers _render_fm writes are comments and are already skipped by it.
|
|
1242
|
-
items = raw if isinstance(raw, list) else ([raw] if isinstance(raw, str) and raw else [])
|
|
1238
|
+
# The `# Layer` group headers _render_fm writes are comments, skipped by _read_fm.
|
|
1239
|
+
items = _fm_list(fm.get("source_files"))
|
|
1243
1240
|
seen = set()
|
|
1244
1241
|
for s in items:
|
|
1245
1242
|
s = (s or "").strip().strip('"').strip("'").replace(os.sep, "/")
|
|
@@ -1546,13 +1543,28 @@ def cmd_co_owners(args):
|
|
|
1546
1543
|
|
|
1547
1544
|
def _map_features(root):
|
|
1548
1545
|
"""(features, commit) from the confirmed boundary map — ([], "") where there is none."""
|
|
1546
|
+
m = _feature_map(root)
|
|
1547
|
+
return (m.get("features") or []), (m.get("commit") or "")
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def _feature_map(root):
|
|
1551
|
+
"""The confirmed boundary map, or {} where there is none."""
|
|
1549
1552
|
try:
|
|
1550
1553
|
with open(os.path.join(root, ".appweaver", "inventory", "feature-map.json"),
|
|
1551
1554
|
encoding="utf-8") as fh:
|
|
1552
1555
|
m = json.load(fh)
|
|
1553
1556
|
except (OSError, ValueError):
|
|
1554
|
-
return
|
|
1555
|
-
return
|
|
1557
|
+
return {}
|
|
1558
|
+
return m if isinstance(m, dict) else {}
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def _published_ids(root):
|
|
1562
|
+
"""{operation: [id, …]} — the ids the boundary map records each operation as
|
|
1563
|
+
published under in the API document, verbatim. Operations published under none
|
|
1564
|
+
are left out."""
|
|
1565
|
+
return {o["name"]: list(o.get("operation_ids") or [])
|
|
1566
|
+
for o in _feature_map(root).get("operations") or []
|
|
1567
|
+
if isinstance(o, dict) and o.get("name") and o.get("operation_ids")}
|
|
1556
1568
|
|
|
1557
1569
|
|
|
1558
1570
|
def _operation_scope(root, feature, siblings):
|
|
@@ -1576,13 +1588,21 @@ def _operation_scope(root, feature, siblings):
|
|
|
1576
1588
|
return mine, others, commit
|
|
1577
1589
|
|
|
1578
1590
|
|
|
1579
|
-
def _operations_note(mine, others):
|
|
1580
|
-
"""One prompt-ready line: this feature's operations and the ones that are not its own.
|
|
1591
|
+
def _operations_note(mine, others, ids=None):
|
|
1592
|
+
"""One prompt-ready line: this feature's operations and the ones that are not its own.
|
|
1593
|
+
`ids` ({operation: [id, …]}) names the id each is published under in the API
|
|
1594
|
+
document, which a plan's endpoint carries verbatim as its Operation id."""
|
|
1581
1595
|
if not mine:
|
|
1582
1596
|
return ("The boundary map records no operation list for this feature: scope its user "
|
|
1583
1597
|
"stories by the feature itself, and treat an operation whose folder or name "
|
|
1584
1598
|
"belongs to another feature as that feature's.")
|
|
1585
|
-
|
|
1599
|
+
ids = ids or {}
|
|
1600
|
+
line = "This feature's operations (the confirmed boundary map): %s." % ", ".join(
|
|
1601
|
+
o + (" (published as %s)" % ", ".join("`%s`" % i for i in ids[o]) if ids.get(o) else "")
|
|
1602
|
+
for o in mine)
|
|
1603
|
+
if any(ids.get(o) for o in mine):
|
|
1604
|
+
line += (" A published id is the API document's operationId: an endpoint's Operation "
|
|
1605
|
+
"id in a plan is that id, copied exactly.")
|
|
1586
1606
|
if others:
|
|
1587
1607
|
line += (" Operations that belong ONLY to another feature over the same aggregate — "
|
|
1588
1608
|
"not this feature's: %s." % "; ".join(
|
|
@@ -1649,7 +1669,7 @@ def cmd_feature_aggregates(args):
|
|
|
1649
1669
|
mine, others, commit = _operation_scope(root, r["feature"], r["shared_with"])
|
|
1650
1670
|
r.update({"operations": mine, "operations_csv": ", ".join(mine),
|
|
1651
1671
|
"sibling_operations": others,
|
|
1652
|
-
"operations_note": _operations_note(mine, others),
|
|
1672
|
+
"operations_note": _operations_note(mine, others, _published_ids(root)),
|
|
1653
1673
|
"baseline_commit": commit, "baseline_time": _commit_time(root, commit)})
|
|
1654
1674
|
print(f"[xref] feature-aggregates {r['feature']}: acts on {r['aggregates_csv']} "
|
|
1655
1675
|
f"(from {r['source']}); owns {r['owned_csv']}; {len(mine)} operation(s)", file=sys.stderr)
|
|
@@ -75,7 +75,9 @@ ui_source_files:
|
|
|
75
75
|
|
|
76
76
|
The grouping is DERIVED from the paths by _group_source_files (aw-xref.py) and
|
|
77
77
|
re-derived on every write (tests → a `tests` bucket; repo-root files → `(root)`), so
|
|
78
|
-
never hand-maintain the comment headers.
|
|
78
|
+
never hand-maintain the comment headers. Every list here is also read in YAML's flow
|
|
79
|
+
form (`features: [A, B]`, `ui_source_files: []`); the next script write turns it into
|
|
80
|
+
the block form above.
|
|
79
81
|
|
|
80
82
|
`ui_source_files` is the SAME shape for the slice's FRONTEND surface — the components,
|
|
81
83
|
routes, stores and stylesheets that serve this aggregate — and it is a separate key
|
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
root · head · date · strategy · reason · scanned · files · code · coverage
|
|
21
21
|
probe_table · context_table · aggregate_table · feature_table · operation_table
|
|
22
22
|
review_section · splits_section · cycles_section · unplaced_section
|
|
23
|
-
`scanned` says what was read, lane by lane
|
|
24
|
-
|
|
23
|
+
`scanned` says what was read, lane by lane, and names each declared lane whose lane
|
|
24
|
+
root folder does not exist yet; `files` (every file in the declared lanes) and `code`
|
|
25
|
+
(service-lane source files) are its two numbers on their own.
|
|
25
26
|
-->
|
|
26
27
|
<!-- Generated by `/appweaver inventory`. Edit freely, then run
|
|
27
28
|
`/appweaver inventory apply`. -->
|
|
@@ -41,10 +42,10 @@ The same four levels the forward pipeline writes, read back from the code:
|
|
|
41
42
|
|
|
42
43
|
| Level | What it is | Where the specs keep it | Where the code keeps it |
|
|
43
44
|
|---|---|---|---|
|
|
44
|
-
| **Bounded context** | a boundary with its own model and language | `specs/<Context>/` and its one `domain-model.md` | the module folder — one per context |
|
|
45
|
+
| **Bounded context** | a boundary with its own model and language | `specs/<Context>/` and its one `domain-model.md` | the module folder — one per context, named in PascalCase (`care/` → `Care`), read below a Java or Kotlin package's base package (`com/acme/clinic/care/` → `Care`); a tree organised by layer alone (`src/controllers/`, `src/models/`) has no context folder and is one context, `Core` |
|
|
45
46
|
| **Aggregate** | what must stay consistent together | a `## Aggregate: <Root>` section of that `domain-model.md` | a type declared as an aggregate root, in the module's `Domain/` |
|
|
46
|
-
| **Feature** | a capability the developer chose | `specs/<Context>/<Feature>/spec.md` | the UI lane's `features/<Feature>/` folder, named verbatim |
|
|
47
|
-
| **Operation** | one command or query the code exposes | the behaviour the `spec.md`'s user stories (`US-n`) and requirements describe | one slice under the module's `Features/` (command or query + handler + endpoint + validator)
|
|
47
|
+
| **Feature** | a capability the developer chose | `specs/<Context>/<Feature>/spec.md` | the UI lane's `features/<Feature>/` folder, named verbatim — or, in a frontend without one, its own module, route or page folders |
|
|
48
|
+
| **Operation** | one command or query the code exposes | the behaviour the `spec.md`'s user stories (`US-n`) and requirements describe | one slice under the module's `Features/` (command or query + handler + endpoint + validator); a command/query handler class; a use case (`RecordEncounterUseCase`, an `…Interactor`); or an endpoint — a controller action, an annotated or decorated handler, a router call — named by the operation id it publishes, else by what it invokes, else by its handler |
|
|
48
49
|
|
|
49
50
|
- A feature lives in **exactly one** bounded context.
|
|
50
51
|
- An operation is **not** a user story. A story usually needs several operations (record
|
|
@@ -91,9 +92,9 @@ exists:
|
|
|
91
92
|
|
|
92
93
|
| strategy | the evidence it reads | available when |
|
|
93
94
|
|---|---|---|
|
|
94
|
-
| `domain` | the aggregate roots the code **declares** — a type deriving from an aggregate-root base or carrying an aggregate-root marker; its context is the module folder holding its `Domain/` layer; it owns what it alone reaches, then what is named after it or used by it alone (its DTOs, errors, mappings, ports); its operations are its module's slices and command/query handlers anywhere in the lane, so a solution split one project per layer reads the same as one split per module | at least one root is declared |
|
|
95
|
-
| `structure` | the repo's own organising axis — a vertical slice, a layer folder under a module, a layer carried as a project-name suffix, or a layer at the top with the module repeated as a noun | always |
|
|
96
|
-
| `reference` | the `using` / `import` / `require` graph, clustered after removing the high-fan-in nodes that are shared kernel rather than features | enough import-parseable source |
|
|
95
|
+
| `domain` | the aggregate roots the code **declares** — a type deriving from an aggregate-root base, embedding one (a Go struct), or carrying an aggregate-root marker; its context is the module folder holding its `Domain/` layer; it owns what it alone reaches, then what is named after it or used by it alone (its DTOs, errors, mappings, ports); its operations are its module's slices, and the command/query handlers, use cases and endpoints anywhere in the lane, so a solution split one project per layer reads the same as one split per module | at least one root is declared |
|
|
96
|
+
| `structure` | the repo's own organising axis — a vertical slice, a layer folder under a module (`domain/`, `controllers/`, `routes/`, `routers/`, Go's `http/` or `transport/`, hexagonal `adapters/` and `ports/`, and `web/` or `service/` beside a `domain/` or `models/` folder), a layer carried as a project-name suffix, or a layer at the top with the module repeated as a noun — read from the file name, singular and in PascalCase (`routes/medication_orders.py` → `MedicationOrder`, `IPatientRepository.cs` → `Patient`). An entry point (`main.go`, `Program.cs`, `index.js`, `__init__.py`) names no module unless it sits in a domain folder. An application with no module folders (one project or base package holding the layers) is read the same way, each file joining the domain noun its name contains (`RecordEncounterUseCase` → `Encounter`); an infrastructure or helper file naming none of them (`Data/ClinicDbContext.cs`) serves every aggregate and is left unplaced. Shared code (`shared/`, `common/`, a shared kernel) and the files of a project root that holds modules (its manifest, entry point and wiring) belong to no module and are left unplaced | always |
|
|
97
|
+
| `reference` | the `using` / `import` / `require` graph, clustered after removing the high-fan-in nodes that are shared kernel rather than features. A file no import touches takes its `structure` module, and what `structure` leaves unplaced (shared code, a project root's manifest and entry point) stays unplaced | enough import-parseable source |
|
|
97
98
|
| `persistence` | the entities the code tells its ORM about — `DbSet<T>`, `IEntityTypeConfiguration<T>`, `modelBuilder.Entity<T>()`, `[Table]`, JPA `@Entity`, `schema.prisma`, `CREATE TABLE` — plus what they hold collections of, with roots derived by ownership: an entity that owns others' lifecycles and is referenced from outside by id. Read from class shape only where nothing is declared | ORM / schema / migration evidence found |
|
|
98
99
|
|
|
99
100
|
**Declared beats measured.** Where the code declares its roots and operations act on
|
|
@@ -105,7 +106,21 @@ cluster a directory and a human name.
|
|
|
105
106
|
|
|
106
107
|
**Pass B — the operations and their seams.** Reads the application, API and UI surface
|
|
107
108
|
only: each operation's route (`Get("/care/encounters/{id}")`, `[HttpPost]`,
|
|
108
|
-
`@GetMapping`, `app.get(…)`),
|
|
109
|
+
`@GetMapping`, `@router.post`, `app.get(…)`, `r.Post(…)`), joined to the prefix its
|
|
110
|
+
controller, router or route group declares, the aggregate it acts on, and whether it
|
|
111
|
+
creates one. A controller with no route declarations is read by convention,
|
|
112
|
+
`/<controller>/<action>`. A route made only of infrastructure words — a health, liveness or
|
|
113
|
+
readiness probe, a ping, a status or version report (`/healthz`, `/api/health`,
|
|
114
|
+
`/health/ready`, `/ping`) — serves the platform and is never an operation, and a UI call
|
|
115
|
+
to one links nothing; a resource's own `/status` (`/care/encounters/{id}/status`) is
|
|
116
|
+
read as usual. Each operation also carries the id it is **published** under
|
|
117
|
+
in the API document, verbatim — declared beside its endpoint (`.WithName`,
|
|
118
|
+
`@Operation(operationId = …)`, `operation_id=`, a route `Name`), or by route in an OpenAPI
|
|
119
|
+
or Swagger document or the doc comments the lane keeps (swagger-jsdoc, swag). What the
|
|
120
|
+
code declares wins: a document fills only an operation whose code declares no id, and
|
|
121
|
+
two documents that disagree are both shown. An operation published under none shows
|
|
122
|
+
`—`; an id is never made up. An id names its operation in PascalCase
|
|
123
|
+
(`record_encounter` → `RecordEncounter`) and is shown verbatim.
|
|
109
124
|
Route families are read below the bounded context's own prefix. **Two route families
|
|
110
125
|
on one aggregate is a split candidate** — proposed below, never applied.
|
|
111
126
|
|
|
@@ -113,7 +128,16 @@ on one aggregate is a split candidate** — proposed below, never applied.
|
|
|
113
128
|
evidence that names one:
|
|
114
129
|
|
|
115
130
|
1. every UI `features/<Feature>/` folder becomes a feature, named verbatim, holding the
|
|
116
|
-
operations it calls and acting on those operations' aggregates
|
|
131
|
+
operations it calls and acting on those operations' aggregates. A call reaches an
|
|
132
|
+
operation by its route — the verb and path the frontend calls, in the folder's own
|
|
133
|
+
files or through the API function, client or service it imports, against the routes
|
|
134
|
+
the service lane declares; parameters (`{id}`, `{id:guid}`, `:id`, `${id}`) read
|
|
135
|
+
alike and a leading `api/` or version segment is ignored. An imported function or
|
|
136
|
+
the folder itself named after an operation links it too. A call no service route
|
|
137
|
+
matches is listed under **Needs a decision**. A frontend with no
|
|
138
|
+
`features/` folder is read from the first of its own feature folders that exists —
|
|
139
|
+
`modules/`, `app/` route segments (Next.js, Angular), `pages/` (React, Nuxt, Blazor),
|
|
140
|
+
`views/` (Vue), `routes/` (SvelteKit, Remix) — each folder name made PascalCase;
|
|
117
141
|
2. a read no feature calls joins every feature acting on its aggregate — reads are
|
|
118
142
|
shared, and they are not what separates two capabilities;
|
|
119
143
|
3. what is left — commands no feature calls, and aggregates no UI feature touches —
|
|
@@ -124,11 +148,24 @@ evidence that names one:
|
|
|
124
148
|
goes first, every other feature on it follows, and an aggregate that references another
|
|
125
149
|
by id follows that one's creator. Reads never order anything.
|
|
126
150
|
|
|
151
|
+
**A project with no service lane** — a front end whose API lives in another repository —
|
|
152
|
+
is read from its UI lane alone (strategy `ui`). The API resources it calls are the
|
|
153
|
+
aggregates it works on (`/care/encounters/…` → `Encounter`), each distinct call is an
|
|
154
|
+
operation, and a leading route segment that every route under it follows with a resource
|
|
155
|
+
(`/care/encounters`, `/care/medication-orders`) is a bounded context; one context, `Core`,
|
|
156
|
+
holds the rest. A feature acts on what it calls, in its own files or
|
|
157
|
+
through the API function, client or service it imports.
|
|
158
|
+
|
|
159
|
+
**A lane declared before it is built** — its lane root folder does not exist yet — is read as
|
|
160
|
+
if it were not declared, and the Scanned line above names it. A service lane not built yet
|
|
161
|
+
reads the project from its UI lane, as above; a UI lane not built yet leaves the features to
|
|
162
|
+
the service lane's evidence.
|
|
163
|
+
|
|
127
164
|
## Strategy probe
|
|
128
165
|
|
|
129
166
|
The clustering each available strategy produced on this tree, with the first names it
|
|
130
167
|
found — judge a strategy by what it found, not only by how many. The chosen one is
|
|
131
|
-
marked; re-run
|
|
168
|
+
marked; re-run `/appweaver inventory <name>` to build the map from another.
|
|
132
169
|
|
|
133
170
|
{probe_table}
|
|
134
171
|
|
|
@@ -159,8 +196,10 @@ when it is the only feature on that aggregate.
|
|
|
159
196
|
{review_section}{splits_section}{cycles_section}
|
|
160
197
|
## Operations
|
|
161
198
|
|
|
162
|
-
Every operation the scan found, what it acts on,
|
|
163
|
-
|
|
199
|
+
Every operation the scan found, what it acts on, the id it is published under, and the
|
|
200
|
+
feature(s) it serves. `adopt` hands the published id to the feature's plans, whose
|
|
201
|
+
endpoints carry it as their Operation id. Read-only — move an operation by editing the
|
|
202
|
+
Operations cells above.
|
|
164
203
|
|
|
165
204
|
{operation_table}
|
|
166
205
|
{unplaced_section}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#requires -Version 5.1
|
|
2
|
+
<#
|
|
3
|
+
.SYNOPSIS
|
|
4
|
+
Unpack an AW-Spec Java foundation pack into a target application, replacing the Java
|
|
5
|
+
package sentinel with the target application's package.
|
|
6
|
+
|
|
7
|
+
.DESCRIPTION
|
|
8
|
+
Windows / PowerShell mirror of unpack-foundation.sh.
|
|
9
|
+
|
|
10
|
+
Reads -PackFile, takes the sentinel from its header (override with -Sentinel) and writes
|
|
11
|
+
every <<<DIR>>> and <<<FILE>>> block under -OutDir with the sentinel rewritten in both
|
|
12
|
+
file CONTENTS and file/dir PATHS:
|
|
13
|
+
|
|
14
|
+
io.appweaver -> <lowercase application name> (contents)
|
|
15
|
+
io/appweaver -> <lowercase application name> (paths)
|
|
16
|
+
|
|
17
|
+
The pack is the Maven module itself (pom.xml, mvnw, src/main/java, src/test/java) and
|
|
18
|
+
lands in -OutDir exactly as packed: that directory IS the module. Text blocks are
|
|
19
|
+
written as UTF-8 (no BOM, LF); base64 blocks are decoded as-is. Refuses to overwrite
|
|
20
|
+
existing files unless -Force; the check also runs under -DryRun, so a non-zero exit from
|
|
21
|
+
a dry run tells the orchestrator that collisions exist.
|
|
22
|
+
|
|
23
|
+
.EXAMPLE
|
|
24
|
+
./unpack-foundation.ps1 -PackFile ./Hexagonal.pack.txt -TargetAppName FlightBooking -OutDir backend
|
|
25
|
+
#>
|
|
26
|
+
[CmdletBinding()]
|
|
27
|
+
param(
|
|
28
|
+
[Parameter(Mandatory)] [string] $PackFile,
|
|
29
|
+
[Parameter(Mandatory)] [string] $TargetAppName,
|
|
30
|
+
[string] $OutDir = 'src',
|
|
31
|
+
[string] $Sentinel,
|
|
32
|
+
[switch] $Force,
|
|
33
|
+
[switch] $DryRun
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
$ErrorActionPreference = 'Stop'
|
|
37
|
+
|
|
38
|
+
# --- validate args ---
|
|
39
|
+
if (-not (Test-Path -LiteralPath $PackFile -PathType Leaf)) { throw "Pack file not found: $PackFile" }
|
|
40
|
+
if ($TargetAppName -cnotmatch '^[A-Za-z0-9._-]+$') {
|
|
41
|
+
throw "Invalid application name '$TargetAppName'. Allowed characters: A-Z a-z 0-9 . _ -"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
$pack = (Resolve-Path -LiteralPath $PackFile).Path
|
|
45
|
+
$lines = ([System.IO.File]::ReadAllText($pack)) -split "`r?`n"
|
|
46
|
+
|
|
47
|
+
# --- sentinel from header unless overridden ---
|
|
48
|
+
if (-not $Sentinel) {
|
|
49
|
+
foreach ($l in $lines) {
|
|
50
|
+
if ($l -match '^#\s*sentinel:\s*(.+?)\s*$') { $Sentinel = $Matches[1]; break }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (-not $Sentinel) { throw "Could not determine sentinel from pack. Use -Sentinel." }
|
|
54
|
+
|
|
55
|
+
# --- Java replacement: dotted and slashed forms of the sentinel -> lowercase app name ---
|
|
56
|
+
# NOTE: an array of pairs, not a hashtable — hashtable keys are case-INSENSITIVE in PowerShell.
|
|
57
|
+
$targetPackage = $TargetAppName.ToLowerInvariant()
|
|
58
|
+
$forms = @(
|
|
59
|
+
@{ From = $Sentinel; To = $targetPackage }
|
|
60
|
+
@{ From = ($Sentinel -replace '\.', '/'); To = $targetPackage }
|
|
61
|
+
)
|
|
62
|
+
$replacements = @()
|
|
63
|
+
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal)
|
|
64
|
+
foreach ($pair in $forms) {
|
|
65
|
+
$from = [string]$pair.From
|
|
66
|
+
if ([string]::IsNullOrEmpty($from)) { continue }
|
|
67
|
+
if (-not $seen.Add($from)) { continue } # ordinal dedupe of identical From tokens
|
|
68
|
+
$replacements += [pscustomobject]@{ From = $from; To = [string]$pair.To }
|
|
69
|
+
}
|
|
70
|
+
# longest token first so overlaps resolve correctly
|
|
71
|
+
$replacements = @($replacements | Sort-Object { $_.From.Length } -Descending)
|
|
72
|
+
|
|
73
|
+
function Apply-Repl([string] $text) {
|
|
74
|
+
foreach ($r in $replacements) { $text = $text.Replace($r.From, $r.To) }
|
|
75
|
+
return $text
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# --- output root (not created until a real run) ---
|
|
79
|
+
if ([System.IO.Path]::IsPathRooted($OutDir)) { $outRoot = $OutDir }
|
|
80
|
+
else { $outRoot = Join-Path (Get-Location).Path $OutDir }
|
|
81
|
+
$outRoot = [System.IO.Path]::GetFullPath($outRoot)
|
|
82
|
+
$sep = [System.IO.Path]::DirectorySeparatorChar
|
|
83
|
+
|
|
84
|
+
# --- parse pack blocks ---
|
|
85
|
+
$files = @()
|
|
86
|
+
$dirRels = @()
|
|
87
|
+
$i = 0
|
|
88
|
+
while ($i -lt $lines.Count) {
|
|
89
|
+
if ($lines[$i] -match '^<<<FILE path="([^"]+)"(?:\s+encoding="(\w+)")?>>>\s*$') {
|
|
90
|
+
$rel = $Matches[1]; $enc = $Matches[2]
|
|
91
|
+
$i++
|
|
92
|
+
$buf = New-Object System.Collections.Generic.List[string]
|
|
93
|
+
while ($i -lt $lines.Count -and $lines[$i] -ne '<<<ENDFILE>>>') {
|
|
94
|
+
$buf.Add($lines[$i]); $i++
|
|
95
|
+
}
|
|
96
|
+
$files += [pscustomobject]@{ Rel = $rel; Enc = $enc; Content = ($buf -join "`n") }
|
|
97
|
+
}
|
|
98
|
+
elseif ($lines[$i] -match '^<<<DIR path="([^"]+)">>>\s*$') {
|
|
99
|
+
$dirRels += $Matches[1]
|
|
100
|
+
}
|
|
101
|
+
$i++
|
|
102
|
+
}
|
|
103
|
+
if ($files.Count -eq 0) { throw "No <<<FILE>>> blocks found in pack: $pack" }
|
|
104
|
+
|
|
105
|
+
function Resolve-Dest([string] $rel) {
|
|
106
|
+
$renamed = Apply-Repl $rel
|
|
107
|
+
foreach ($seg in ($renamed -split '[\\/]')) {
|
|
108
|
+
if ($seg -eq '..') { throw "Refusing path outside the output folder: $rel" }
|
|
109
|
+
}
|
|
110
|
+
if ([System.IO.Path]::IsPathRooted($renamed)) { throw "Refusing absolute path in pack: $rel" }
|
|
111
|
+
return (Join-Path $outRoot $renamed.Replace('/', $sep))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# The pack carries the empty Java package parents (src/main/java/io, src/test/java/io);
|
|
115
|
+
# once io.appweaver is renamed they would be left behind empty, so they are not created.
|
|
116
|
+
$skipDirs = @('src/main/java/io', 'src/test/java/io')
|
|
117
|
+
$dirDests = @()
|
|
118
|
+
foreach ($d in $dirRels) {
|
|
119
|
+
if ($skipDirs -contains $d) { continue }
|
|
120
|
+
$dirDests += (Resolve-Dest $d)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
$fileDests = @($files | ForEach-Object { Resolve-Dest $_.Rel })
|
|
124
|
+
|
|
125
|
+
# --- conflict guard (also under -DryRun: a non-zero exit there means "collisions exist") ---
|
|
126
|
+
if (-not $Force) {
|
|
127
|
+
$conflicts = @($fileDests | Where-Object { Test-Path -LiteralPath $_ })
|
|
128
|
+
if ($conflicts.Count -gt 0) {
|
|
129
|
+
throw ("Destination already exists (use -Force to overwrite):`n " + ($conflicts -join "`n "))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# --- directories, then files ---
|
|
134
|
+
$directoriesCreated = 0
|
|
135
|
+
foreach ($d in $dirDests) {
|
|
136
|
+
if ($DryRun) { Write-Host "WOULD CREATE DIRECTORY $d"; continue }
|
|
137
|
+
New-Item -ItemType Directory -Force -Path $d | Out-Null
|
|
138
|
+
$directoriesCreated++
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
$utf8 = [System.Text.UTF8Encoding]::new($false)
|
|
142
|
+
$written = 0
|
|
143
|
+
for ($n = 0; $n -lt $files.Count; $n++) {
|
|
144
|
+
$f = $files[$n]
|
|
145
|
+
$dest = $fileDests[$n]
|
|
146
|
+
if ($DryRun) { Write-Host "WOULD WRITE $dest"; continue }
|
|
147
|
+
$destDir = Split-Path -Parent $dest
|
|
148
|
+
if ($destDir -and -not (Test-Path -LiteralPath $destDir)) {
|
|
149
|
+
New-Item -ItemType Directory -Force -Path $destDir | Out-Null
|
|
150
|
+
}
|
|
151
|
+
if ($f.Enc -eq 'base64') {
|
|
152
|
+
[System.IO.File]::WriteAllBytes($dest, [Convert]::FromBase64String(($f.Content -replace '\s', '')))
|
|
153
|
+
} else {
|
|
154
|
+
[System.IO.File]::WriteAllText($dest, ((Apply-Repl $f.Content) + "`n"), $utf8)
|
|
155
|
+
}
|
|
156
|
+
$written++
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# --- summary ---
|
|
160
|
+
Write-Host ''
|
|
161
|
+
if ($DryRun) {
|
|
162
|
+
Write-Host "DryRun: $($files.Count) file(s) and $($dirRels.Count) directorie(s) would be written to:"
|
|
163
|
+
} else {
|
|
164
|
+
Write-Host "Unpacked $written file(s) and $directoriesCreated directorie(s) to:"
|
|
165
|
+
}
|
|
166
|
+
Write-Host " $outRoot"
|
|
167
|
+
Write-Host " Sentinel: $Sentinel"
|
|
168
|
+
Write-Host " Package: $targetPackage"
|
package/package.json
CHANGED