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.
@@ -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 (not args.allow_missing_root and not root_on_disk(repo, root)
1806
- and (not args.working_only or kind in worked or kind in required)):
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
- # both lenient frontmatter parsers in the toolchain only understand scalars +
116
- # flat block lists. So the grouping is emitted as YAML comment headers, which
117
- # PyYAML and both lenient parsers skip: readable grouping, zero schema change, no
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). Scalars are strings; block lists are lists.
431
- Mirrors aw-inventory.py's lenient parser so the two agree without a YAML dep."""
432
- if not text.startswith("---"):
433
- return {}, text
434
- lines = text.splitlines()
435
- end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
436
- if end is None:
437
- return {}, text
438
- fm, key = {}, None
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
- files = fm.get("source_files")
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
- existing = fm.get("source_files") if isinstance(fm.get("source_files"), list) else []
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 = [x for x in (existing if isinstance(existing, list) else []) if x]
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
- raw = fm.get("source_files")
1240
- # _read_fm gives a list for a block list, "" for an empty scalar; the `# Layer`
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 (m.get("features") or []), (m.get("commit") or "")
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
- line = "This feature's operations (the confirmed boundary map): %s." % ", ".join(mine)
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; `files` (every file in the declared
24
- lanes) and `code` (service-lane source files) are its two numbers on their own.
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), or a handler class where the solution keeps them in its own project |
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(…)`), the aggregate it acts on, and whether it creates one.
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 with `--strategy <name>` to pick another.
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, and the feature(s) it serves.
163
- Read-only — move an operation by editing the Operations cells above.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aw-spec-cli",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "AppWeaver Spec-Driven Development CLI - scaffolds projects with vertical slices, DDD, CQRS, and agent skills",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",