aw-spec-cli 0.1.28 → 0.1.30

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.
Files changed (67) hide show
  1. package/assets/.amp/agent.json +22 -0
  2. package/assets/.amp/settings-edit.json +5 -0
  3. package/assets/.amp/settings-read.json +12 -0
  4. package/assets/.appweaver/DESIGN-FORMAT.md +9 -0
  5. package/assets/.appweaver/STACK-PROFILES.md +102 -15
  6. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +40 -18
  7. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +56 -4
  8. package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +17 -1
  9. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +12 -3
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +289 -24
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +370 -85
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +349 -65
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +39 -7
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +63 -4
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +5 -4
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +430 -138
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +392 -76
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-lane.orch.yaml +104 -0
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +20 -3
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +26 -3
  21. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +314 -44
  22. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +249 -20
  23. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +17 -2
  24. package/assets/.appweaver/scripts/python/aw-plangate.py +109 -19
  25. package/assets/.appweaver/scripts/python/aw-stack.py +1295 -9
  26. package/assets/.appweaver/templates/delta-design-template.md +7 -5
  27. package/assets/.appweaver/templates/delta-spec-template.md +2 -0
  28. package/assets/.appweaver/templates/design-template.md +17 -0
  29. package/assets/.appweaver/templates/ui-design-template.md +19 -4
  30. package/assets/.augment/agent.json +22 -0
  31. package/assets/.augment/commands/appweaver.md +12 -0
  32. package/assets/.cline/agent.json +22 -0
  33. package/assets/.clinerules/workflows/appweaver.md +7 -0
  34. package/assets/.codex/agent.json +22 -0
  35. package/assets/.continue/agent.json +22 -0
  36. package/assets/.continue/prompts/appweaver.md +13 -0
  37. package/assets/.cursor/agent.json +22 -0
  38. package/assets/.cursor/commands/appweaver.md +7 -0
  39. package/assets/.devin/agent.json +22 -0
  40. package/assets/.devin/workflows/appweaver.md +11 -0
  41. package/assets/.factory/agent.json +22 -0
  42. package/assets/.factory/commands/appweaver.md +12 -0
  43. package/assets/.gemini/agent.json +22 -0
  44. package/assets/.github/prompts/appweaver.prompt.md +13 -0
  45. package/assets/.kilo/agent.json +28 -0
  46. package/assets/.kilo/agents/appweaver-edit.md +12 -0
  47. package/assets/.kilo/agents/appweaver-read.md +12 -0
  48. package/assets/.kilo/commands/appweaver.md +11 -0
  49. package/assets/.kiro/agent.json +22 -0
  50. package/assets/.kiro/skills/appweaver/SKILL.md +12 -0
  51. package/assets/.opencode/agent.json +22 -0
  52. package/assets/.opencode/agents/appweaver-edit.md +12 -0
  53. package/assets/.opencode/agents/appweaver-read.md +13 -0
  54. package/assets/.opencode/commands/appweaver.md +11 -0
  55. package/assets/.roo/agent.json +22 -0
  56. package/assets/.roo/commands/appweaver.md +12 -0
  57. package/dist/commands/init.d.ts.map +1 -1
  58. package/dist/commands/init.js +14 -1
  59. package/dist/commands/init.js.map +1 -1
  60. package/dist/utils/config.d.ts.map +1 -1
  61. package/dist/utils/config.js +14 -0
  62. package/dist/utils/config.js.map +1 -1
  63. package/dist/utils/copy-assets.d.ts +16 -3
  64. package/dist/utils/copy-assets.d.ts.map +1 -1
  65. package/dist/utils/copy-assets.js +29 -14
  66. package/dist/utils/copy-assets.js.map +1 -1
  67. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
  """
3
3
  aw-stack — the lane model's deterministic core.
4
4
 
5
- Backs ten aw-spec-actions subcommands, called identically by the bash and the
5
+ Backs seventeen aw-spec-actions subcommands, called identically by the bash and the
6
6
  powershell twin so the two stay in parity by construction rather than by review:
7
7
 
8
8
  list enumerate .appweaver/stack-profiles/ — the candidate list
@@ -13,10 +13,18 @@ powershell twin so the two stay in parity by construction rather than by review:
13
13
  resolve read .appweaver/stacks.json — for every downstream reader; with
14
14
  --feature-dir, narrowed to the lanes that feature has a surface in
15
15
  verify is the declaration USABLE for the phase about to run — the gate
16
+ lane-select read or write .appweaver/lane.json — the developer's WORKING lane
17
+ cr-lanes the lanes a change touches, read from or written to its delta-spec
18
+ slice-gate-stamp one lane's analyze-slice-delta result, written into the change
16
19
  run-phase run a phase's command for EVERY declared lane, not just the first
17
20
  probe is there evidence of a lane of this kind — read-only, never fatal
18
21
  contract-export start the service lane, fetch its API document, stop it
22
+ contract-draft draft a feature's API document from its backend plan's API section
23
+ contract-combine the exported document plus every draft operation it does not serve
24
+ contract-requests the frontend plan's contract requests and the backend plan's answers
19
25
  contract-check the operation ids a frontend plan cites vs that document
26
+ contract-impact operations a change's service delta changes or removes that the
27
+ frontend binds to, with no matching frontend change
20
28
 
21
29
  This file is the only code that writes stacks.json, through two subcommands with
22
30
  different jobs. `declare` creates or replaces a whole lane and has exactly one caller —
@@ -41,6 +49,22 @@ declared before the keys existed answers them too:
41
49
  The document is written to CONTRACT_DOC under the service lane root folder, so it is
42
50
  committed with the service that publishes it.
43
51
 
52
+ THE BACKEND PUBLISHES, THE FRONTEND READS. The export runs where the service is worked on —
53
+ implement's backend half, and build after a green compile — and never from a frontend run:
54
+ a frontend developer may have neither the service's code built nor its runtime installed.
55
+ What a frontend run reads is the committed document, plus a DRAFT for what is planned and
56
+ not built yet: `contract-draft` turns each backend plan's API section into DRAFT_NAME beside
57
+ the plan, by script, and `contract-combine` writes COMBINED_DOC — the exported document with
58
+ every draft operation it does not serve yet added and marked `x-aw-draft`. The client is
59
+ generated from the combined document, so a frontend can bind to an endpoint the backend has
60
+ planned and not built; the draft operation drops out of it once the service serves its id.
61
+
62
+ THE REQUEST LEDGER. A frontend plan asks for what the service does not offer in its §5
63
+ "Contract deltas needed" table, one RQ-n row each; the backend plan answers in its API
64
+ section's "Requests from the frontend lane" table, published as an operation id or declined
65
+ with the reason. Each developer writes only their own plan, and `contract-requests` reads
66
+ both to say which requests are open, answered, declined, or answered and not re-bound yet.
67
+
44
68
  A person editing stacks.json in a text editor is the other way a lane arrives,
45
69
  and it is supported: the kit ships the file with an empty `lanes` array for exactly
46
70
  that, plus a `_lane_format` key holding one sample lane of each kind with every field
@@ -49,6 +73,17 @@ filled in. Nothing reads `_lane_format`; it is there to copy from. It is the one
49
73
  `declare` already did. `resolve` can never write, which is why these are two
50
74
  subcommands and not one with a mode flag.
51
75
 
76
+ THE WORKING LANE. stacks.json says which lanes the PROJECT has; .appweaver/lane.json says
77
+ which of them THIS DEVELOPER is working on — `backend`, `frontend` or `both`. It is not
78
+ committed: a backend developer and a frontend developer on one repository each keep their
79
+ own. `select-stack` writes the default (both when both lanes are declared, otherwise the
80
+ one that is), `select-lane` changes it, and a clone without the file reads that same
81
+ default. Every reader gets `<kind>_working` beside `<kind>_declared`: a phase WRITES only a
82
+ working lane — plans it, gates it, generates, builds and tests it — and still READS a lane
83
+ that is declared but not worked, which is how a frontend plan binds to a backend plan
84
+ another developer owns. With the default, every declared lane is worked, which is the
85
+ behaviour from before the file existed.
86
+
52
87
  A lane's `root` IS the lane root folder — the subtree the lane owns and the only place
53
88
  its implement phase may write. The two words name one field, and `--source-path`
54
89
  is accepted wherever `--root` is.
@@ -85,9 +120,23 @@ import sys
85
120
  STACKS_REL = ".appweaver/stacks.json"
86
121
  PROFILES_REL = ".appweaver/stack-profiles"
87
122
 
123
+ # The developer's working lane, and the three answers it can hold. Not committed — see
124
+ # the module docstring.
125
+ LANE_REL = ".appweaver/lane.json"
126
+ WORKING_LANES = ("backend", "frontend", "both")
127
+
88
128
  # Where the exported API document lands, relative to the service lane root folder.
89
129
  CONTRACT_DOC = "contracts/openapi.json"
90
130
 
131
+ # A feature's draft API document, beside the plans it is drafted from, and the combined
132
+ # document a frontend generates its client from (repository-relative, not committed).
133
+ DRAFT_NAME = "api-draft.json"
134
+ COMBINED_DOC = ".appweaver/contracts/openapi.combined.json"
135
+
136
+ # Each lane's plan document, under its current name and its pre-lane-split one.
137
+ BACKEND_PLAN_NAMES = ("backend-plan.md", "design.md")
138
+ FRONTEND_PLAN_NAMES = ("frontend-plan.md", "design.ui.md")
139
+
91
140
  # The API-contract keys: the only ones `set` may change, and the ones read from the
92
141
  # stack profile when the lane does not set them (lane_value). Everything else on a lane
93
142
  # is the declaration itself and changes only through `declare`.
@@ -715,7 +764,7 @@ def lane_host(repo, lane):
715
764
  return None
716
765
 
717
766
 
718
- def lane_command(lane, phase, repo=None):
767
+ def lane_command(lane, phase, repo=None, contract=""):
719
768
  """The command for one phase, with `{{root}}` standing for the lane root folder and
720
769
  `{{host}}` for the lane's runnable project inside it (see lane_host).
721
770
 
@@ -739,16 +788,17 @@ def lane_command(lane, phase, repo=None):
739
788
  several. It is looked up when the command is read, so a project scaffolded after the
740
789
  lane was declared is found; with no single match it falls back to `{{root}}`.
741
790
 
742
- `{{contract}}` is the service lane's exported API document (contract_doc), named as
743
- seen from where THIS lane's command runs — what a frontend lane's `client` command
744
- generates from. With no service lane it is left as written.
791
+ `{{contract}}` is the API document a frontend lane's `client` command generates from,
792
+ named as seen from where THIS lane's command runs: `contract` when the caller passes
793
+ one (implement passes the combined document), else the service lane's exported
794
+ document (contract_doc). With neither it is left as written.
745
795
  """
746
796
  if phase in CONTRACT_KEYS:
747
797
  cmd = lane_value(repo, lane, phase)
748
798
  else:
749
799
  cmd = (lane.get(phase) or "").strip()
750
800
  if "{{contract}}" in cmd and repo:
751
- doc = contract_doc(repo)
801
+ doc = contract or contract_doc(repo)
752
802
  if doc:
753
803
  rel = os.path.relpath(os.path.join(repo, doc), lane_workdir(repo, lane))
754
804
  cmd = cmd.replace("{{contract}}", rel.replace("\\", "/"))
@@ -1226,6 +1276,385 @@ def apply_feature_lanes(payload, kinds):
1226
1276
  payload[kind + "_feature_excluded"] = excluded
1227
1277
  if excluded:
1228
1278
  payload[kind + "_declared"] = False
1279
+ payload[kind + "_working"] = False
1280
+
1281
+
1282
+ # ───────────────────────────── working lane ────────────────────────────────
1283
+
1284
+ def declared_kinds(lanes):
1285
+ """The lane kinds stacks.json declares, backend first."""
1286
+ return [k for k in ("backend", "frontend") if any(l.get("kind") == k for l in lanes)]
1287
+
1288
+
1289
+ def default_working(lanes):
1290
+ """The working lane a developer has before choosing one: every declared lane."""
1291
+ kinds = declared_kinds(lanes)
1292
+ return "both" if len(kinds) == 2 else (kinds[0] if kinds else "")
1293
+
1294
+
1295
+ def read_lane_file(repo):
1296
+ """lane.json's record: None when there is no file, {} when it cannot be read."""
1297
+ path = os.path.join(repo, LANE_REL)
1298
+ if not os.path.isfile(path):
1299
+ return None
1300
+ try:
1301
+ with open(path, encoding="utf-8") as fh:
1302
+ rec = json.load(fh)
1303
+ except (OSError, ValueError):
1304
+ return {}
1305
+ return rec if isinstance(rec, dict) else {}
1306
+
1307
+
1308
+ def working_lane(repo, lanes):
1309
+ """Which declared lanes this developer works on, and where that answer came from.
1310
+
1311
+ lane.json is honoured only while it names a lane that is still declared. A file that
1312
+ is missing, unreadable, or names a lane select-stack has since removed falls back to
1313
+ the default, and `note` says why — a stale choice must never switch a lane off.
1314
+ """
1315
+ kinds = declared_kinds(lanes)
1316
+ default = default_working(lanes)
1317
+ rec = read_lane_file(repo)
1318
+ chosen = str((rec or {}).get("lane", "")).strip().lower()
1319
+ lane, source, note = default, "default", ""
1320
+ if rec is None:
1321
+ note = "no %s — every declared lane is worked" % LANE_REL
1322
+ elif chosen not in WORKING_LANES:
1323
+ note = ("%s names no working lane ('%s'); every declared lane is worked. Run "
1324
+ "/appweaver select-lane to choose one." % (LANE_REL, chosen))
1325
+ elif chosen != "both" and chosen not in kinds:
1326
+ note = ("%s selects the %s lane, which .appweaver/stacks.json does not declare; "
1327
+ "every declared lane is worked. Run /appweaver select-lane to choose again."
1328
+ % (LANE_REL, chosen))
1329
+ else:
1330
+ source = LANE_REL
1331
+ lane = chosen if chosen != "both" else default
1332
+ worked = kinds if lane == "both" else ([lane] if lane else [])
1333
+ return {"lane": lane, "kinds": worked, "source": source, "note": note,
1334
+ "default": default}
1335
+
1336
+
1337
+ def apply_working(payload, repo, lanes):
1338
+ """Add the working-lane fields to a resolve/verify payload, after both kind blocks.
1339
+
1340
+ `working_kind` is the ONE kind being worked, or "" when every declared lane is — what a
1341
+ caller passes as `--kind` so that both lanes behave exactly as an unnarrowed call did.
1342
+ `working_root` / `working_test_path` follow the same rule, for a caller that narrows a
1343
+ file list with `--under`.
1344
+ """
1345
+ w = working_lane(repo, lanes)
1346
+ single = w["kinds"][0] if len(w["kinds"]) == 1 and len(declared_kinds(lanes)) == 2 else ""
1347
+ payload.update({
1348
+ "working_lane": w["lane"],
1349
+ "working_kind": single,
1350
+ "working_kinds": w["kinds"],
1351
+ "working_source": w["source"],
1352
+ "working_note": w["note"],
1353
+ "working_default": w["default"],
1354
+ "working_root": payload.get(single + "_source_path", "") if single else "",
1355
+ "working_test_path": payload.get(single + "_test_path", "") if single else "",
1356
+ "lane_file": LANE_REL,
1357
+ "lane_file_exists": os.path.isfile(os.path.join(repo, LANE_REL)),
1358
+ })
1359
+ for kind in ("backend", "frontend"):
1360
+ payload[kind + "_working"] = bool(payload.get(kind + "_declared")) and kind in w["kinds"]
1361
+
1362
+
1363
+ def lane_select_options(repo, lanes):
1364
+ """One selectable line per working-lane answer: each declared lane, then `both` when
1365
+ there are two. The value is the first token; the rest says what it means."""
1366
+ out = []
1367
+ for kind in declared_kinds(lanes):
1368
+ lane = next(l for l in lanes if l.get("kind") == kind)
1369
+ prof = profile_of(repo, lane)
1370
+ title = (prof.get("title", "") or lane.get("stack", "")
1371
+ or lane.get("profile", "") or "no stack declared")
1372
+ root = norm_root(lane.get("root"))
1373
+ where = "the repo root" if root == REPO_ROOT else (root or "no lane root folder")
1374
+ out.append(ROOT_LABEL_SEP.join(
1375
+ [kind, "work on the %s lane `%s` only (%s at %s)"
1376
+ % (kind, lane.get("id", ""), title, where)]))
1377
+ if len(out) == 2:
1378
+ out.append(ROOT_LABEL_SEP.join(["both", "work on both lanes, as a full-stack developer"]))
1379
+ return out
1380
+
1381
+
1382
+ def cmd_lane_select(args):
1383
+ """Read the working lane, or write it.
1384
+
1385
+ No argument reports the current answer and the menu `select-lane` shows. `--lane`
1386
+ writes one of the offered answers (a whole option line is accepted, as `user_select`
1387
+ hands it back). `--default` writes the default for the declared stacks — select-stack's
1388
+ call, so a re-stacked project starts again from every declared lane.
1389
+ """
1390
+ import datetime
1391
+
1392
+ repo = args.repo
1393
+ lanes = (read_stacks(repo) or {}).get("lanes") or []
1394
+ options = lane_select_options(repo, lanes)
1395
+ keys = [strip_root_label(o) for o in options]
1396
+ default = default_working(lanes)
1397
+
1398
+ wanted = ""
1399
+ if args.default:
1400
+ wanted = default
1401
+ if not wanted:
1402
+ fail("lane-select: no lane is declared in %s, so there is no working lane to "
1403
+ "set. Run /appweaver select-stack first." % STACKS_REL)
1404
+ elif args.lane:
1405
+ wanted = strip_root_label(args.lane).strip().lower()
1406
+ if wanted not in keys:
1407
+ fail("lane-select: '%s' is not a working lane for this project; the declared "
1408
+ "stacks offer %s" % (wanted, ", ".join(keys) or "none — run /appweaver select-stack"))
1409
+
1410
+ written = False
1411
+ if wanted:
1412
+ os.makedirs(os.path.join(repo, ".appweaver"), exist_ok=True)
1413
+ with open(os.path.join(repo, LANE_REL), "w", encoding="utf-8") as fh:
1414
+ json.dump({
1415
+ "_note": ("This developer's working lane: backend, frontend or both. Not "
1416
+ "committed. select-stack writes the default; /appweaver "
1417
+ "select-lane changes it."),
1418
+ "lane": wanted,
1419
+ "set_by": "select-stack" if args.default else "select-lane",
1420
+ "updated": datetime.datetime.now(datetime.timezone.utc)
1421
+ .strftime("%Y-%m-%dT%H:%M:%SZ"),
1422
+ }, fh, indent=2)
1423
+ fh.write("\n")
1424
+ written = True
1425
+
1426
+ # The argument doubles as the answer, but only as a WHOLE word: `select-lane frontend`
1427
+ # selects, prose that merely mentions a lane does not.
1428
+ text = (args.preselect or "").strip().lower()
1429
+ preselect = next((o for o, k in zip(options, keys) if k == text), "")
1430
+
1431
+ w = working_lane(repo, lanes)
1432
+ emit({
1433
+ "ok": True,
1434
+ "declared": bool(lanes),
1435
+ "declared_kinds": declared_kinds(lanes),
1436
+ "lane": w["lane"],
1437
+ "kinds": w["kinds"],
1438
+ "source": w["source"],
1439
+ "note": w["note"],
1440
+ "default": default,
1441
+ "select_options": options,
1442
+ "option_keys": keys,
1443
+ "option_count": len(options),
1444
+ "preselect": preselect,
1445
+ "written": written,
1446
+ "path": LANE_REL,
1447
+ })
1448
+
1449
+
1450
+ # THE LANES A CHANGE TOUCHES. A change's delta-spec names them on its `**Lanes**:` header
1451
+ # line: `backend`, `frontend`, or `backend, frontend`. specify asks when it opens a change
1452
+ # and plan asks when it opens a plan-only one, with both lanes as the answer listed first;
1453
+ # the spec half is written whole whatever the answer. A delta-spec with no such line, or
1454
+ # with the template's unfilled `[…]` placeholder, touches every declared lane — which is
1455
+ # what every change opened before the line existed did.
1456
+ _LANES_LINE = re.compile(r"^\*\*Lanes\*\*:[ \t]*(.*?)[ \t]*(?:<!--.*)?$", re.M)
1457
+ _WAVE_LINE = re.compile(r"^\*\*Wave\*\*:.*$", re.M)
1458
+
1459
+
1460
+ def change_lane_options(lanes):
1461
+ """One selectable line per answer to "which lanes does this change touch?": both
1462
+ first, then each lane alone. One declared lane is the only answer; none, no answer."""
1463
+ kinds = declared_kinds(lanes)
1464
+ if len(kinds) == 2:
1465
+ return [ROOT_LABEL_SEP.join(["both", "the change touches the backend and the "
1466
+ "frontend — each lane plans and builds its half"]),
1467
+ ROOT_LABEL_SEP.join(["backend", "the change touches only the backend"]),
1468
+ ROOT_LABEL_SEP.join(["frontend", "the change touches only the frontend"])]
1469
+ return [ROOT_LABEL_SEP.join([k, "the change touches the %s, the only declared lane" % k])
1470
+ for k in kinds]
1471
+
1472
+
1473
+ def read_change_lanes(path):
1474
+ """The kinds a delta-spec's `**Lanes**:` line names, or None when it has no line, still
1475
+ carries the template placeholder or cannot be opened.
1476
+
1477
+ The one Python reader of the line: the bash cr-list and cr-paths load it from this file,
1478
+ and powershell's Get-ChangeLanes is its twin. A byte that is not UTF-8 elsewhere in the
1479
+ document is read as a replacement character, as powershell reads it, so it never hides
1480
+ the line."""
1481
+ try:
1482
+ with open(path, encoding="utf-8", errors="replace") as fh:
1483
+ text = fh.read()
1484
+ except OSError:
1485
+ return None
1486
+ m = _LANES_LINE.search(text)
1487
+ value = m.group(1).strip().lower() if m else ""
1488
+ if not value or value.startswith("["):
1489
+ return None
1490
+ if re.search(r"\bboth\b", value):
1491
+ return ["backend", "frontend"]
1492
+ kinds = [k for k in ("backend", "frontend") if re.search(r"\b%s\b" % k, value)]
1493
+ return kinds or None
1494
+
1495
+
1496
+ def stamp_change_lanes(path, kinds):
1497
+ """Write the `**Lanes**:` line: replaced in place when there is one, otherwise inserted
1498
+ after the `**Wave**:` line — or after the title when there is no wave either — so the
1499
+ header block stays together."""
1500
+ with open(path, encoding="utf-8") as fh:
1501
+ text = fh.read()
1502
+ line = "**Lanes**: " + ", ".join(kinds)
1503
+ if _LANES_LINE.search(text):
1504
+ text = _LANES_LINE.sub(lambda _m: line, text, count=1)
1505
+ else:
1506
+ lines = text.split("\n")
1507
+ at = next((i + 1 for i, l in enumerate(lines) if _WAVE_LINE.match(l)), None)
1508
+ if at is None:
1509
+ at = next((i + 1 for i, l in enumerate(lines) if l.startswith("# ")), 0)
1510
+ lines[at:at] = ["", line]
1511
+ text = "\n".join(lines)
1512
+ with open(path, "w", encoding="utf-8") as fh:
1513
+ fh.write(text)
1514
+
1515
+
1516
+ def cmd_cr_lanes(args):
1517
+ """The answers to "which lanes does this change touch?", and — with --delta-spec — the
1518
+ lanes that change records.
1519
+
1520
+ `--set` writes an answer onto the delta-spec's `**Lanes**:` line (a whole option line
1521
+ is accepted, as `user_select` hands it back); `--stamp` with no `--set` writes every
1522
+ declared lane. `lanes` is what the change touches among the lanes declared now: the
1523
+ recorded ones that are still declared, or every declared lane when nothing is recorded.
1524
+ """
1525
+ repo = args.repo
1526
+ lanes = (read_stacks(repo) or {}).get("lanes") or []
1527
+ kinds = declared_kinds(lanes)
1528
+ options = change_lane_options(lanes)
1529
+ keys = [strip_root_label(o) for o in options]
1530
+ path = args.delta_spec
1531
+ exists = bool(path) and os.path.isfile(path)
1532
+
1533
+ stamped = False
1534
+ if args.stamp or args.set:
1535
+ if not exists:
1536
+ fail("cr-lanes: no delta-spec at %s — open the change first" % (path or "(none given)"))
1537
+ answer = strip_root_label(args.set).strip().lower()
1538
+ if answer:
1539
+ offered = keys or ["both", "backend", "frontend"]
1540
+ if answer not in offered:
1541
+ fail("cr-lanes: '%s' is not an answer for this project; the declared stacks "
1542
+ "offer %s" % (answer, ", ".join(offered)))
1543
+ chosen = ["backend", "frontend"] if answer == "both" else [answer]
1544
+ else:
1545
+ chosen = kinds or ["backend", "frontend"]
1546
+ stamp_change_lanes(path, chosen)
1547
+ stamped = True
1548
+
1549
+ recorded = read_change_lanes(path) if exists else None
1550
+ if kinds:
1551
+ touched = [k for k in (recorded or kinds) if k in kinds]
1552
+ else:
1553
+ touched = list(recorded or [])
1554
+ emit({
1555
+ "ok": True,
1556
+ "declared_kinds": kinds,
1557
+ "select_options": options,
1558
+ "option_keys": keys,
1559
+ "option_count": len(options),
1560
+ "has_options": bool(options),
1561
+ "default": keys[0] if keys else "",
1562
+ "delta_spec": path,
1563
+ "exists": exists,
1564
+ "recorded": recorded is not None,
1565
+ "recorded_kinds": recorded or [],
1566
+ "lanes": touched,
1567
+ "lanes_csv": ",".join(touched),
1568
+ "backend": "backend" in touched,
1569
+ "frontend": "frontend" in touched,
1570
+ "source": "delta-spec" if recorded is not None else "declared",
1571
+ "stamped": stamped,
1572
+ })
1573
+
1574
+
1575
+ # THE RESULT OF analyze-slice-delta, ONE FILE PER LANE. A change's two lanes may be built and
1576
+ # gated by two developers, each on their own machine, so each lane's result is its own file
1577
+ # in the change's directory — `.aw-slice-gate.<lane>.json` — committed with the change and
1578
+ # archived with it. Each developer writes only their own lane's file, so the two results
1579
+ # never collide in a merge. `pass` needs the lane's tests green AND its requirements met;
1580
+ # anything else is `fail`. Green means no test failed: a lane that declares no test command
1581
+ # is green with nothing run, and `tests_ran` false says so, so a reader that needs an
1582
+ # executed suite checks that field rather than the verdict. The record also hashes the
1583
+ # delta-spec and the lane's delta plan the code was gated against, so a reader can tell
1584
+ # when either has moved since.
1585
+ SLICE_GATE = ".aw-slice-gate.%s.json"
1586
+ DELTA_PLAN_NAMES = {
1587
+ "backend": ("delta-backend-plan.md", "delta-design.md"),
1588
+ "frontend": ("delta-frontend-plan.md", "delta-design.ui.md"),
1589
+ }
1590
+ SLICE_GATE_NOTE = ("Written by analyze-slice-delta for one lane of this change: pass means no "
1591
+ "test of the lane failed and its code met the delta-spec. tests_ran says "
1592
+ "whether a test suite actually ran: false when the lane declares no test "
1593
+ "command. Each lane's developer writes only their own lane's file.")
1594
+
1595
+
1596
+ def _file_sha(path):
1597
+ import hashlib
1598
+ try:
1599
+ with open(path, "rb") as fh:
1600
+ return hashlib.sha256(fh.read()).hexdigest()
1601
+ except OSError:
1602
+ return ""
1603
+
1604
+
1605
+ def cmd_slice_gate_stamp(args):
1606
+ import datetime
1607
+ repo = args.repo
1608
+ cr_dir = args.cr_dir if os.path.isabs(args.cr_dir) else os.path.join(repo, args.cr_dir)
1609
+ if not os.path.isdir(cr_dir):
1610
+ fail("slice-gate-stamp: no open change at %s" % args.cr_dir)
1611
+ lane = args.lane
1612
+ truthy = lambda v: str(v or "").strip().lower() == "true"
1613
+ tests_green, requirements_ok = truthy(args.tests_green), truthy(args.requirements_ok)
1614
+ # The findings are the lane check's own list. One that cannot be read is recorded as
1615
+ # unreadable, never as none.
1616
+ try:
1617
+ findings = json.loads(args.findings) if (args.findings or "").strip() else []
1618
+ if not isinstance(findings, list):
1619
+ raise ValueError("not a list")
1620
+ except ValueError:
1621
+ findings = None
1622
+ high = (sum(1 for f in findings if isinstance(f, dict)
1623
+ and str(f.get("severity", "")).strip().lower() == "high")
1624
+ if findings is not None else None)
1625
+ plan = next((os.path.join(cr_dir, n) for n in DELTA_PLAN_NAMES[lane]
1626
+ if os.path.isfile(os.path.join(cr_dir, n))), "")
1627
+ rec = {
1628
+ "lane": lane,
1629
+ "cr_id": args.cr_id or os.path.basename(os.path.normpath(cr_dir)),
1630
+ "gated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
1631
+ "verdict": "pass" if tests_green and requirements_ok else "fail",
1632
+ "tests_green": tests_green,
1633
+ "tests_ran": truthy(args.tests_ran),
1634
+ "requirements_ok": requirements_ok,
1635
+ "findings": len(findings) if findings is not None else None,
1636
+ "high_findings": high,
1637
+ "delta_spec_sha": _file_sha(os.path.join(cr_dir, "delta-spec.md")),
1638
+ "delta_plan": os.path.basename(plan),
1639
+ "delta_plan_sha": _file_sha(plan) if plan else "",
1640
+ "note": SLICE_GATE_NOTE,
1641
+ }
1642
+ path = os.path.join(cr_dir, SLICE_GATE % lane)
1643
+ with open(path, "w", encoding="utf-8") as fh:
1644
+ fh.write(json.dumps(rec, indent=2) + "\n")
1645
+ emit({
1646
+ "ok": True,
1647
+ "file": os.path.relpath(path, repo).replace("\\", "/"),
1648
+ "lane": lane,
1649
+ "cr_id": rec["cr_id"],
1650
+ "verdict": rec["verdict"],
1651
+ "tests_green": tests_green,
1652
+ "tests_ran": rec["tests_ran"],
1653
+ "requirements_ok": requirements_ok,
1654
+ "findings": rec["findings"],
1655
+ "high_findings": high,
1656
+ "gated": rec["gated"],
1657
+ })
1229
1658
 
1230
1659
 
1231
1660
  def cmd_resolve(args):
@@ -1248,6 +1677,7 @@ def cmd_resolve(args):
1248
1677
  }
1249
1678
  payload.update(kind_block(args.repo, [], "backend"))
1250
1679
  payload.update(kind_block(args.repo, [], "frontend"))
1680
+ apply_working(payload, args.repo, [])
1251
1681
  apply_feature_lanes(payload, fl)
1252
1682
  emit(payload)
1253
1683
  return
@@ -1293,6 +1723,7 @@ def cmd_resolve(args):
1293
1723
  }
1294
1724
  payload.update(kind_block(args.repo, lanes, "backend"))
1295
1725
  payload.update(kind_block(args.repo, lanes, "frontend"))
1726
+ apply_working(payload, args.repo, lanes)
1296
1727
  apply_feature_lanes(payload, fl)
1297
1728
  emit(payload)
1298
1729
 
@@ -1317,6 +1748,12 @@ def cmd_resolve(args):
1317
1748
  # select-stack declares `src/` before any code exists, and the path becomes real
1318
1749
  # when implement writes into it. Every phase that READS the subtree — implement,
1319
1750
  # build, run, onboard — leaves the check on.
1751
+ #
1752
+ # --working-only narrows the on-disk half to the lanes this developer works on (and any
1753
+ # kind the caller requires). A backend developer's build must not stop because the
1754
+ # frontend lane — declared, and somebody else's — has not been scaffolded yet. A lane with
1755
+ # no root at all, and two lanes overlapping, are still checked for every lane: those are
1756
+ # declaration errors, not a lane that is merely somebody else's work in progress.
1320
1757
 
1321
1758
  # Named in the "no <kind> lane is declared, and %s needs one" message, for a caller that
1322
1759
  # passed --require but no --command of its own. `implement` covers both lanes in one
@@ -1333,6 +1770,7 @@ def cmd_verify(args):
1333
1770
  data = read_stacks(repo)
1334
1771
  lanes = (data or {}).get("lanes") or []
1335
1772
  required = [k for k in (args.require or []) if k in ("backend", "frontend")]
1773
+ worked = working_lane(repo, lanes)["kinds"]
1336
1774
  issues = []
1337
1775
 
1338
1776
  # An empty file and an absent one are the SAME state. The kit ships
@@ -1364,7 +1802,8 @@ def cmd_verify(args):
1364
1802
  "subtree; without one nothing can be built, run or written into it. "
1365
1803
  "Re-run `/appweaver select-stack` and give it a lane root folder."
1366
1804
  % (lane_id, kind, lane.get("profile", "?")))
1367
- elif not args.allow_missing_root and not root_on_disk(repo, root):
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)):
1368
1807
  issues.append(
1369
1808
  "lane '%s' (%s, stack %s) declares lane root folder '%s', which is not a "
1370
1809
  "directory in this repository. Re-run `/appweaver select-stack` and "
@@ -1398,6 +1837,7 @@ def cmd_verify(args):
1398
1837
  "lane_count": len(lanes),
1399
1838
  "kinds": sorted({l.get("kind", "") for l in lanes if l.get("kind")}),
1400
1839
  "root_check": not args.allow_missing_root,
1840
+ "root_check_working_only": bool(args.working_only),
1401
1841
  "issues": issues,
1402
1842
  "issue_count": len(issues),
1403
1843
  # No lanes always carries an issue now, so a clean run always has lanes to name.
@@ -1412,6 +1852,7 @@ def cmd_verify(args):
1412
1852
  }
1413
1853
  payload.update(kind_block(repo, lanes, "backend"))
1414
1854
  payload.update(kind_block(repo, lanes, "frontend"))
1855
+ apply_working(payload, repo, lanes)
1415
1856
  emit(payload)
1416
1857
  # Exit 0 even when the check fails: the workflow gate owns the stop, so the
1417
1858
  # message and every flattened field still reach it. A non-zero exit here would
@@ -1486,7 +1927,7 @@ def cmd_run_phase(args):
1486
1927
  testlog = _testlog()
1487
1928
  ok, ran = True, False
1488
1929
  for lane in lanes:
1489
- cmd = lane_command(lane, args.phase, repo)
1930
+ cmd = lane_command(lane, args.phase, repo, getattr(args, "contract", "") or "")
1490
1931
  lane_id, root = lane.get("id", "?"), lane.get("root", "")
1491
1932
  if not cmd:
1492
1933
  results.append({"lane": lane_id, "root": root, "cmd": "",
@@ -1779,6 +2220,716 @@ def cmd_contract_export(args):
1779
2220
  unnamed=sum(1 for o in ops if not o["id"]), reason=note, log_tail="")
1780
2221
 
1781
2222
 
2223
+ # ─────────────────────────── contract-draft ────────────────────────────────
2224
+ # A feature's API document as its backend plan DESCRIBES it, for a frontend that binds
2225
+ # before the service is built. Read by script from the plan's API section, so it says
2226
+ # exactly what the plan says and is redrawn whenever plan or analyze-plan writes the plan.
2227
+ #
2228
+ # What it reads, in the plan template's own shapes:
2229
+ # ### `VERB /route` — purpose · Serves … one endpoint per heading
2230
+ # - **Operation id**: `Name`
2231
+ # - **Request**: `params` + body `{ field: type, … }`
2232
+ # - **Success**: `201` → `{ field: type, … }`
2233
+ # - **Errors** table: | Status | When | Body shape |
2234
+ # - **Maps to**: `UseCase` (§2.2), whose Request / Response (DTO) stands in for a shape
2235
+ # the endpoint names instead of spelling out
2236
+ # Conceptual types become JSON schema: string, int, decimal, money, bool, date, datetime,
2237
+ # id<T>, enum(A|B), list<T>, map<K,V> and nested `{ … }`. A shape it cannot read is kept as
2238
+ # an untyped schema carrying the plan's text, and counted as unread.
2239
+
2240
+ _ENDPOINT = re.compile(r"^(#{2,6})\s+`?\s*(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(/[^\s`]*)\s*`?\s*(.*)$",
2241
+ re.I)
2242
+ _HEADING = re.compile(r"^#{1,6}\s+(.*)$")
2243
+ _FIELD = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(\?)?\s*:\s*(.+?)\s*$", re.S)
2244
+ _RQ_ID = re.compile(r"\bRQ-\d+\b")
2245
+
2246
+ _BASE_TYPES = {
2247
+ "string": {"type": "string"}, "str": {"type": "string"}, "text": {"type": "string"},
2248
+ "int": {"type": "integer"}, "integer": {"type": "integer"}, "long": {"type": "integer"},
2249
+ "int32": {"type": "integer", "format": "int32"}, "int64": {"type": "integer", "format": "int64"},
2250
+ "short": {"type": "integer"},
2251
+ "decimal": {"type": "number"}, "number": {"type": "number"}, "float": {"type": "number"},
2252
+ "double": {"type": "number"}, "money": {"type": "number", "description": "money"},
2253
+ "amount": {"type": "number"},
2254
+ "bool": {"type": "boolean"}, "boolean": {"type": "boolean"},
2255
+ "date": {"type": "string", "format": "date"},
2256
+ "datetime": {"type": "string", "format": "date-time"},
2257
+ "date-time": {"type": "string", "format": "date-time"},
2258
+ "timestamp": {"type": "string", "format": "date-time"},
2259
+ "instant": {"type": "string", "format": "date-time"},
2260
+ "datetimeoffset": {"type": "string", "format": "date-time"},
2261
+ "time": {"type": "string", "description": "time of day"},
2262
+ "guid": {"type": "string", "format": "uuid"}, "uuid": {"type": "string", "format": "uuid"},
2263
+ "id": {"type": "string"}, "email": {"type": "string", "format": "email"},
2264
+ "url": {"type": "string", "format": "uri"}, "uri": {"type": "string", "format": "uri"},
2265
+ "object": {"type": "object"}, "json": {}, "any": {},
2266
+ }
2267
+ _NO_SHAPE = {"", "none", "—", "-", "–", "n/a", "no content", "nothing", "empty", "no body"}
2268
+
2269
+
2270
+ def _strip_comments(text):
2271
+ return re.sub(r"<!--.*?-->", "", text or "", flags=re.S)
2272
+
2273
+
2274
+ def api_section(text):
2275
+ """The text of a backend plan's API section — from the `## ` heading that names the API
2276
+ to the next `## ` heading — or None when the plan has none (the brownfield layout groups
2277
+ its endpoints under its own axes)."""
2278
+ lines = (text or "").splitlines()
2279
+ start = None
2280
+ for n, line in enumerate(lines):
2281
+ m = re.match(r"^##\s+(.*)$", line)
2282
+ if not m:
2283
+ continue
2284
+ if start is not None:
2285
+ return "\n".join(lines[start:n])
2286
+ if re.search(r"\bAPI\b", m.group(1)):
2287
+ start = n
2288
+ return "\n".join(lines[start:]) if start is not None else None
2289
+
2290
+
2291
+ def _split_top(s, seps=","):
2292
+ """Split at the separators that are not inside <>, {}, () or []."""
2293
+ out, cur, depth = [], [], 0
2294
+ for ch in s:
2295
+ if ch in "<{([":
2296
+ depth += 1
2297
+ elif ch in ">})]":
2298
+ depth = max(0, depth - 1)
2299
+ if ch in seps and depth == 0:
2300
+ out.append("".join(cur))
2301
+ cur = []
2302
+ else:
2303
+ cur.append(ch)
2304
+ out.append("".join(cur))
2305
+ return [x.strip() for x in out if x.strip()]
2306
+
2307
+
2308
+ def _balanced(s, start):
2309
+ """The `{ … }` that opens at s[start], or "" when it never closes."""
2310
+ depth = 0
2311
+ for n in range(start, len(s)):
2312
+ if s[n] == "{":
2313
+ depth += 1
2314
+ elif s[n] == "}":
2315
+ depth -= 1
2316
+ if depth == 0:
2317
+ return s[start:n + 1]
2318
+ return ""
2319
+
2320
+
2321
+ def _is_placeholder(s):
2322
+ s = (s or "").strip().strip("`").strip()
2323
+ return s.startswith("[") or s in ("…", "...")
2324
+
2325
+
2326
+ class _Drafter(object):
2327
+ """One backend plan's endpoints, as OpenAPI operations."""
2328
+
2329
+ def __init__(self, text):
2330
+ self.text = _strip_comments(text)
2331
+ self.unread = []
2332
+ self.dtos = self._dto_index()
2333
+
2334
+ def _dto_index(self):
2335
+ """Request / Response (DTO) shapes by use-case name ("Name:request"), and by the
2336
+ type name a DTO is given when it is written `Name { … }`."""
2337
+ dtos, current = {}, ""
2338
+ for line in self.text.splitlines():
2339
+ h = _HEADING.match(line)
2340
+ if h:
2341
+ ticks = re.findall(r"`([^`]+)`", h.group(1))
2342
+ current = ticks[0].strip() if ticks else ""
2343
+ continue
2344
+ m = re.search(r"\*\*(Request|Response)\s*\(DTO\)\*\*\s*:\s*(.+)$", line)
2345
+ if not m or not current:
2346
+ continue
2347
+ value = m.group(2)
2348
+ brace = value.find("{")
2349
+ if brace < 0:
2350
+ continue
2351
+ obj = _balanced(value, brace)
2352
+ if not obj:
2353
+ continue
2354
+ dtos["%s:%s" % (current, m.group(1).lower())] = obj
2355
+ named = re.search(r"([A-Za-z_][A-Za-z0-9_]*)\s*$", value[:brace].replace("`", ""))
2356
+ if named:
2357
+ dtos[named.group(1)] = obj
2358
+ return dtos
2359
+
2360
+ def schema(self, text):
2361
+ raw = (text or "").strip().strip("`").strip().rstrip(".,;").strip()
2362
+ s = raw[:-1].strip() if raw.endswith("?") else raw
2363
+ if not s or s in ("…", "..."):
2364
+ return {}
2365
+ if s.startswith("{") and s.endswith("}"):
2366
+ return self.object_schema(s[1:-1])
2367
+ m = re.match(r"^(list|set|array|collection|seq|sequence|ienumerable|ilist|ireadonlylist)"
2368
+ r"\s*[<(\[]\s*(.+?)\s*[>)\]]$", s, re.I)
2369
+ if m:
2370
+ return {"type": "array", "items": self.schema(m.group(2))}
2371
+ if s.endswith("[]"):
2372
+ return {"type": "array", "items": self.schema(s[:-2])}
2373
+ m = re.match(r"^(map|dict|dictionary|record)\s*<\s*(.+)\s*>$", s, re.I)
2374
+ if m:
2375
+ parts = _split_top(m.group(2))
2376
+ return {"type": "object",
2377
+ "additionalProperties": self.schema(parts[-1]) if parts else {}}
2378
+ m = re.match(r"^id\s*[<(]\s*(.+?)\s*[>)]$", s, re.I)
2379
+ if m:
2380
+ return {"type": "string", "description": "id of %s" % m.group(1)}
2381
+ m = re.match(r"^enum\s*[<({\[]\s*(.+?)\s*[>)}\]]$", s, re.I)
2382
+ if m:
2383
+ vals = [v.strip().strip("'\"`") for v in re.split(r"[|,]", m.group(1)) if v.strip()]
2384
+ return {"type": "string", "enum": vals}
2385
+ if "|" in s:
2386
+ vals = [v.strip().strip("'\"") for v in s.split("|") if v.strip()]
2387
+ typed = [v for v in vals if v.lower() not in ("null", "none")]
2388
+ if typed and all(v.lower() in _BASE_TYPES for v in typed):
2389
+ return dict(_BASE_TYPES[typed[0].lower()])
2390
+ if len(vals) > 1 and all(re.match(r"^[A-Za-z][\w-]*$", v) for v in vals):
2391
+ return {"type": "string", "enum": vals}
2392
+ base = _BASE_TYPES.get(s.lower())
2393
+ if base is not None:
2394
+ return dict(base)
2395
+ if s in self.dtos:
2396
+ return self.schema(self.dtos[s])
2397
+ # A named type the plan does not spell out, or prose — kept, never guessed.
2398
+ self.unread.append(raw)
2399
+ return {"description": raw}
2400
+
2401
+ def object_schema(self, body):
2402
+ props, required = {}, []
2403
+ for item in _split_top(body, ",;"):
2404
+ if _is_placeholder(item):
2405
+ continue
2406
+ m = _FIELD.match(item)
2407
+ if not m:
2408
+ self.unread.append(item)
2409
+ continue
2410
+ name, q, typ = m.groups()
2411
+ optional = bool(q) or typ.rstrip().endswith("?") or "(optional)" in typ.lower()
2412
+ typ = re.sub(r"\(optional\)", "", typ, flags=re.I).strip()
2413
+ props[name] = self.schema(typ)
2414
+ if not optional:
2415
+ required.append(name)
2416
+ out = {"type": "object", "properties": props}
2417
+ if required:
2418
+ out["required"] = required
2419
+ return out
2420
+
2421
+ def shape(self, text, fallback=""):
2422
+ """The schema a Request body or a Success response states: the first `{ … }`, else
2423
+ the first `code` span (a type, a list<…>, or a DTO named elsewhere). None when the
2424
+ text says there is no body; the use case's DTO (`fallback`) when it says nothing."""
2425
+ t = (text or "").strip()
2426
+ if not t:
2427
+ return self.object_schema(fallback[1:-1]) if fallback else None
2428
+ spans = [x.strip() for x in re.findall(r"`([^`]+)`", t) if not _is_placeholder(x)]
2429
+ first = spans[0] if spans else ""
2430
+ # `list<{ … }>` written in one span keeps its wrapper.
2431
+ if re.match(r"^(list|set|array|collection|seq|sequence|map|dict|dictionary|record)\s*<",
2432
+ first, re.I):
2433
+ return self.schema(first)
2434
+ brace = t.find("{")
2435
+ if brace >= 0:
2436
+ obj = _balanced(t, brace)
2437
+ if obj:
2438
+ return self.object_schema(obj[1:-1])
2439
+ word = (first or t).strip().strip(".")
2440
+ if word.lower() in _NO_SHAPE or re.fullmatch(r"[1-5]\d\d", word):
2441
+ return None
2442
+ if fallback and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", word) and word not in self.dtos \
2443
+ and word.lower() not in _BASE_TYPES:
2444
+ return self.object_schema(fallback[1:-1])
2445
+ return self.schema(word)
2446
+
2447
+ def operations(self, plan_rel):
2448
+ """[(path, method, operation)] in plan order."""
2449
+ section = api_section(self.text)
2450
+ lines = (section if section is not None else self.text).splitlines()
2451
+ blocks, cur = [], None
2452
+ for line in lines:
2453
+ m = _ENDPOINT.match(line)
2454
+ if m:
2455
+ cur = {"method": m.group(2).lower(), "path": m.group(3), "title": m.group(4),
2456
+ "lines": []}
2457
+ blocks.append(cur)
2458
+ continue
2459
+ if _HEADING.match(line):
2460
+ cur = None
2461
+ continue
2462
+ if cur is not None:
2463
+ cur["lines"].append(line)
2464
+ return [self.operation(b, plan_rel) for b in blocks]
2465
+
2466
+ def operation(self, b, plan_rel):
2467
+ path = re.sub(r":([A-Za-z_]\w*)", r"{\1}", b["path"].split("?")[0].rstrip(".,;"))
2468
+ if len(path) > 1:
2469
+ path = path.rstrip("/")
2470
+ method = b["method"]
2471
+ title = re.sub(r"^[\s—–:-]+", "", b["title"]).strip()
2472
+ summary = re.split(r"\s+·\s+|\s+\|\s+", title)[0].strip()
2473
+ fields, errors = {}, []
2474
+ for line in b["lines"]:
2475
+ m = re.match(r"^\s*[-*]\s+\*\*([^*]+)\*\*\s*(?:\([^)]*\))?\s*:?\s*(.*)$", line)
2476
+ if m:
2477
+ fields[m.group(1).strip().rstrip(":").strip().lower()] = m.group(2).strip()
2478
+ continue
2479
+ cells = _cells(line)
2480
+ if cells and re.fullmatch(r"[1-5]\d\d", cells[0].strip("` ")):
2481
+ errors.append((cells[0].strip("` "), cells[1] if len(cells) > 1 else ""))
2482
+ oid = ""
2483
+ for key in ("operation id", "operationid", "operation"):
2484
+ if key in fields:
2485
+ ids = _op_ids(fields[key])
2486
+ oid = ids[0] if ids else ""
2487
+ break
2488
+ maps = ""
2489
+ if "maps to" in fields:
2490
+ ticks = re.findall(r"`([^`]+)`", fields["maps to"])
2491
+ maps = ticks[0].strip() if ticks else fields["maps to"].split()[0] if fields["maps to"] else ""
2492
+ req_dto = self.dtos.get("%s:request" % maps, "")
2493
+ res_dto = self.dtos.get("%s:response" % maps, "")
2494
+
2495
+ op = {}
2496
+ if oid:
2497
+ op["operationId"] = oid
2498
+ if summary:
2499
+ op["summary"] = summary
2500
+ op["description"] = ("Drafted from %s — what the backend plan says this endpoint will "
2501
+ "serve, not what the service serves." % plan_rel)
2502
+ op["x-aw-draft"] = True
2503
+
2504
+ path_names = re.findall(r"\{([^}]+)\}", path)
2505
+ typed, body = {}, None
2506
+ request = fields.get("request", "")
2507
+ if request:
2508
+ m = re.search(r"\bbody\b", request, re.I)
2509
+ head = request[:m.start()] if m else request
2510
+ if m:
2511
+ body = self.shape(request[m.end():], req_dto)
2512
+ elif method in ("post", "put", "patch") and "{" in request:
2513
+ body = self.shape(request, req_dto)
2514
+ for span in re.findall(r"`([^`]+)`", head):
2515
+ if _is_placeholder(span) or "{" in span:
2516
+ continue
2517
+ for item in _split_top(span.strip().lstrip("?"), ",;&"):
2518
+ f = _FIELD.match(item)
2519
+ if f:
2520
+ typed[f.group(1)] = (self.schema(f.group(3)), bool(f.group(2)))
2521
+ elif re.fullmatch(r"[A-Za-z_]\w*", item) and item.lower() not in _NO_SHAPE:
2522
+ typed.setdefault(item, ({"type": "string"}, True))
2523
+ elif method in ("post", "put", "patch") and req_dto:
2524
+ body = self.object_schema(req_dto[1:-1])
2525
+ params = []
2526
+ for name in path_names:
2527
+ sch = typed.get(name, ({"type": "string"}, False))[0]
2528
+ params.append({"name": name, "in": "path", "required": True, "schema": sch})
2529
+ for name, (sch, _opt) in typed.items():
2530
+ if name not in path_names:
2531
+ params.append({"name": name, "in": "query", "required": False, "schema": sch})
2532
+ if params:
2533
+ op["parameters"] = params
2534
+ if body is not None:
2535
+ op["requestBody"] = {"required": True,
2536
+ "content": {"application/json": {"schema": body}}}
2537
+
2538
+ success = fields.get("success", "")
2539
+ status = re.search(r"\b([1-5]\d\d)\b", success)
2540
+ code = status.group(1) if status else "200"
2541
+ after = re.split(r"→|->", success, maxsplit=1)
2542
+ result = self.shape(after[1] if len(after) > 1 else "", res_dto) if success \
2543
+ else (self.object_schema(res_dto[1:-1]) if res_dto else None)
2544
+ responses = {code: {"description": "Success"}}
2545
+ if result is not None and code != "204":
2546
+ responses[code]["content"] = {"application/json": {"schema": result}}
2547
+ for err, when in errors:
2548
+ if err not in responses:
2549
+ responses[err] = {"description": re.sub(r"\s+", " ", when).strip() or "Error"}
2550
+ op["responses"] = responses
2551
+ return path, method, op
2552
+
2553
+
2554
+ def _sha_text(text):
2555
+ import hashlib
2556
+ return hashlib.sha256((text or "").encode("utf-8")).hexdigest()
2557
+
2558
+
2559
+ def _first_file(folder, names):
2560
+ for name in names:
2561
+ p = os.path.join(folder, name)
2562
+ if os.path.isfile(p):
2563
+ return p
2564
+ return ""
2565
+
2566
+
2567
+ def _read(path):
2568
+ try:
2569
+ with open(path, encoding="utf-8", errors="replace") as fh:
2570
+ return fh.read()
2571
+ except OSError:
2572
+ return ""
2573
+
2574
+
2575
+ def _is_draft_file(path):
2576
+ try:
2577
+ with open(path, encoding="utf-8") as fh:
2578
+ return "x-aw-draft" in (json.load(fh) or {})
2579
+ except Exception:
2580
+ return False
2581
+
2582
+
2583
+ def _draft_ids(doc):
2584
+ """The operation ids a document carries only as draft operations."""
2585
+ out = set()
2586
+ for item in (doc.get("paths") or {}).values():
2587
+ if isinstance(item, dict):
2588
+ for method in CONTRACT_HTTP_METHODS:
2589
+ op = item.get(method)
2590
+ if isinstance(op, dict) and op.get("x-aw-draft") and op.get("operationId"):
2591
+ out.add(str(op["operationId"]))
2592
+ return out
2593
+
2594
+
2595
+ def cmd_contract_draft(args):
2596
+ repo = args.repo
2597
+ fd = args.feature_dir if os.path.isabs(args.feature_dir) else os.path.join(repo, args.feature_dir)
2598
+ rel = lambda p: os.path.relpath(p, repo).replace("\\", "/") if p else ""
2599
+ draft = os.path.join(fd, DRAFT_NAME)
2600
+ # No feature named means no plan, never the repository root's.
2601
+ plan = _first_file(fd, BACKEND_PLAN_NAMES) if args.feature_dir else ""
2602
+ out = {"ok": True, "written": False, "removed": False, "path": rel(draft), "plan": rel(plan),
2603
+ "operations": 0, "ids": [], "ids_csv": "", "unnamed": 0, "unread": [],
2604
+ "unread_count": 0, "duplicates": [], "reason": ""}
2605
+ if not plan:
2606
+ out["reason"] = "no backend plan in %s — nothing to draft" % rel(fd)
2607
+ return emit(out)
2608
+ text = _read(plan)
2609
+ drafter = _Drafter(text)
2610
+ paths, ids, dupes, seen = {}, [], [], set()
2611
+ for path, method, op in drafter.operations(rel(plan)):
2612
+ if (path, method) in seen:
2613
+ dupes.append("%s %s" % (method.upper(), path))
2614
+ continue
2615
+ seen.add((path, method))
2616
+ paths.setdefault(path, {})[method] = op
2617
+ if op.get("operationId"):
2618
+ ids.append(op["operationId"])
2619
+ count = sum(len(v) for v in paths.values())
2620
+ out.update(operations=count, ids=ids, ids_csv=", ".join(ids),
2621
+ unnamed=count - len(ids), unread=drafter.unread[:20],
2622
+ unread_count=len(drafter.unread), duplicates=dupes)
2623
+ if not count:
2624
+ # A draft this script wrote earlier would now describe endpoints the plan dropped.
2625
+ if os.path.isfile(draft) and _is_draft_file(draft):
2626
+ os.remove(draft)
2627
+ out["removed"] = True
2628
+ out["reason"] = ("%s names no endpoint — no heading such as ### `POST /route` in its "
2629
+ "API section — so there is nothing to draft" % rel(plan))
2630
+ return emit(out)
2631
+ section = api_section(text)
2632
+ doc = {
2633
+ "openapi": "3.0.3",
2634
+ "info": {
2635
+ "title": "%s — API draft" % os.path.basename(os.path.normpath(fd)),
2636
+ "version": "draft",
2637
+ "description": ("Drafted by script from %s, its API section — what the backend "
2638
+ "plan says the service will serve, not what it serves. The "
2639
+ "service's exported document replaces each operation once it "
2640
+ "serves its id. Rewritten by plan and analyze-plan; do not edit."
2641
+ % rel(plan)),
2642
+ },
2643
+ "x-aw-draft": {"source": rel(plan),
2644
+ "source_sha": _sha_text(section if section is not None else text)},
2645
+ "paths": paths,
2646
+ }
2647
+ body = json.dumps(doc, indent=2) + "\n"
2648
+ if _read(draft) != body:
2649
+ with open(draft, "w", encoding="utf-8") as fh:
2650
+ fh.write(body)
2651
+ out["written"] = True
2652
+ emit(out)
2653
+
2654
+
2655
+ # ─────────────────────────── contract-combine ──────────────────────────────
2656
+ # What a frontend generates its client from: the service's exported document, plus every
2657
+ # operation a backend plan has drafted that the document does not serve yet. A draft whose
2658
+ # id the service serves is dropped — the export is the truth for it — and one whose route
2659
+ # and verb the service already answers under another id is left out and reported, since
2660
+ # adding it would describe one endpoint twice.
2661
+
2662
+ def _draft_files(repo):
2663
+ base = os.path.join(repo, "specs")
2664
+ found = glob.glob(os.path.join(base, "*", DRAFT_NAME)) + \
2665
+ glob.glob(os.path.join(base, "*", "*", DRAFT_NAME))
2666
+ return sorted(set(found))
2667
+
2668
+
2669
+ def _route_key(path, method):
2670
+ norm = re.sub(r"\{[^}]*\}", "{}", path.rstrip("/") or "/").lower()
2671
+ return norm, method.upper()
2672
+
2673
+
2674
+ def cmd_contract_combine(args):
2675
+ import copy
2676
+ repo = args.repo
2677
+ rel = lambda p: os.path.relpath(p, repo).replace("\\", "/")
2678
+ export_rel = contract_doc(repo)
2679
+ export_abs = os.path.join(repo, export_rel) if export_rel else ""
2680
+ export = None
2681
+ if export_abs and os.path.isfile(export_abs):
2682
+ with open(export_abs, "rb") as fh:
2683
+ export = _api_document(fh.read())
2684
+ out_abs = os.path.join(repo, COMBINED_DOC)
2685
+ result = {"ok": False, "doc": "", "export": export_rel,
2686
+ "export_exists": export is not None, "export_operations": 0,
2687
+ "drafts": [], "draft_files": 0, "draft_operations": 0,
2688
+ "added": 0, "added_ids": [], "added_csv": "", "served_csv": "",
2689
+ "clashes": [], "clashes_csv": "", "status": "", "reason": "",
2690
+ # The placeholder a `client` command names the document by, spelled out for
2691
+ # a prompt that asks for one (see contract-export).
2692
+ "placeholder": "{{contract}}"}
2693
+
2694
+ combined = copy.deepcopy(export) if export else {
2695
+ "openapi": "3.0.3",
2696
+ "info": {"title": "API — drafts only", "version": "draft",
2697
+ "description": ("No exported document yet: every operation here is drafted "
2698
+ "from a backend plan and none is served.")},
2699
+ "paths": {},
2700
+ }
2701
+ combined.setdefault("paths", {})
2702
+ export_ops = _operations(export) if export else []
2703
+ export_ids = {o["id"] for o in export_ops if o["id"]}
2704
+ routes = {_route_key(o["path"], o["method"]) for o in export_ops}
2705
+ added_ids = set()
2706
+ added, served, clashes = [], [], []
2707
+ drafts = _draft_files(repo)
2708
+ for f in drafts:
2709
+ try:
2710
+ with open(f, encoding="utf-8") as fh:
2711
+ doc = json.load(fh)
2712
+ except Exception:
2713
+ continue
2714
+ if not isinstance(doc, dict) or not isinstance(doc.get("paths"), dict):
2715
+ continue
2716
+ result["drafts"].append(rel(f))
2717
+ for path, item in doc["paths"].items():
2718
+ if not isinstance(item, dict):
2719
+ continue
2720
+ for method in CONTRACT_HTTP_METHODS:
2721
+ op = item.get(method)
2722
+ if not isinstance(op, dict):
2723
+ continue
2724
+ result["draft_operations"] += 1
2725
+ oid = str(op.get("operationId") or "")
2726
+ if oid and oid in export_ids:
2727
+ served.append(oid)
2728
+ continue
2729
+ if oid and oid in added_ids:
2730
+ clashes.append("%s drafted twice (%s)" % (oid, rel(f)))
2731
+ continue
2732
+ key = _route_key(path, method)
2733
+ if key in routes:
2734
+ clashes.append("%s %s (%s) — the service already answers it under another id"
2735
+ % (method.upper(), path, oid or "no id"))
2736
+ continue
2737
+ new = copy.deepcopy(op)
2738
+ new["x-aw-draft"] = True
2739
+ new["x-aw-draft-source"] = rel(f)
2740
+ combined["paths"].setdefault(path, {})[method] = new
2741
+ routes.add(key)
2742
+ if oid:
2743
+ added_ids.add(oid)
2744
+ added.append(oid or "%s %s" % (method.upper(), path))
2745
+ result.update(draft_files=len(result["drafts"]), export_operations=len(export_ops),
2746
+ added=len(added), added_ids=added, added_csv=", ".join(added),
2747
+ served_csv=", ".join(served), clashes=clashes, clashes_csv="; ".join(clashes))
2748
+
2749
+ if export is None and not added:
2750
+ # Nothing to read: a combined document left from an earlier run would be stale.
2751
+ if os.path.isfile(out_abs):
2752
+ os.remove(out_abs)
2753
+ result["reason"] = ("the service has not published %s and no backend plan has a draft "
2754
+ "(%s) — there is no API document to bind to"
2755
+ % (export_rel or "an API document (no service lane)", DRAFT_NAME))
2756
+ return emit(result)
2757
+
2758
+ os.makedirs(os.path.dirname(out_abs), exist_ok=True)
2759
+ with open(out_abs, "w", encoding="utf-8") as fh:
2760
+ json.dump(combined, fh, indent=2)
2761
+ fh.write("\n")
2762
+ if export is not None and added:
2763
+ status = ("%s — the service's exported document (%s, %d operation(s)) plus %d "
2764
+ "operation(s) drafted from the backend plans that it does not serve yet, "
2765
+ "marked x-aw-draft: %s" % (COMBINED_DOC, export_rel, len(export_ops),
2766
+ len(added), ", ".join(added)))
2767
+ elif export is not None:
2768
+ status = ("%s — the service's exported document (%s, %d operation(s)); no drafted "
2769
+ "operation is missing from it" % (COMBINED_DOC, export_rel, len(export_ops)))
2770
+ else:
2771
+ status = ("%s — %d operation(s) drafted from the backend plans, marked x-aw-draft; the "
2772
+ "service has not published %s yet, so none of them is served: %s"
2773
+ % (COMBINED_DOC, len(added), export_rel or "an API document", ", ".join(added)))
2774
+ result.update(ok=True, doc=COMBINED_DOC, status=status)
2775
+ emit(result)
2776
+
2777
+
2778
+ # ─────────────────────────── contract-requests ─────────────────────────────
2779
+ # The request ledger between the lanes, read from both plans:
2780
+ # frontend plan §5 "Contract deltas needed" — | Request | Operation id | Needed by |
2781
+ # Sends | Receives | Serves |, one RQ-n per row, the operation id it proposes
2782
+ # backend plan's API section "Requests from the frontend lane" — | Request | Outcome |
2783
+ # Operation id | Why |, `published` with the id that serves it, or `declined` with why
2784
+ # A request is OPEN until the backend answers it — and still open when the answer names an
2785
+ # id the backend plan has no endpoint for. A PUBLISHED request whose id no frontend binding
2786
+ # row cites yet is to RE-BIND. A DECLINED one goes back to the frontend plan.
2787
+
2788
+ def _tables(text):
2789
+ """Every markdown table: (lowered header cells, [row cells])."""
2790
+ lines = _strip_comments(text).splitlines()
2791
+ out, i = [], 0
2792
+ while i < len(lines):
2793
+ header = _cells(lines[i])
2794
+ rule = _cells(lines[i + 1]) if i + 1 < len(lines) else None
2795
+ if header is None or rule is None or not _is_rule(rule):
2796
+ i += 1
2797
+ continue
2798
+ rows = []
2799
+ i += 2
2800
+ while i < len(lines) and _cells(lines[i]) is not None:
2801
+ rows.append(_cells(lines[i]))
2802
+ i += 1
2803
+ out.append(([h.lower() for h in header], rows))
2804
+ return out
2805
+
2806
+
2807
+ def _col(header, *prefixes):
2808
+ for n, h in enumerate(header):
2809
+ if any(h.replace("-", " ").startswith(p) for p in prefixes):
2810
+ return n
2811
+ return -1
2812
+
2813
+
2814
+ def frontend_requests(text):
2815
+ reqs, seen = [], set()
2816
+ for header, rows in _tables(text):
2817
+ rc = _col(header, "request")
2818
+ if rc < 0 or _col(header, "outcome") >= 0:
2819
+ continue
2820
+ oc = _col(header, "operation id")
2821
+ cols = {k: _col(header, k) for k in ("needed by", "sends", "receives", "serves")}
2822
+ for row in rows:
2823
+ m = _RQ_ID.search(row[rc]) if rc < len(row) else None
2824
+ if not m or m.group(0) in seen:
2825
+ continue
2826
+ seen.add(m.group(0))
2827
+ ids = _op_ids(row[oc]) if 0 <= oc < len(row) else []
2828
+ cell = lambda k: row[cols[k]].strip() if 0 <= cols[k] < len(row) else ""
2829
+ reqs.append({"id": m.group(0), "proposed": ids[0] if ids else "",
2830
+ "needed_by": cell("needed by"), "sends": cell("sends"),
2831
+ "receives": cell("receives"), "serves": cell("serves")})
2832
+ return reqs
2833
+
2834
+
2835
+ def backend_answers(text):
2836
+ answers = {}
2837
+ for header, rows in _tables(text):
2838
+ rc, oc = _col(header, "request"), _col(header, "outcome")
2839
+ if rc < 0 or oc < 0:
2840
+ continue
2841
+ ic, wc = _col(header, "operation id"), _col(header, "why")
2842
+ for row in rows:
2843
+ m = _RQ_ID.search(row[rc]) if rc < len(row) else None
2844
+ if not m:
2845
+ continue
2846
+ outcome = row[oc] if oc < len(row) else ""
2847
+ ids = _op_ids(row[ic]) if 0 <= ic < len(row) else []
2848
+ answers[m.group(0)] = {
2849
+ "outcome": ("declined" if re.search(r"declin", outcome, re.I)
2850
+ else "published" if re.search(r"publish", outcome, re.I) else ""),
2851
+ "id": ids[0] if ids else "",
2852
+ "why": row[wc].strip() if 0 <= wc < len(row) else "",
2853
+ }
2854
+ return answers
2855
+
2856
+
2857
+ def backend_operation_ids(text):
2858
+ ids = []
2859
+ for value in re.findall(r"\*\*Operation id\*\*\s*:\s*(.+)", _strip_comments(text)):
2860
+ for x in _op_ids(value)[:1]:
2861
+ if x not in ids:
2862
+ ids.append(x)
2863
+ return ids
2864
+
2865
+
2866
+ def cmd_contract_requests(args):
2867
+ repo = args.repo
2868
+ fd = args.feature_dir if os.path.isabs(args.feature_dir) else os.path.join(repo, args.feature_dir)
2869
+ fe_plan = _first_file(fd, FRONTEND_PLAN_NAMES) if args.feature_dir else ""
2870
+ be_plan = _first_file(fd, BACKEND_PLAN_NAMES) if args.feature_dir else ""
2871
+ fe_text, be_text = _read(fe_plan), _read(be_plan)
2872
+ reqs = frontend_requests(fe_text) if fe_plan else []
2873
+ answers = backend_answers(be_text) if be_plan else {}
2874
+ be_ids = set(backend_operation_ids(be_text)) if be_plan else set()
2875
+ bound = set(plan_operation_ids(fe_text)[0]) if fe_plan else set()
2876
+
2877
+ open_, declined, published, rebind = [], [], [], []
2878
+ be_lines, fe_lines = [], []
2879
+ for r in reqs:
2880
+ a = answers.get(r["id"]) or {}
2881
+ need = "; ".join("%s %s" % (k.replace("_", " "), r[k]) for k in
2882
+ ("needed_by", "sends", "receives", "serves") if r[k])
2883
+ ask = "%s — proposed `%s`%s" % (r["id"], r["proposed"] or "—", (" (%s)" % need) if need else "")
2884
+ if not be_plan or not a.get("outcome"):
2885
+ r.update(status="open", answer="", why="" if be_plan else "no backend plan yet")
2886
+ elif a["outcome"] == "declined":
2887
+ r.update(status="declined", answer="", why=a["why"])
2888
+ else:
2889
+ served = a["id"] or r["proposed"]
2890
+ if served and served in be_ids:
2891
+ r.update(status="published", answer=served, why=a["why"])
2892
+ else:
2893
+ r.update(status="open", answer="",
2894
+ why="answered as `%s`, but the backend plan has no endpoint with that id"
2895
+ % (served or "—"))
2896
+ r["rebind"] = r["status"] == "published" and r["answer"] not in bound
2897
+ if r["status"] == "open":
2898
+ open_.append(r["id"])
2899
+ be_lines.append(ask + ((" — " + r["why"]) if r["why"] else ""))
2900
+ elif r["status"] == "declined":
2901
+ declined.append(r["id"])
2902
+ fe_lines.append("%s was DECLINED by the backend plan (%s) — plan the experience "
2903
+ "without it, or route the need to /appweaver specify"
2904
+ % (r["id"], r["why"] or "no reason given"))
2905
+ else:
2906
+ published.append(r["id"])
2907
+ if r["rebind"]:
2908
+ rebind.append(r["id"])
2909
+ fe_lines.append("%s was ANSWERED as `%s` — re-bind every binding row that needs "
2910
+ "it to that operation id" % (r["id"], r["answer"]))
2911
+ orphans = sorted(k for k in answers if k not in {r["id"] for r in reqs})
2912
+ summary = ("%d request(s): %d open, %d answered (%d still to re-bind), %d declined"
2913
+ % (len(reqs), len(open_), len(published), len(rebind), len(declined))) \
2914
+ if reqs else "no contract request"
2915
+ emit({
2916
+ "ok": True,
2917
+ "frontend_plan": os.path.relpath(fe_plan, repo).replace("\\", "/") if fe_plan else "",
2918
+ "backend_plan": os.path.relpath(be_plan, repo).replace("\\", "/") if be_plan else "",
2919
+ "requests": reqs,
2920
+ "count": len(reqs),
2921
+ "open": open_, "open_count": len(open_), "open_csv": ", ".join(open_),
2922
+ "published_count": len(published),
2923
+ "declined": declined, "declined_count": len(declined), "declined_csv": ", ".join(declined),
2924
+ "rebind": rebind, "rebind_count": len(rebind), "rebind_csv": ", ".join(rebind),
2925
+ "orphans": orphans,
2926
+ # What each lane's developer has to do, one line per request, for a prompt to quote.
2927
+ "backend_text": "; ".join(be_lines) or "none",
2928
+ "frontend_text": "; ".join(fe_lines) or "none",
2929
+ "summary": summary,
2930
+ })
2931
+
2932
+
1782
2933
  # ─────────────────────────── contract-check ────────────────────────────────
1783
2934
  # Do the operation ids a frontend plan cites exist in the exported document? Read by
1784
2935
  # script, so the answer does not depend on a model reading two documents carefully.
@@ -1787,6 +2938,8 @@ def cmd_contract_export(args):
1787
2938
  # - the master plan's §5, a table with an `Operation id` column;
1788
2939
  # - a delta plan's ADDED/MODIFIED tables, where a row whose section is `Contract
1789
2940
  # Binding` names the operation id as its element's first `code` span.
2941
+ # A table with a `Request` column is the request ledger, not a binding: its operation ids
2942
+ # are proposals, and the binding row that needs one cites it on its own.
1790
2943
 
1791
2944
  _OP_ID = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
1792
2945
  _NOT_AN_ID = {"none", "tbd", "todo", "na"}
@@ -1825,6 +2978,8 @@ def plan_operation_ids(text):
1825
2978
  lowered = [h.lower() for h in header]
1826
2979
  id_col = next((n for n, h in enumerate(lowered) if h.replace("-", " ") == "operation id"), -1)
1827
2980
  section_col = next((n for n, h in enumerate(lowered) if h.startswith("section")), -1)
2981
+ if "request" in lowered:
2982
+ id_col = section_col = -1
1828
2983
  i += 2
1829
2984
  while i < len(lines) and _cells(lines[i]) is not None:
1830
2985
  row = _cells(lines[i])
@@ -1844,6 +2999,60 @@ def plan_operation_ids(text):
1844
2999
  return cited, uncited
1845
3000
 
1846
3001
 
3002
+ # ─────────────────────────── contract-impact ───────────────────────────────
3003
+ # Does a change's SERVICE delta change or remove an operation the frontend binds to, with no
3004
+ # matching change on the frontend side? The service delta's "Contract deltas for the
3005
+ # frontend lane" table names each operation it adds, changes or removes; the frontend master
3006
+ # plan's binding rows name the ids it calls. A changed or removed id the master binds to is
3007
+ # AFFECTED, and it is ADDRESSED when the change's frontend delta plan names it anywhere. What
3008
+ # is left is reported as a warning, never a refusal: the frontend half may be planned later
3009
+ # by its own developer, or in a follow-up change.
3010
+
3011
+ def service_delta_changes(text):
3012
+ """[(operation id, 'changed' | 'removed')] from the contract table of a service delta."""
3013
+ out, seen = [], set()
3014
+ for header, rows in _tables(text):
3015
+ ic, cc = _col(header, "operation id"), _col(header, "change")
3016
+ if ic < 0 or cc < 0:
3017
+ continue
3018
+ for row in rows:
3019
+ ids = _op_ids(row[ic]) if ic < len(row) else []
3020
+ change = row[cc].lower() if cc < len(row) else ""
3021
+ kind = ("removed" if re.search(r"remov|delet|retir", change)
3022
+ else "changed" if re.search(r"chang|modif|narrow|renam", change) else "")
3023
+ if ids and kind and ids[0] not in seen:
3024
+ seen.add(ids[0])
3025
+ out.append((ids[0], kind))
3026
+ return out
3027
+
3028
+
3029
+ def cmd_contract_impact(args):
3030
+ repo = args.repo
3031
+ absolute = lambda p: (p if os.path.isabs(p) else os.path.join(repo, p)) if p else ""
3032
+ be_delta, fe_plan, fe_delta = (absolute(args.delta_backend), absolute(args.frontend_plan),
3033
+ absolute(args.delta_frontend))
3034
+ changes = service_delta_changes(_read(be_delta)) if be_delta else []
3035
+ bound = plan_operation_ids(_read(fe_plan))[0] if fe_plan and os.path.isfile(fe_plan) else []
3036
+ fe_delta_exists = bool(fe_delta) and os.path.isfile(fe_delta)
3037
+ fe_delta_text = _strip_comments(_read(fe_delta)) if fe_delta_exists else ""
3038
+ affected = [(x, k) for x, k in changes if x in bound]
3039
+ addressed = [x for x, _ in affected if re.search(r"(?<![\w.-])%s(?![\w.-])" % re.escape(x), fe_delta_text)]
3040
+ unaddressed = [(x, k) for x, k in affected if x not in addressed]
3041
+ emit({
3042
+ "ok": True,
3043
+ "changes": [{"id": x, "change": k} for x, k in changes],
3044
+ "changed_csv": ", ".join("%s (%s)" % (x, k) for x, k in changes),
3045
+ "frontend_bound": len(bound),
3046
+ "affected": [x for x, _ in affected],
3047
+ "addressed": addressed,
3048
+ "frontend_delta_exists": fe_delta_exists,
3049
+ "unaddressed": [{"id": x, "change": k} for x, k in unaddressed],
3050
+ "unaddressed_count": len(unaddressed),
3051
+ "unaddressed_csv": ", ".join("%s (%s)" % (x, k) for x, k in unaddressed),
3052
+ "warn": bool(unaddressed),
3053
+ })
3054
+
3055
+
1847
3056
  def cmd_contract_check(args):
1848
3057
  repo = args.repo
1849
3058
  plan_abs = args.plan if os.path.isabs(args.plan) else os.path.join(repo, args.plan)
@@ -1852,6 +3061,7 @@ def cmd_contract_check(args):
1852
3061
  result = {"ok": False, "plan": args.plan, "doc": doc_rel,
1853
3062
  "doc_exists": bool(doc_abs) and os.path.isfile(doc_abs),
1854
3063
  "cited": 0, "ids": [], "missing": [], "uncited": 0,
3064
+ "drafted": [], "drafted_csv": "",
1855
3065
  "doc_operations": 0, "doc_unnamed": 0, "reason": ""}
1856
3066
  if not os.path.isfile(plan_abs):
1857
3067
  result["reason"] = "the frontend plan %s is not on disk" % args.plan
@@ -1875,12 +3085,19 @@ def cmd_contract_check(args):
1875
3085
  if x not in known:
1876
3086
  near = lower.get(x.lower())
1877
3087
  missing.append(x + (" (the document has %s)" % near if near else ""))
3088
+ # A cited id the document has only as a draft operation binds, and fails at run time
3089
+ # until the service serves it.
3090
+ drafted = [x for x in cited if x in _draft_ids(doc)]
1878
3091
  result.update(missing=missing, doc_operations=len(ops),
1879
- doc_unnamed=sum(1 for o in ops if not o["id"]))
3092
+ doc_unnamed=sum(1 for o in ops if not o["id"]),
3093
+ drafted=drafted, drafted_csv=", ".join(drafted))
1880
3094
  parts = []
1881
3095
  if missing:
1882
3096
  parts.append("%d cited operation id(s) are not in %s: %s"
1883
3097
  % (len(missing), doc_rel, ", ".join(missing)))
3098
+ if drafted:
3099
+ parts.append("%d cited operation id(s) are drafts the service does not serve yet: %s"
3100
+ % (len(drafted), ", ".join(drafted)))
1884
3101
  if not cited:
1885
3102
  parts.append("the plan cites no operation id")
1886
3103
  if uncited:
@@ -2056,14 +3273,58 @@ def main():
2056
3273
  "will create")
2057
3274
  p.add_argument("--command", default="",
2058
3275
  help="the phase being gated, quoted back in the failure message")
3276
+ p.add_argument("--working-only", dest="working_only", action="store_true",
3277
+ help="check on disk only the lane root folders of the working lane(s) "
3278
+ "(.appweaver/lane.json) and the --require'd kinds")
2059
3279
  p.set_defaults(func=cmd_verify)
2060
3280
 
3281
+ p = sub.add_parser("lane-select", help="read or write the developer's working lane")
3282
+ p.add_argument("--lane", default="",
3283
+ help="write this working lane: backend, frontend or both (an option "
3284
+ "line from select_options is accepted)")
3285
+ p.add_argument("--default", action="store_true",
3286
+ help="write the default for the declared stacks: both when both lanes "
3287
+ "are declared, otherwise the one that is")
3288
+ p.add_argument("--preselect", default="",
3289
+ help="direct-argument text; a whole-word match selects that option")
3290
+ p.set_defaults(func=cmd_lane_select)
3291
+
3292
+ p = sub.add_parser("cr-lanes", help="the lanes a change touches: the answers, and the "
3293
+ "delta-spec's **Lanes** line")
3294
+ p.add_argument("--delta-spec", dest="delta_spec", default="",
3295
+ help="the change's delta-spec.md; its **Lanes** line is read, or written "
3296
+ "with --set / --stamp")
3297
+ p.add_argument("--set", default="",
3298
+ help="write this answer: both, backend or frontend (an option line from "
3299
+ "select_options is accepted)")
3300
+ p.add_argument("--stamp", action="store_true",
3301
+ help="write the line; with no --set, every declared lane")
3302
+ p.set_defaults(func=cmd_cr_lanes)
3303
+
3304
+ p = sub.add_parser("slice-gate-stamp",
3305
+ help="write one lane's analyze-slice-delta result into the change")
3306
+ p.add_argument("--cr-dir", dest="cr_dir", required=True, help="the change's directory")
3307
+ p.add_argument("--cr-id", dest="cr_id", default="")
3308
+ p.add_argument("--lane", required=True, choices=["backend", "frontend"])
3309
+ p.add_argument("--tests-green", dest="tests_green", default="false",
3310
+ help="true when no test of the lane failed (green with nothing run when "
3311
+ "the lane declares no test command)")
3312
+ p.add_argument("--tests-ran", dest="tests_ran", default="false",
3313
+ help="true when a test suite actually ran for the lane")
3314
+ p.add_argument("--requirements-ok", dest="requirements_ok", default="false",
3315
+ help="true when the lane's code met the delta-spec")
3316
+ p.add_argument("--findings", default="",
3317
+ help="the lane check's findings, a JSON list")
3318
+ p.set_defaults(func=cmd_slice_gate_stamp)
3319
+
2061
3320
  p = sub.add_parser("run-phase", help="run a phase's command for every declared lane")
2062
3321
  p.add_argument("--phase", required=True, choices=["build", "test", "run", "migrate", "client"],
2063
3322
  help="which project-supplied command to run; `client` is a frontend lane's "
2064
3323
  "client generator, with {{contract}} expanded")
2065
3324
  p.add_argument("--kind", choices=["backend", "frontend"],
2066
3325
  help="restrict to one lane kind (default: every declared lane)")
3326
+ p.add_argument("--contract", default="",
3327
+ help="the API document {{contract}} names (default: the exported one)")
2067
3328
  p.set_defaults(func=cmd_run_phase)
2068
3329
 
2069
3330
  p = sub.add_parser("set", help="change API-contract keys on a declared lane")
@@ -2083,12 +3344,37 @@ def main():
2083
3344
  help="seconds to wait for the document after starting the service")
2084
3345
  p.set_defaults(func=cmd_contract_export)
2085
3346
 
3347
+ p = sub.add_parser("contract-draft",
3348
+ help="draft a feature's API document from its backend plan's API section")
3349
+ p.add_argument("--feature-dir", dest="feature_dir", default="")
3350
+ p.set_defaults(func=cmd_contract_draft)
3351
+
3352
+ p = sub.add_parser("contract-combine",
3353
+ help="the exported document plus every drafted operation it does not serve")
3354
+ p.set_defaults(func=cmd_contract_combine)
3355
+
3356
+ p = sub.add_parser("contract-requests",
3357
+ help="the frontend plan's contract requests and the backend plan's answers")
3358
+ p.add_argument("--feature-dir", dest="feature_dir", default="")
3359
+ p.set_defaults(func=cmd_contract_requests)
3360
+
2086
3361
  p = sub.add_parser("contract-check",
2087
3362
  help="the operation ids a frontend plan cites vs the exported document")
2088
3363
  p.add_argument("--plan", required=True, help="the frontend plan (master or delta)")
2089
3364
  p.add_argument("--doc", default="", help="the API document (default: the exported one)")
2090
3365
  p.set_defaults(func=cmd_contract_check)
2091
3366
 
3367
+ p = sub.add_parser("contract-impact",
3368
+ help="operations a change's service delta changes or removes that the "
3369
+ "frontend binds to, and whether its frontend delta carries them")
3370
+ p.add_argument("--delta-backend", dest="delta_backend", default="",
3371
+ help="the change's delta-backend-plan.md")
3372
+ p.add_argument("--frontend-plan", dest="frontend_plan", default="",
3373
+ help="the feature's master frontend plan")
3374
+ p.add_argument("--delta-frontend", dest="delta_frontend", default="",
3375
+ help="the change's delta-frontend-plan.md (may not exist yet)")
3376
+ p.set_defaults(func=cmd_contract_impact)
3377
+
2092
3378
  p = sub.add_parser("probe", help="evidence of a lane of this kind (file signals only)")
2093
3379
  p.add_argument("--kind", choices=["backend", "frontend"], default="frontend")
2094
3380
  p.add_argument("--hint", default="", help="free prose to scan for a stack name")