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.
- package/assets/.amp/agent.json +22 -0
- package/assets/.amp/settings-edit.json +5 -0
- package/assets/.amp/settings-read.json +12 -0
- package/assets/.appweaver/DESIGN-FORMAT.md +9 -0
- package/assets/.appweaver/STACK-PROFILES.md +102 -15
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +40 -18
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +56 -4
- package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +17 -1
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +12 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +289 -24
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +370 -85
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +349 -65
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +39 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +63 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +5 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +430 -138
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +392 -76
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-lane.orch.yaml +104 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +20 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +26 -3
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +314 -44
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +249 -20
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +17 -2
- package/assets/.appweaver/scripts/python/aw-plangate.py +109 -19
- package/assets/.appweaver/scripts/python/aw-stack.py +1295 -9
- package/assets/.appweaver/templates/delta-design-template.md +7 -5
- package/assets/.appweaver/templates/delta-spec-template.md +2 -0
- package/assets/.appweaver/templates/design-template.md +17 -0
- package/assets/.appweaver/templates/ui-design-template.md +19 -4
- package/assets/.augment/agent.json +22 -0
- package/assets/.augment/commands/appweaver.md +12 -0
- package/assets/.cline/agent.json +22 -0
- package/assets/.clinerules/workflows/appweaver.md +7 -0
- package/assets/.codex/agent.json +22 -0
- package/assets/.continue/agent.json +22 -0
- package/assets/.continue/prompts/appweaver.md +13 -0
- package/assets/.cursor/agent.json +22 -0
- package/assets/.cursor/commands/appweaver.md +7 -0
- package/assets/.devin/agent.json +22 -0
- package/assets/.devin/workflows/appweaver.md +11 -0
- package/assets/.factory/agent.json +22 -0
- package/assets/.factory/commands/appweaver.md +12 -0
- package/assets/.gemini/agent.json +22 -0
- package/assets/.github/prompts/appweaver.prompt.md +13 -0
- package/assets/.kilo/agent.json +28 -0
- package/assets/.kilo/agents/appweaver-edit.md +12 -0
- package/assets/.kilo/agents/appweaver-read.md +12 -0
- package/assets/.kilo/commands/appweaver.md +11 -0
- package/assets/.kiro/agent.json +22 -0
- package/assets/.kiro/skills/appweaver/SKILL.md +12 -0
- package/assets/.opencode/agent.json +22 -0
- package/assets/.opencode/agents/appweaver-edit.md +12 -0
- package/assets/.opencode/agents/appweaver-read.md +13 -0
- package/assets/.opencode/commands/appweaver.md +11 -0
- package/assets/.roo/agent.json +22 -0
- package/assets/.roo/commands/appweaver.md +12 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +14 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +14 -0
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/copy-assets.d.ts +16 -3
- package/dist/utils/copy-assets.d.ts.map +1 -1
- package/dist/utils/copy-assets.js +29 -14
- package/dist/utils/copy-assets.js.map +1 -1
- 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
|
|
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
|
|
743
|
-
seen from where THIS lane's command runs
|
|
744
|
-
|
|
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")
|