aw-spec-cli 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/.appweaver/DESIGN-FORMAT.md +17 -9
- package/assets/.appweaver/ORCH-DIALECT.md +8 -3
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +26 -3
- package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
- package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +4 -2
- package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +121 -42
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +7 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +145 -52
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +18 -10
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +306 -28
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +114 -30
- package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +385 -61
- package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +217 -174
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +407 -107
- package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +49 -11
- package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +29 -23
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +19 -11
- package/assets/.appweaver/scripts/bash/aw-inventory.sh +15 -0
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +683 -92
- package/assets/.appweaver/scripts/bash/aw-xref.sh +3 -0
- package/assets/.appweaver/scripts/powershell/aw-inventory.ps1 +15 -0
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +625 -64
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +3 -0
- package/assets/.appweaver/scripts/python/aw-crstate.py +194 -0
- package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
- package/assets/.appweaver/scripts/python/aw-inventory.py +1829 -63
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +3 -0
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +423 -0
- package/assets/.appweaver/scripts/python/aw-stack.py +251 -28
- package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
- package/assets/.appweaver/scripts/python/aw-xref.py +500 -81
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +1 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +1 -0
- package/assets/.appweaver/stack-profiles/node-express.json +1 -0
- package/assets/.appweaver/stack-profiles/python-fastapi.json +1 -0
- package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
- package/assets/.appweaver/templates/delta-design-template.md +1 -1
- package/assets/.appweaver/templates/delta-domain-model-template.md +1 -1
- package/assets/.appweaver/templates/delta-spec-template.md +4 -3
- package/assets/.appweaver/templates/design-template.md +16 -11
- package/assets/.appweaver/templates/okf-concept-template.md +11 -1
- package/assets/.appweaver/templates/proposed-inventory-template.md +127 -0
- package/assets/.appweaver/templates/source-map-template.md +5 -3
- package/assets/.appweaver/templates/spec-template.md +17 -17
- package/assets/.appweaver/templates/ui-delta-design-template.md +1 -1
- package/assets/.appweaver/templates/ui-design-template.md +4 -4
- package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +4489 -361
- package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +2274 -125
- package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
- package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
- package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
- package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
- package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
- package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
- package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
- package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +70 -0
- package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
- package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
- package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
- package/package.json +1 -1
- package/assets/.appweaver/orchestrator/.state/smoke.state.json +0 -32
|
@@ -22,10 +22,12 @@
|
|
|
22
22
|
# -> {checklist_file, created}
|
|
23
23
|
# list-domains -> {domains[]}
|
|
24
24
|
# set-domain --domain <name> -> {domain, map_path, map_exists, written}
|
|
25
|
-
# stack-list [--kind backend|frontend|all] [--preselect <text>] [--include-none] [--suggest]
|
|
25
|
+
# stack-list [--kind backend|frontend|all] [--preselect <text>] [--profile <id|label>] [--include-none] [--suggest]
|
|
26
26
|
# -> {profiles[], ids[], select_options[], count, preselect,
|
|
27
|
-
# detected_profile, detected_root,
|
|
28
|
-
#
|
|
27
|
+
# detected_profile, detected_root, detected_summary,
|
|
28
|
+
# root_options[], none_sentinel}
|
|
29
|
+
# (--suggest also detects, so the menu opens on what the repo IS;
|
|
30
|
+
# --profile builds root_options for the stack already chosen)
|
|
29
31
|
# stack-detect [--kind <k>] [--exclude-root <p>]… -> {matches[], backend, frontend, count}
|
|
30
32
|
# (matches the repo's OWN files against the profiles; writes nothing)
|
|
31
33
|
# lane-declare --profile <id|label|none|detected> [--kind <k>] [--id <lane>] [--source-path|--root <p>]
|
|
@@ -42,7 +44,7 @@
|
|
|
42
44
|
# Always exits 0 — the workflow's verify_context owns the stop)
|
|
43
45
|
# lane-probe [--kind <k>] [--hint <text>] -> {declared, declared_profile, hint_profile, hint_terms[], signals[], any, evidence}
|
|
44
46
|
# (the FILE signals for "does this lane exist"; resolve-tech carries the rule signal)
|
|
45
|
-
# list-features [--preselect <name>] [--bundle <dir>] -> {features[], names[], count, select_options[], sentinel} (specs/ ∪ OKF
|
|
47
|
+
# list-features [--preselect <name>] [--bundle <dir>] -> {features[], names[], count, select_options[], sentinel} (specs/ ∪ features the OKF concepts serve)
|
|
46
48
|
# set-feature --feature <name> -> {feature, feature_dir, spec_file, existed, created, spec_exists}
|
|
47
49
|
# version-read [--feature-dir <path>] -> {feature, version, active_change, exists, bootstrap_pending}
|
|
48
50
|
# version-init [--feature-dir <path>] [--bootstrap] -> {…, created, bootstrap_pending} (create active_version.json v1.0; --bootstrap marks a FIRST draft in progress)
|
|
@@ -51,6 +53,10 @@
|
|
|
51
53
|
# design_file/ui_design_file (aliases of the two plans), spec_exists, bootstrap_pending, needs_bootstrap}
|
|
52
54
|
# checklist-read --file <path> -> {exists, total, checked, items[]}
|
|
53
55
|
# checklist-apply --file <path> --results <json>-> {exists, before_checked, after_checked, total, newly_passing[], regressions[]}
|
|
56
|
+
# plan-coverage --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--source-path <p>]
|
|
57
|
+
# [--test-path <p>] [--reported <json list>]... [--list-only]
|
|
58
|
+
# -> {ok, total, names[], names_csv, elements[], gaps[], gap_count, gaps_text}
|
|
59
|
+
# (which plan elements the lane's code names and the model reported done)
|
|
54
60
|
# detect-project -> {scaffolded, markers[]}
|
|
55
61
|
# foundation-list [--args '<F> <A> [out]'] -> {foundations[], count, preselect_name, preselect_app, out_dir}
|
|
56
62
|
# foundation-locate --name <F> --app <A> [--out <d>]
|
|
@@ -76,7 +82,11 @@
|
|
|
76
82
|
# four-document model is gone. "Which files is this slice made of" is now an OKF
|
|
77
83
|
# query: aw-xref concept-paths / concept-upsert --merge.)
|
|
78
84
|
# build-compile [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
|
|
79
|
-
# test-run [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail
|
|
85
|
+
# test-run [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail,
|
|
86
|
+
# failing_excerpt, failing_lanes[],
|
|
87
|
+
# no_setup, setup_reason, setup_missing[]}
|
|
88
|
+
# (failing_excerpt = the lines that NAME the failure, per failing lane;
|
|
89
|
+
# no_setup = no suite ran because no test command is wired up, with why)
|
|
80
90
|
# run-migrate [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
|
|
81
91
|
# deploy-dispatch -> {ok, configured, ran, cmd, log_tail}
|
|
82
92
|
# cr-setup --cr-id <id> [--feature-dir <path>] [--title <t>] [--wave <n>]
|
|
@@ -742,9 +752,10 @@ cmd_stack_list() {
|
|
|
742
752
|
case "$1" in
|
|
743
753
|
--kind) fwd+=("--kind" "$2"); shift 2 ;;
|
|
744
754
|
--preselect) fwd+=("--preselect" "$2"); shift 2 ;;
|
|
755
|
+
--profile) fwd+=("--profile" "$2"); shift 2 ;;
|
|
745
756
|
--include-none) fwd+=("--include-none"); shift ;;
|
|
746
757
|
--suggest) fwd+=("--suggest"); shift ;;
|
|
747
|
-
--help|-h) echo "stack-list [--kind backend|frontend|all] [--preselect <text>] [--include-none] [--suggest]"; return 0 ;;
|
|
758
|
+
--help|-h) echo "stack-list [--kind backend|frontend|all] [--preselect <text>] [--profile <id|label>] [--include-none] [--suggest]"; return 0 ;;
|
|
748
759
|
*) echo "stack-list: unknown arg '$1'" >&2; return 1 ;;
|
|
749
760
|
esac
|
|
750
761
|
done
|
|
@@ -1206,6 +1217,10 @@ cmd_model_scaffold() {
|
|
|
1206
1217
|
# reverse-engineers (the discovered aggregates NOT yet under specs/). This is
|
|
1207
1218
|
# why select-feature can offer real code features in a brownfield repo before
|
|
1208
1219
|
# anything is adopted, instead of an empty specs/ list.
|
|
1220
|
+
# BOTH sources are scoped to the ACTIVE BOUNDED CONTEXT: source 1 by enumerating the
|
|
1221
|
+
# context's own directory, source 2 by the `bounded_context:` key onboard stamps on
|
|
1222
|
+
# every concept it writes. onboard runs once per context, so an unfiltered bundle
|
|
1223
|
+
# offers a neighbouring context's features to a picker that would scaffold them here.
|
|
1209
1224
|
# On greenfield (no bundle) source 2 is empty → identical to the old specs-only
|
|
1210
1225
|
# behaviour. `select_options` = merged names + (an injected --preselect that is not
|
|
1211
1226
|
# yet a feature) + a "create new" sentinel, so select-feature can offer add-new and
|
|
@@ -1223,16 +1238,24 @@ cmd_list_features() {
|
|
|
1223
1238
|
local repo_root; repo_root=$(get_repo_root)
|
|
1224
1239
|
[ -n "$bundle" ] || bundle="knowledge"
|
|
1225
1240
|
case "$bundle" in /*) ;; *) bundle="$repo_root/$bundle" ;; esac
|
|
1226
|
-
# Best-effort OKF aggregate list from the shared python core (single source of
|
|
1227
|
-
# truth for OKF parsing; the ps1 twin calls the SAME script). Empty on greenfield.
|
|
1228
|
-
local okf_json="{}" invpy="$SCRIPT_DIR/../python/aw-inventory.py"
|
|
1229
|
-
if [ -d "$bundle" ] && [ -f "$invpy" ] && command -v python3 >/dev/null 2>&1; then
|
|
1230
|
-
okf_json=$(python3 "$invpy" list-aggregates --bundle "$bundle" 2>/dev/null) || okf_json="{}"
|
|
1231
|
-
fi
|
|
1232
1241
|
# Features are enumerated from the ACTIVE BOUNDED CONTEXT's directory when one is
|
|
1233
1242
|
# set, and from specs/ itself when none is — so a context scopes the picker to its
|
|
1234
1243
|
# own features and a pre-context repo lists exactly what it always did.
|
|
1235
1244
|
local base_rel; base_rel=$(feature_base_rel "$repo_root")
|
|
1245
|
+
# The OKF half is scoped the same way. onboard runs once per bounded context and
|
|
1246
|
+
# stamps each concept it writes, so a bundle that has seen two runs holds two
|
|
1247
|
+
# contexts' aggregates; unfiltered, standing inside one context would offer the
|
|
1248
|
+
# other's features and scaffold them into the wrong tree. Empty when no context is
|
|
1249
|
+
# active, which keeps the flat layout listing everything.
|
|
1250
|
+
local active_ctx=""
|
|
1251
|
+
[ "$base_rel" != "specs" ] && active_ctx="${base_rel##*/}"
|
|
1252
|
+
# Best-effort OKF aggregate list from the shared python core (single source of
|
|
1253
|
+
# truth for OKF parsing AND for the context filter; the ps1 twin calls the SAME
|
|
1254
|
+
# script with the same argument). Empty on greenfield.
|
|
1255
|
+
local okf_json="{}" invpy="$SCRIPT_DIR/../python/aw-inventory.py"
|
|
1256
|
+
if [ -d "$bundle" ] && [ -f "$invpy" ] && command -v python3 >/dev/null 2>&1; then
|
|
1257
|
+
okf_json=$(python3 "$invpy" list-aggregates --bundle "$bundle" --root "$repo_root" --context "$active_ctx" 2>/dev/null) || okf_json="{}"
|
|
1258
|
+
fi
|
|
1236
1259
|
AW_SPECS="$repo_root/$base_rel" AW_BASE_REL="$base_rel" AW_PRESELECT="$preselect" AW_OKF="$okf_json" python3 - <<'PY'
|
|
1237
1260
|
import json, os, glob
|
|
1238
1261
|
base = os.environ["AW_SPECS"]
|
|
@@ -1293,9 +1316,13 @@ if os.path.isdir(base):
|
|
|
1293
1316
|
"version": ver,
|
|
1294
1317
|
"active_change": active_change,
|
|
1295
1318
|
})
|
|
1296
|
-
# Merge OKF-discovered
|
|
1297
|
-
# folder is the adopted/created form of the same
|
|
1298
|
-
|
|
1319
|
+
# Merge the OKF-discovered FEATURES not already scaffolded under specs/ (a specs/
|
|
1320
|
+
# folder is the adopted/created form of the same feature — it wins, no dup).
|
|
1321
|
+
# `pickable` is each aggregate concept offered as the features the map puts on it, so
|
|
1322
|
+
# an aggregate split into capabilities is never itself offered as a feature to pick.
|
|
1323
|
+
# An EMPTY `pickable` is an answer — nothing to offer, e.g. a greenfield bundle whose
|
|
1324
|
+
# features all have specs/ dirs — so only its absence falls back to the raw aggregates.
|
|
1325
|
+
for agg in (okf["pickable"] if "pickable" in okf else okf.get("aggregates") or []):
|
|
1299
1326
|
nm = (agg.get("name") or "").strip()
|
|
1300
1327
|
if not nm or nm.lower() in seen:
|
|
1301
1328
|
continue
|
|
@@ -1328,26 +1355,26 @@ PY
|
|
|
1328
1355
|
}
|
|
1329
1356
|
|
|
1330
1357
|
# ─────────────────────────────── set-feature ───────────────────────────────
|
|
1331
|
-
# Create-or-select
|
|
1332
|
-
# (.appweaver/feature.json).
|
|
1333
|
-
# PascalCase identifier (e.g. "
|
|
1358
|
+
# Create-or-select a feature under specs/ and persist it as the active feature
|
|
1359
|
+
# (.appweaver/feature.json). The name is the developer's capability scope, normalized to
|
|
1360
|
+
# a PascalCase identifier (e.g. "patient registration" -> PatientRegistration). Idempotent:
|
|
1334
1361
|
# selecting an existing feature just re-points feature.json; a new one is created.
|
|
1335
1362
|
cmd_set_feature() {
|
|
1336
1363
|
local raw=""
|
|
1337
1364
|
while [ $# -gt 0 ]; do
|
|
1338
1365
|
case "$1" in
|
|
1339
1366
|
--feature) raw="$2"; shift 2 ;;
|
|
1340
|
-
--help|-h) echo "set-feature --feature <name> (create-or-select the
|
|
1367
|
+
--help|-h) echo "set-feature --feature <name> (create-or-select the feature under specs/)"; return 0 ;;
|
|
1341
1368
|
*) echo "set-feature: unknown arg '$1'" >&2; return 1 ;;
|
|
1342
1369
|
esac
|
|
1343
1370
|
done
|
|
1344
1371
|
[ -n "$raw" ] || { echo "set-feature: --feature is required" >&2; return 1; }
|
|
1345
1372
|
|
|
1346
|
-
# Normalize to a PascalCase
|
|
1373
|
+
# Normalize to a PascalCase identifier.
|
|
1347
1374
|
local cleaned; cleaned=$(echo "$raw" | sed -E 's/[^A-Za-z0-9]+/ /g; s/^ +| +$//g')
|
|
1348
1375
|
local feature; feature=$(_pascal "$cleaned")
|
|
1349
1376
|
if ! [[ "$feature" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then
|
|
1350
|
-
echo "set-feature: '$raw' is not a valid feature name (need a letter-led alphanumeric
|
|
1377
|
+
echo "set-feature: '$raw' is not a valid feature name (need a letter-led alphanumeric name, e.g. Order or PatientRegistration)" >&2
|
|
1351
1378
|
return 1
|
|
1352
1379
|
fi
|
|
1353
1380
|
|
|
@@ -1889,15 +1916,23 @@ _foundation_stack() {
|
|
|
1889
1916
|
# ──────────────────────────── foundation-list ──────────────────────────────
|
|
1890
1917
|
# Enumerate available foundations (foundations/<stack>/<Name>/ that carry a real
|
|
1891
1918
|
# <Name>.pack.txt) so the workflow can let the user PICK one — deterministic, no LLM. An optional
|
|
1892
|
-
# --args "<Foundation> <App>
|
|
1919
|
+
# --args "<Foundation> <App>" is parsed as a direct-argument fast path:
|
|
1893
1920
|
# preselect_name is echoed back only when it names a real foundation; preselect_app
|
|
1894
|
-
#
|
|
1921
|
+
# carries any 2nd token through (empty otherwise).
|
|
1922
|
+
#
|
|
1923
|
+
# A THIRD token is no longer an output directory. A scaffold lands in the source path of
|
|
1924
|
+
# the lane it is scaffolding, which is the only thing that keeps generated code inside a
|
|
1925
|
+
# declared lane, so there is no caller-chosen destination left to accept. It comes back as
|
|
1926
|
+
# `rejected_out` for the workflow to refuse by name rather than be silently ignored —
|
|
1927
|
+
# a path argument that is quietly dropped is how someone believes they scaffolded a
|
|
1928
|
+
# sandbox and finds they overwrote their service.
|
|
1895
1929
|
cmd_foundation_list() {
|
|
1896
|
-
local raw=""
|
|
1930
|
+
local raw="" eco_filter=""
|
|
1897
1931
|
while [ $# -gt 0 ]; do
|
|
1898
1932
|
case "$1" in
|
|
1899
1933
|
--args) raw="$2"; shift 2 ;;
|
|
1900
|
-
--
|
|
1934
|
+
--ecosystem) eco_filter="$2"; shift 2 ;;
|
|
1935
|
+
--help|-h) echo "foundation-list [--args '<Foundation> <App>'] [--ecosystem <eco>] -> {foundations[], count, preselect_name, preselect_app, rejected_out}"; return 0 ;;
|
|
1901
1936
|
*) echo "foundation-list: unknown arg '$1'" >&2; return 1 ;;
|
|
1902
1937
|
esac
|
|
1903
1938
|
done
|
|
@@ -1906,13 +1941,19 @@ cmd_foundation_list() {
|
|
|
1906
1941
|
# WHICH foundations this repo may be offered. A foundation bundle is a whole
|
|
1907
1942
|
# compiling project in one ecosystem's languages and build system, so unpacking
|
|
1908
1943
|
# a .NET one into a Spring repo does not produce a scaffold — it produces a
|
|
1909
|
-
# second, foreign project beside the real one.
|
|
1910
|
-
#
|
|
1911
|
-
#
|
|
1912
|
-
#
|
|
1944
|
+
# second, foreign project beside the real one.
|
|
1945
|
+
#
|
|
1946
|
+
# --ecosystem narrows to the ONE lane being scaffolded, which is stricter than the
|
|
1947
|
+
# union of every declared lane: a repo with a .NET service and a Node UI must not be
|
|
1948
|
+
# offered its .NET bundle while the UI lane is the target. Without it the filter falls
|
|
1949
|
+
# back to every declared lane's ecosystem, and to nothing at all on a bare repo.
|
|
1913
1950
|
local -a lane_ecos=()
|
|
1914
1951
|
local e
|
|
1915
|
-
|
|
1952
|
+
if [ -n "$eco_filter" ]; then
|
|
1953
|
+
lane_ecos=("$eco_filter")
|
|
1954
|
+
else
|
|
1955
|
+
while IFS= read -r e; do [ -n "$e" ] && lane_ecos+=("$e"); done < <(_aw_ecosystems)
|
|
1956
|
+
fi
|
|
1916
1957
|
|
|
1917
1958
|
local -a foundations=() all=() eco_of=()
|
|
1918
1959
|
local rel name eco keep
|
|
@@ -1927,22 +1968,22 @@ cmd_foundation_list() {
|
|
|
1927
1968
|
fi
|
|
1928
1969
|
[ "$keep" = true ] && foundations+=("$name")
|
|
1929
1970
|
done < <(_foundation_dirs "$repo_root")
|
|
1930
|
-
# Optional direct-argument fast path: "<FoundationName> <AppName>
|
|
1931
|
-
local pre_name="" pre_app=""
|
|
1971
|
+
# Optional direct-argument fast path: "<FoundationName> <AppName>".
|
|
1972
|
+
local pre_name="" pre_app="" rejected_out=""
|
|
1932
1973
|
if [ -n "$raw" ]; then
|
|
1933
1974
|
# shellcheck disable=SC2086
|
|
1934
1975
|
set -- $raw
|
|
1935
|
-
[ -n "${1:-}" ] && pre_name="$1"; [ -n "${2:-}" ] && pre_app="$2"; [ -n "${3:-}" ] &&
|
|
1976
|
+
[ -n "${1:-}" ] && pre_name="$1"; [ -n "${2:-}" ] && pre_app="$2"; [ -n "${3:-}" ] && rejected_out="$3"
|
|
1936
1977
|
fi
|
|
1937
1978
|
# Only preselect a name that actually names an available foundation.
|
|
1938
1979
|
local match="" f
|
|
1939
1980
|
for f in "${foundations[@]}"; do [ "$f" = "$pre_name" ] && match="$pre_name"; done
|
|
1940
|
-
printf '{"foundations":%s,"count":%s,"all_foundations":%s,"all_count":%s,"ecosystems":%s,"lane_ecosystems":%s,"filtered":%s,"preselect_name":"%s","preselect_app":"%s","
|
|
1981
|
+
printf '{"foundations":%s,"count":%s,"all_foundations":%s,"all_count":%s,"ecosystems":%s,"lane_ecosystems":%s,"filtered":%s,"preselect_name":"%s","preselect_app":"%s","rejected_out":"%s"}\n' \
|
|
1941
1982
|
"$(json_str_array "${foundations[@]}")" "${#foundations[@]}" \
|
|
1942
1983
|
"$(json_str_array "${all[@]}")" "${#all[@]}" \
|
|
1943
1984
|
"$(json_str_array "${eco_of[@]}")" "$(json_str_array "${lane_ecos[@]}")" \
|
|
1944
1985
|
"$([ ${#lane_ecos[@]} -gt 0 ] && echo true || echo false)" \
|
|
1945
|
-
"$(json_escape "$match")" "$(json_escape "$pre_app")" "$(json_escape "$
|
|
1986
|
+
"$(json_escape "$match")" "$(json_escape "$pre_app")" "$(json_escape "$rejected_out")"
|
|
1946
1987
|
}
|
|
1947
1988
|
|
|
1948
1989
|
# ─────────────────────────── foundation-locate ─────────────────────────────
|
|
@@ -2005,17 +2046,23 @@ cmd_foundation_locate() {
|
|
|
2005
2046
|
|
|
2006
2047
|
# ─────────────────────────── foundation-unpack ─────────────────────────────
|
|
2007
2048
|
# Run the OS-selected unpacker for the foundation (or reference) pack.
|
|
2049
|
+
#
|
|
2050
|
+
# --out is the LANE'S SOURCE PATH, so every file a pack carries lands inside the subtree
|
|
2051
|
+
# its lane owns. --test-path is the lane's other subtree: a pack that carries its own test
|
|
2052
|
+
# project puts it under the source path like everything else, and aw-foundation.py lifts it
|
|
2053
|
+
# onto the path the lane actually keeps tests in. See that module for when it is a no-op.
|
|
2008
2054
|
cmd_foundation_unpack() {
|
|
2009
|
-
local name="" app="" out="." dry=false force="no" reference=false
|
|
2055
|
+
local name="" app="" out="." dry=false force="no" reference=false test_path=""
|
|
2010
2056
|
while [ $# -gt 0 ]; do
|
|
2011
2057
|
case "$1" in
|
|
2012
2058
|
--name) name="$2"; shift 2 ;;
|
|
2013
2059
|
--app) app="$2"; shift 2 ;;
|
|
2014
2060
|
--out) out="$2"; shift 2 ;;
|
|
2061
|
+
--test-path) test_path="$2"; shift 2 ;;
|
|
2015
2062
|
--dry-run) dry=true; shift ;;
|
|
2016
2063
|
--force) force="$2"; shift 2 ;;
|
|
2017
2064
|
--reference) reference=true; shift ;;
|
|
2018
|
-
--help|-h) echo "foundation-unpack --name <F> --app <A> --out <d> [--dry-run] [--force <yes|no>] [--reference]"; return 0 ;;
|
|
2065
|
+
--help|-h) echo "foundation-unpack --name <F> --app <A> --out <d> [--test-path <d>] [--dry-run] [--force <yes|no>] [--reference]"; return 0 ;;
|
|
2019
2066
|
*) echo "foundation-unpack: unknown arg '$1'" >&2; return 1 ;;
|
|
2020
2067
|
esac
|
|
2021
2068
|
done
|
|
@@ -2051,9 +2098,29 @@ cmd_foundation_unpack() {
|
|
|
2051
2098
|
[ -n "$written" ] || written=0
|
|
2052
2099
|
local has_coll=false
|
|
2053
2100
|
if [ "$dry" = true ] && [ $rc -ne 0 ]; then has_coll=true; fi
|
|
2054
|
-
|
|
2101
|
+
|
|
2102
|
+
# Put the pack's test tree on the lane's test path. Only after a REAL unpack that
|
|
2103
|
+
# worked: a dry run has written nothing to lift, and a failed one leaves a tree
|
|
2104
|
+
# nobody should be rearranging.
|
|
2105
|
+
local tests_moved=0 tests_msg="" redirect=""
|
|
2106
|
+
if [ "$dry" != true ] && [ $rc -eq 0 ] && [ -n "$test_path" ]; then
|
|
2107
|
+
local aw_fnd="$SCRIPT_DIR/../python/aw-foundation.py"
|
|
2108
|
+
if [ -f "$aw_fnd" ] && command -v python3 >/dev/null 2>&1; then
|
|
2109
|
+
local -a rargs=(--repo "$repo_root" redirect-tests --out "$out" --test-path "$test_path")
|
|
2110
|
+
{ [ "$force" = "yes" ] || [ "$reference" = true ]; } && rargs+=(--force)
|
|
2111
|
+
redirect=$(python3 "$aw_fnd" "${rargs[@]}" 2>/dev/null | tail -1)
|
|
2112
|
+
if [ -n "$redirect" ]; then
|
|
2113
|
+
tests_moved=$(printf '%s' "$redirect" | python3 -c 'import json,sys; print(json.loads(sys.stdin.readline()).get("moved",0))' 2>/dev/null || echo 0)
|
|
2114
|
+
tests_msg=$(printf '%s' "$redirect" | python3 -c 'import json,sys; print(json.loads(sys.stdin.readline()).get("message",""))' 2>/dev/null || echo "")
|
|
2115
|
+
fi
|
|
2116
|
+
fi
|
|
2117
|
+
fi
|
|
2118
|
+
[ -n "$tests_moved" ] || tests_moved=0
|
|
2119
|
+
|
|
2120
|
+
printf '{"ok":%s,"mode":"%s","written":%s,"has_collisions":%s,"tests_moved":%s,"tests_message":"%s","stdout":"%s"}\n' \
|
|
2055
2121
|
"$([ $rc -eq 0 ] && echo true || echo false)" "$([ "$dry" = true ] && echo dry || echo real)" \
|
|
2056
|
-
"$written" "$has_coll" "$
|
|
2122
|
+
"$written" "$has_coll" "$tests_moved" "$(json_escape "$tests_msg")" \
|
|
2123
|
+
"$(json_escape "$(printf '%s' "$outp" | tail -c 800)")"
|
|
2057
2124
|
}
|
|
2058
2125
|
|
|
2059
2126
|
# ─────────────────────── foundation-stash-dbcontext ────────────────────────
|
|
@@ -2070,7 +2137,9 @@ cmd_foundation_stash_dbcontext() {
|
|
|
2070
2137
|
done
|
|
2071
2138
|
local repo_root; repo_root=$(get_repo_root)
|
|
2072
2139
|
[[ "$out" != /* ]] && out="$repo_root/$out"
|
|
2073
|
-
|
|
2140
|
+
# Searched from the unpack root itself, which IS the lane's source path — a `src/`
|
|
2141
|
+
# segment underneath it is the old repo-root layout and is not there any more.
|
|
2142
|
+
local ctx; ctx=$(find "$out" -name "ApplicationDbContext.cs" 2>/dev/null | head -1)
|
|
2074
2143
|
if [ -z "$ctx" ]; then printf '{"found":false,"dbcontext":"","backup":""}\n'; return 0; fi
|
|
2075
2144
|
local bak="$ctx.foundation.bak"
|
|
2076
2145
|
cp "$ctx" "$bak"
|
|
@@ -2957,7 +3026,9 @@ cmd_test_run() {
|
|
|
2957
3026
|
fi
|
|
2958
3027
|
|
|
2959
3028
|
local tool="none" ran=false ok=true cmd="" log; log=$(mktemp)
|
|
2960
|
-
|
|
3029
|
+
# The runner's exit code, kept because 126/127 is every shell's way of saying the
|
|
3030
|
+
# command itself could not be executed — a missing runner, not a failing suite.
|
|
3031
|
+
local matched=false scoped=false fence_green=false filter="" t2 rc=0
|
|
2961
3032
|
|
|
2962
3033
|
local dotnet_present=false
|
|
2963
3034
|
if ls "$repo_root"/*.sln >/dev/null 2>&1 || find "$repo_root" -maxdepth 5 -name "*.csproj" 2>/dev/null | grep -q .; then
|
|
@@ -2990,6 +3061,16 @@ print(json.dumps({
|
|
|
2990
3061
|
"scope": os.environ.get("AW_SCOPE", "repo"), "slices": os.environ.get("AW_SLICES", ""),
|
|
2991
3062
|
"scoped": False, "matched": bool(re.search(r"Total:\s*[1-9]", log)),
|
|
2992
3063
|
"fence_green": ok, "cmd": d.get("cmd", ""), "log_tail": log,
|
|
3064
|
+
# The failing lines of EVERY failing lane, budgeted per lane by run-phase — what the
|
|
3065
|
+
# fix loop reads, in place of a tail that may belong to a lane that passed.
|
|
3066
|
+
"failing_excerpt": d.get("failing_excerpt", ""),
|
|
3067
|
+
"failing_lanes": d.get("failing_lanes", []),
|
|
3068
|
+
# NOTHING ran a suite, because no lane has a test command wired up — a different
|
|
3069
|
+
# state from a red suite, and the one a fix loop cannot help with. (No apostrophes
|
|
3070
|
+
# in this block: it is a single-quoted python3 -c program.)
|
|
3071
|
+
"no_setup": bool(d.get("no_setup")),
|
|
3072
|
+
"setup_reason": d.get("setup_reason", ""),
|
|
3073
|
+
"setup_missing": d.get("setup_missing", []),
|
|
2993
3074
|
"lanes_run": d.get("lanes_run", []), "lanes_skipped": d.get("lanes_skipped", []),
|
|
2994
3075
|
}))
|
|
2995
3076
|
'
|
|
@@ -3008,16 +3089,16 @@ print(json.dumps({
|
|
|
3008
3089
|
else
|
|
3009
3090
|
for t2 in $slices; do filter="${filter:+$filter|}FullyQualifiedName~$t2"; done
|
|
3010
3091
|
scoped=true; cmd="dotnet test --nologo --filter \"$filter\""; ran=true
|
|
3011
|
-
if ( cd "$repo_root" && dotnet test --nologo --filter "$filter" ) >"$log" 2>&1; then ok=true; else ok=false; fi
|
|
3092
|
+
if ( cd "$repo_root" && dotnet test --nologo --filter "$filter" ) >"$log" 2>&1; then ok=true; else rc=$?; ok=false; fi
|
|
3012
3093
|
fi
|
|
3013
3094
|
else
|
|
3014
3095
|
cmd="dotnet test --nologo"; ran=true
|
|
3015
|
-
if ( cd "$repo_root" && dotnet test --nologo ) >"$log" 2>&1; then ok=true; else ok=false; fi
|
|
3096
|
+
if ( cd "$repo_root" && dotnet test --nologo ) >"$log" 2>&1; then ok=true; else rc=$?; ok=false; fi
|
|
3016
3097
|
fi
|
|
3017
3098
|
else ok=false; fi
|
|
3018
3099
|
elif [ -f "$repo_root/package.json" ] && command -v npm >/dev/null 2>&1; then
|
|
3019
3100
|
tool="npm"; cmd="npm test"; ran=true
|
|
3020
|
-
if ( cd "$repo_root" && npm test ) >"$log" 2>&1; then ok=true; else ok=false; fi
|
|
3101
|
+
if ( cd "$repo_root" && npm test ) >"$log" 2>&1; then ok=true; else rc=$?; ok=false; fi
|
|
3021
3102
|
else
|
|
3022
3103
|
tool="none"; ran=false; ok=true
|
|
3023
3104
|
fi
|
|
@@ -3037,10 +3118,30 @@ print(json.dumps({
|
|
|
3037
3118
|
fence_green=$ok
|
|
3038
3119
|
fi
|
|
3039
3120
|
|
|
3040
|
-
local t; t=$(tail -c 1400 "$log" 2>/dev/null || true)
|
|
3041
|
-
|
|
3121
|
+
local t; t=$(tail -c 1400 "$log" 2>/dev/null || true)
|
|
3122
|
+
# The lines that NAME the failure, which a positional tail does not reliably contain:
|
|
3123
|
+
# a .NET run opens with its restore banner and a runner closes with a summary, so the
|
|
3124
|
+
# last 1400 characters can be entirely green while the failure sits above them.
|
|
3125
|
+
local excerpt="" label no_setup=false setup_reason="" helper_out=""
|
|
3126
|
+
label=$(printf '%s' "$tool${cmd:+ · $cmd}" | tr '\n' ' ' | cut -c1-80)
|
|
3127
|
+
if [ "$fence_green" != true ]; then
|
|
3128
|
+
helper_out=$(python3 "$SCRIPT_DIR/../python/aw-testlog.py" --file "$log" \
|
|
3129
|
+
--label "$label" --exit "$rc" 2>/dev/null || true)
|
|
3130
|
+
excerpt=$(printf '%s' "$helper_out" | python3 -c 'import json,sys
|
|
3131
|
+
try: sys.stdout.write(json.load(sys.stdin).get("excerpt", ""))
|
|
3132
|
+
except Exception: pass' 2>/dev/null || true)
|
|
3133
|
+
# "The command never ran" — no test script, no runner, no test project. Not a red
|
|
3134
|
+
# suite, and the callers gate on it rather than sending a fix loop after nothing.
|
|
3135
|
+
setup_reason=$(printf '%s' "$helper_out" | python3 -c 'import json,sys
|
|
3136
|
+
try: sys.stdout.write(json.load(sys.stdin).get("reason", ""))
|
|
3137
|
+
except Exception: pass' 2>/dev/null || true)
|
|
3138
|
+
[ -n "$setup_reason" ] && no_setup=true
|
|
3139
|
+
fi
|
|
3140
|
+
rm -f "$log"
|
|
3141
|
+
printf '{"ok":%s,"tool":"%s","ran":%s,"scope":"%s","slices":"%s","scoped":%s,"matched":%s,"fence_green":%s,"cmd":"%s","log_tail":"%s","failing_excerpt":"%s","failing_lanes":[],"no_setup":%s,"setup_reason":"%s","setup_missing":[]}\n' \
|
|
3042
3142
|
"$ok" "$(json_escape "$tool")" "$ran" "$(json_escape "$scope")" "$(json_escape "$slices")" \
|
|
3043
|
-
"$scoped" "$matched" "$fence_green" "$(json_escape "$cmd")" "$(json_escape "$t")"
|
|
3143
|
+
"$scoped" "$matched" "$fence_green" "$(json_escape "$cmd")" "$(json_escape "$t")" \
|
|
3144
|
+
"$(json_escape "$excerpt")" "$no_setup" "$(json_escape "$setup_reason")"
|
|
3044
3145
|
}
|
|
3045
3146
|
|
|
3046
3147
|
cmd_run_migrate() {
|
|
@@ -3296,6 +3397,349 @@ cmd_cr_wave() {
|
|
|
3296
3397
|
"$(json_escape "$dspec")" "$(json_escape "$dmodel")"
|
|
3297
3398
|
}
|
|
3298
3399
|
|
|
3400
|
+
# ──────────────────────────── cr-wave-derive ───────────────────────────────
|
|
3401
|
+
# THE WAVE A CHANGE'S OWN DOCUMENTS IMPLY, from their `## Depends on` sections.
|
|
3402
|
+
#
|
|
3403
|
+
# cr-setup stamps a new change at the highest wave already open, which is right for a
|
|
3404
|
+
# change that stands alone and wrong for one that builds on another: a chained delta lands
|
|
3405
|
+
# at its predecessor's wave, where the blocker fence reads the two as independent and lets
|
|
3406
|
+
# them merge in either order. The delta documents already say otherwise — an author writes
|
|
3407
|
+
# the predecessor under `## Depends on`, with a reason — and nothing acted on it.
|
|
3408
|
+
#
|
|
3409
|
+
# This makes that declaration the ordering authority: required wave = the highest wave
|
|
3410
|
+
# among the changes named there, plus one; 1 when it names none. `specify` stamps it once
|
|
3411
|
+
# the model delta is authored, and the merge fence checks it, so the number and the prose
|
|
3412
|
+
# cannot drift apart. A change sitting HIGHER than required is fine and left alone — that
|
|
3413
|
+
# is deliberate slack, not a contradiction.
|
|
3414
|
+
cmd_cr_wave_derive() {
|
|
3415
|
+
local cr_id="" feature_dir_arg="" stamp=false
|
|
3416
|
+
while [ $# -gt 0 ]; do
|
|
3417
|
+
case "$1" in
|
|
3418
|
+
--cr-id) cr_id="$2"; shift 2 ;;
|
|
3419
|
+
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
3420
|
+
--stamp) stamp=true; shift ;;
|
|
3421
|
+
--help|-h) echo "cr-wave-derive --cr-id <id> [--feature-dir <path>] [--stamp] -> {wave, required_wave, ok, depends_on[], unknown[], stamped, message}"; return 0 ;;
|
|
3422
|
+
*) echo "cr-wave-derive: unknown arg '$1'" >&2; return 1 ;;
|
|
3423
|
+
esac
|
|
3424
|
+
done
|
|
3425
|
+
cr_id=$(_cr_id_token "$cr_id")
|
|
3426
|
+
[ -n "$cr_id" ] || { echo "cr-wave-derive: --cr-id is required" >&2; return 1; }
|
|
3427
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
3428
|
+
local fd="$feature_dir_arg"
|
|
3429
|
+
[ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
|
|
3430
|
+
[ -n "$fd" ] || { echo "cr-wave-derive: no feature set and no --feature-dir" >&2; return 1; }
|
|
3431
|
+
[[ "$fd" != /* ]] && fd="$repo_root/$fd"
|
|
3432
|
+
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
3433
|
+
AW_CR="$cr_id" AW_FD="$fd" AW_CTX="$ctx_dir" AW_STAMP="$stamp" python3 - <<'PY'
|
|
3434
|
+
import glob, json, os, re
|
|
3435
|
+
|
|
3436
|
+
cr = os.environ["AW_CR"]
|
|
3437
|
+
fd = os.environ["AW_FD"]
|
|
3438
|
+
ctx = os.environ["AW_CTX"]
|
|
3439
|
+
stamp = os.environ.get("AW_STAMP", "") == "true"
|
|
3440
|
+
|
|
3441
|
+
CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
|
|
3442
|
+
WAVE_RX = re.compile(r"^\*\*Wave\*\*:\s*(\d+)", re.M)
|
|
3443
|
+
|
|
3444
|
+
|
|
3445
|
+
def read(path):
|
|
3446
|
+
try:
|
|
3447
|
+
return open(path, encoding="utf-8").read()
|
|
3448
|
+
except Exception:
|
|
3449
|
+
return ""
|
|
3450
|
+
|
|
3451
|
+
|
|
3452
|
+
def wave_of(cr_id):
|
|
3453
|
+
"""A change states its wave in the header of its own delta documents."""
|
|
3454
|
+
for p in (os.path.join(ctx, "domain-model.%s.delta.md" % cr_id),
|
|
3455
|
+
os.path.join(fd, "changes", cr_id, "delta-spec.md")):
|
|
3456
|
+
m = WAVE_RX.search(read(p))
|
|
3457
|
+
if m:
|
|
3458
|
+
return int(m.group(1))
|
|
3459
|
+
for p in glob.glob(os.path.join(glob.escape(os.path.join(fd, "changes", cr_id)), "delta-*.md")):
|
|
3460
|
+
m = WAVE_RX.search(read(p))
|
|
3461
|
+
if m:
|
|
3462
|
+
return int(m.group(1))
|
|
3463
|
+
return None
|
|
3464
|
+
|
|
3465
|
+
|
|
3466
|
+
def depends_ids(text):
|
|
3467
|
+
"""The CR ids named under `## Depends on`, up to the next heading of that level."""
|
|
3468
|
+
m = re.search(r"^##\s+Depends on\s*$(.*?)(?=^##\s|\Z)", text, re.M | re.S)
|
|
3469
|
+
if not m:
|
|
3470
|
+
return []
|
|
3471
|
+
body = re.sub(r"<!--.*?-->", " ", m.group(1), flags=re.S)
|
|
3472
|
+
return [i for i in CR_RX.findall(body) if i != cr]
|
|
3473
|
+
|
|
3474
|
+
|
|
3475
|
+
change_dir = os.path.join(fd, "changes", cr)
|
|
3476
|
+
docs = [os.path.join(ctx, "domain-model.%s.delta.md" % cr),
|
|
3477
|
+
os.path.join(change_dir, "delta-spec.md")]
|
|
3478
|
+
docs += sorted(glob.glob(os.path.join(glob.escape(change_dir), "delta-*plan*.md")))
|
|
3479
|
+
present = [p for p in docs if os.path.isfile(p)]
|
|
3480
|
+
|
|
3481
|
+
deps, seen = [], set()
|
|
3482
|
+
for p in present:
|
|
3483
|
+
for i in depends_ids(read(p)):
|
|
3484
|
+
if i not in seen:
|
|
3485
|
+
seen.add(i)
|
|
3486
|
+
deps.append(i)
|
|
3487
|
+
|
|
3488
|
+
known, unknown = [], []
|
|
3489
|
+
for d in deps:
|
|
3490
|
+
w = wave_of(d)
|
|
3491
|
+
(known if w is not None else unknown).append({"cr_id": d, "wave": w})
|
|
3492
|
+
required = max([d["wave"] for d in known], default=0) + 1
|
|
3493
|
+
current = wave_of(cr)
|
|
3494
|
+
ok = current is not None and current >= required
|
|
3495
|
+
|
|
3496
|
+
stamped = []
|
|
3497
|
+
if stamp and current is not None and current < required:
|
|
3498
|
+
line = "**Wave**: %d" % required
|
|
3499
|
+
pat = re.compile(r"^\*\*Wave\*\*:.*$", re.M)
|
|
3500
|
+
for p in present:
|
|
3501
|
+
text = read(p)
|
|
3502
|
+
if pat.search(text):
|
|
3503
|
+
open(p, "w", encoding="utf-8").write(pat.sub(lambda _m: line, text, count=1))
|
|
3504
|
+
stamped.append(os.path.basename(p))
|
|
3505
|
+
if stamped:
|
|
3506
|
+
current, ok = required, True
|
|
3507
|
+
|
|
3508
|
+
if not deps:
|
|
3509
|
+
msg = "%s declares no predecessor, so wave %d is the floor; it carries %s" % (
|
|
3510
|
+
cr, required, current if current is not None else "no wave")
|
|
3511
|
+
elif stamped:
|
|
3512
|
+
msg = "%s depends on %s, so it was raised to wave %d in %s" % (
|
|
3513
|
+
cr, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
|
|
3514
|
+
required, ", ".join(stamped))
|
|
3515
|
+
elif ok:
|
|
3516
|
+
msg = "%s is at wave %s, at or above the %d its `## Depends on` requires" % (
|
|
3517
|
+
cr, current, required)
|
|
3518
|
+
else:
|
|
3519
|
+
msg = ("%s is at wave %s but depends on %s, so it must sit at wave %d — a predecessor "
|
|
3520
|
+
"at the same or a higher wave is not ordered behind it at all" % (
|
|
3521
|
+
cr, current, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
|
|
3522
|
+
required))
|
|
3523
|
+
if unknown:
|
|
3524
|
+
msg += ". Named but not found open: %s" % ", ".join(u["cr_id"] for u in unknown)
|
|
3525
|
+
|
|
3526
|
+
print(json.dumps({
|
|
3527
|
+
"cr_id": cr, "wave": current, "required_wave": required, "ok": ok,
|
|
3528
|
+
"depends_on": known, "unknown": unknown, "depends_count": len(deps),
|
|
3529
|
+
"stamped": stamped, "documents": [os.path.basename(p) for p in present],
|
|
3530
|
+
"message": msg,
|
|
3531
|
+
}))
|
|
3532
|
+
PY
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
# ────────────────────────────── cr-rebase ──────────────────────────────────
|
|
3536
|
+
# RE-STAMP a change's base versions onto the masters it is being merged into — but only
|
|
3537
|
+
# when nothing that landed in between touched what this delta touches.
|
|
3538
|
+
#
|
|
3539
|
+
# `**Base version**` is written once, by cr-setup, and the merge fence refuses when it no
|
|
3540
|
+
# longer matches the master. Nothing could ever update it: every other site reads it, and
|
|
3541
|
+
# every authoring prompt forbids the model from writing it, so the only documented way out
|
|
3542
|
+
# of that refusal was hand-editing the delta-spec in the middle of an automated loop.
|
|
3543
|
+
#
|
|
3544
|
+
# WHAT "APPLIES CLEANLY" MEANS HERE, because a stamp that is not earned is worse than a
|
|
3545
|
+
# refusal: the masters moved, and every change that landed since this delta was stamped
|
|
3546
|
+
# touched OTHER aggregates than the ones this delta names. Then the delta still describes
|
|
3547
|
+
# the document it was written against, and the stamp is bookkeeping. A landed change
|
|
3548
|
+
# sharing an aggregate — or one whose own model delta cannot be read — leaves it refused,
|
|
3549
|
+
# and a human rebases. `**Base context version**` is compared and re-stamped the same way,
|
|
3550
|
+
# which is the first time anything reads it.
|
|
3551
|
+
cmd_cr_rebase() {
|
|
3552
|
+
local cr_id="" feature_dir_arg="" stamp=false
|
|
3553
|
+
while [ $# -gt 0 ]; do
|
|
3554
|
+
case "$1" in
|
|
3555
|
+
--cr-id) cr_id="$2"; shift 2 ;;
|
|
3556
|
+
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
3557
|
+
--stamp) stamp=true; shift ;;
|
|
3558
|
+
--help|-h) echo "cr-rebase --cr-id <id> [--feature-dir <path>] [--stamp] -> {ok, clean, moved, context_moved, base_version, current_version, landed_since[], conflicts[], stamped[], message}"; return 0 ;;
|
|
3559
|
+
*) echo "cr-rebase: unknown arg '$1'" >&2; return 1 ;;
|
|
3560
|
+
esac
|
|
3561
|
+
done
|
|
3562
|
+
cr_id=$(_cr_id_token "$cr_id")
|
|
3563
|
+
[ -n "$cr_id" ] || { echo "cr-rebase: --cr-id is required" >&2; return 1; }
|
|
3564
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
3565
|
+
local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-rebase: no feature set and no --feature-dir" >&2; return 1; }
|
|
3566
|
+
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
3567
|
+
AW_CR="$cr_id" AW_FD="$fd" AW_CTX="$ctx_dir" AW_STAMP="$stamp" python3 - <<'PY'
|
|
3568
|
+
import glob, json, os, re
|
|
3569
|
+
|
|
3570
|
+
cr = os.environ["AW_CR"]
|
|
3571
|
+
fd = os.environ["AW_FD"]
|
|
3572
|
+
ctx = os.environ["AW_CTX"]
|
|
3573
|
+
stamp = os.environ.get("AW_STAMP", "") == "true"
|
|
3574
|
+
|
|
3575
|
+
CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
|
|
3576
|
+
TICK_RX = re.compile(r"`([^`]+)`")
|
|
3577
|
+
|
|
3578
|
+
|
|
3579
|
+
def read(path):
|
|
3580
|
+
try:
|
|
3581
|
+
return open(path, encoding="utf-8").read()
|
|
3582
|
+
except Exception:
|
|
3583
|
+
return ""
|
|
3584
|
+
|
|
3585
|
+
|
|
3586
|
+
def header(text, name):
|
|
3587
|
+
m = re.search(r"^\*\*%s\*\*:[ \t]*(.+?)[ \t]*(?:<!--.*)?$" % re.escape(name), text, re.M)
|
|
3588
|
+
if not m:
|
|
3589
|
+
return ""
|
|
3590
|
+
v = m.group(1).strip()
|
|
3591
|
+
return "" if (v.startswith("[") and v.endswith("]")) else v
|
|
3592
|
+
|
|
3593
|
+
|
|
3594
|
+
def norm(v):
|
|
3595
|
+
v = (v or "").strip()
|
|
3596
|
+
return "" if not v else (v if v.startswith("v") else "v" + v)
|
|
3597
|
+
|
|
3598
|
+
|
|
3599
|
+
def as_tuple(v):
|
|
3600
|
+
parts = re.findall(r"\d+", v or "")
|
|
3601
|
+
return tuple(int(p) for p in parts) if parts else None
|
|
3602
|
+
|
|
3603
|
+
|
|
3604
|
+
def aggregates(text):
|
|
3605
|
+
"""The `**Aggregates touched**:` header — the list finalize-delta opens sections for."""
|
|
3606
|
+
m = re.search(r"^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$", text, re.M)
|
|
3607
|
+
if not m:
|
|
3608
|
+
return set()
|
|
3609
|
+
value = re.sub(r"<!--.*?(?:-->|$)", "", m.group(1)).strip()
|
|
3610
|
+
names = TICK_RX.findall(value)
|
|
3611
|
+
if not names:
|
|
3612
|
+
names = [p.strip() for p in re.split(r"[,;/&]|\band\b", value) if p.strip()]
|
|
3613
|
+
names = [re.match(r"[A-Za-z_][A-Za-z0-9_]*", n).group(0)
|
|
3614
|
+
for n in names if re.match(r"[A-Za-z_][A-Za-z0-9_]*", n)]
|
|
3615
|
+
return {n for n in names if not n.startswith(("<", "[")) and not re.match(r"(?i)none\b", n)}
|
|
3616
|
+
|
|
3617
|
+
|
|
3618
|
+
def landed_since(text, base):
|
|
3619
|
+
"""CR ids in Change History rows whose version is ABOVE `base` — what moved the master
|
|
3620
|
+
since this delta was stamped. A row with no readable version counts, because a row this
|
|
3621
|
+
cannot place is one it cannot prove is older."""
|
|
3622
|
+
ids, base_t = [], as_tuple(base)
|
|
3623
|
+
for line in text.split("\n"):
|
|
3624
|
+
if not line.strip().startswith("|"):
|
|
3625
|
+
continue
|
|
3626
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
3627
|
+
if len(cells) < 2 or set("".join(cells)) <= set("-: "):
|
|
3628
|
+
continue
|
|
3629
|
+
row_t = as_tuple(cells[0]) if re.match(r"^v?\d", cells[0]) else None
|
|
3630
|
+
if row_t is None:
|
|
3631
|
+
continue
|
|
3632
|
+
if base_t is not None and row_t <= base_t:
|
|
3633
|
+
continue
|
|
3634
|
+
for i in CR_RX.findall(line):
|
|
3635
|
+
if i != cr and i not in ids:
|
|
3636
|
+
ids.append(i)
|
|
3637
|
+
return ids
|
|
3638
|
+
|
|
3639
|
+
|
|
3640
|
+
def model_delta_of(cr_id):
|
|
3641
|
+
"""A change's model half: beside the master while open, in its archive once closed."""
|
|
3642
|
+
p = os.path.join(ctx, "domain-model.%s.delta.md" % cr_id)
|
|
3643
|
+
if os.path.isfile(p):
|
|
3644
|
+
return read(p)
|
|
3645
|
+
for feat in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
|
|
3646
|
+
a = os.path.join(ctx, feat, "changes", "archive", cr_id, "domain-model.delta.md")
|
|
3647
|
+
if os.path.isfile(a):
|
|
3648
|
+
return read(a)
|
|
3649
|
+
return ""
|
|
3650
|
+
|
|
3651
|
+
|
|
3652
|
+
my_spec = os.path.join(fd, "changes", cr, "delta-spec.md")
|
|
3653
|
+
my_model = os.path.join(ctx, "domain-model.%s.delta.md" % cr)
|
|
3654
|
+
spec_master = os.path.join(fd, "spec.md")
|
|
3655
|
+
model_master = os.path.join(ctx, "domain-model.md")
|
|
3656
|
+
|
|
3657
|
+
spec_text, model_text = read(my_spec), read(my_model)
|
|
3658
|
+
base_version = norm(header(spec_text, "Base version"))
|
|
3659
|
+
base_ctx = norm(header(model_text, "Base context version"))
|
|
3660
|
+
|
|
3661
|
+
current = ""
|
|
3662
|
+
try:
|
|
3663
|
+
current = norm(json.load(open(os.path.join(fd, "active_version.json"), encoding="utf-8")).get("version", ""))
|
|
3664
|
+
except Exception:
|
|
3665
|
+
current = ""
|
|
3666
|
+
if not current:
|
|
3667
|
+
current = norm(header(read(spec_master), "Version"))
|
|
3668
|
+
current_ctx = norm(header(read(model_master), "Version"))
|
|
3669
|
+
|
|
3670
|
+
moved = bool(base_version and current and base_version != current)
|
|
3671
|
+
ctx_moved = bool(base_ctx and current_ctx and base_ctx != current_ctx)
|
|
3672
|
+
|
|
3673
|
+
# WHAT LANDED IN BETWEEN, and whether any of it touched what this delta touches. A delta
|
|
3674
|
+
# whose aggregates nobody else moved still applies to the master it was written against;
|
|
3675
|
+
# one whose aggregates a landed change rewrote does not, and no stamp can make it.
|
|
3676
|
+
mine = aggregates(model_text)
|
|
3677
|
+
intervening = landed_since(read(spec_master), base_version) if moved else []
|
|
3678
|
+
if ctx_moved:
|
|
3679
|
+
for i in landed_since(read(model_master), base_ctx):
|
|
3680
|
+
if i not in intervening:
|
|
3681
|
+
intervening.append(i)
|
|
3682
|
+
|
|
3683
|
+
rows, conflicts = [], []
|
|
3684
|
+
for i in intervening:
|
|
3685
|
+
theirs = aggregates(model_delta_of(i))
|
|
3686
|
+
shared = sorted(mine & theirs) if (mine and theirs) else []
|
|
3687
|
+
# Unknown aggregates fail CLOSED: a change this cannot read is one it cannot clear.
|
|
3688
|
+
unknown = not theirs
|
|
3689
|
+
rows.append({"cr_id": i, "aggregates": sorted(theirs), "shared": shared, "unknown": unknown})
|
|
3690
|
+
if shared or unknown:
|
|
3691
|
+
conflicts.append({"cr_id": i, "shared": shared, "unknown": unknown})
|
|
3692
|
+
|
|
3693
|
+
clean = (moved or ctx_moved) and not conflicts
|
|
3694
|
+
stamped = []
|
|
3695
|
+
if stamp and clean:
|
|
3696
|
+
if moved and os.path.isfile(my_spec):
|
|
3697
|
+
open(my_spec, "w", encoding="utf-8").write(
|
|
3698
|
+
re.sub(r"^\*\*Base version\*\*:.*$", "**Base version**: %s" % current,
|
|
3699
|
+
spec_text, count=1, flags=re.M))
|
|
3700
|
+
stamped.append({"file": os.path.basename(my_spec), "header": "Base version",
|
|
3701
|
+
"from": base_version, "to": current})
|
|
3702
|
+
if ctx_moved and os.path.isfile(my_model):
|
|
3703
|
+
open(my_model, "w", encoding="utf-8").write(
|
|
3704
|
+
re.sub(r"^\*\*Base context version\*\*:.*$", "**Base context version**: %s" % current_ctx,
|
|
3705
|
+
model_text, count=1, flags=re.M))
|
|
3706
|
+
stamped.append({"file": os.path.basename(my_model), "header": "Base context version",
|
|
3707
|
+
"from": base_ctx, "to": current_ctx})
|
|
3708
|
+
if stamped:
|
|
3709
|
+
moved = ctx_moved = False
|
|
3710
|
+
|
|
3711
|
+
ok = not (moved or ctx_moved)
|
|
3712
|
+
if not (base_version or base_ctx):
|
|
3713
|
+
msg = "%s carries no base stamp, so there is nothing to compare — treated as not moved" % cr
|
|
3714
|
+
elif ok and not stamped:
|
|
3715
|
+
msg = "%s is stamped against the current masters (%s, context %s)" % (cr, current or "—", current_ctx or "—")
|
|
3716
|
+
elif stamped:
|
|
3717
|
+
msg = "%s was re-stamped: %s. Nothing that landed in between (%s) touched its aggregates (%s)" % (
|
|
3718
|
+
cr, "; ".join("%s %s → %s" % (s["header"], s["from"], s["to"]) for s in stamped),
|
|
3719
|
+
", ".join(r["cr_id"] for r in rows) or "no recorded change", ", ".join(sorted(mine)) or "none named")
|
|
3720
|
+
else:
|
|
3721
|
+
parts = []
|
|
3722
|
+
for c in conflicts:
|
|
3723
|
+
parts.append("%s (%s)" % (c["cr_id"], "aggregates unreadable" if c["unknown"]
|
|
3724
|
+
else "also touched " + ", ".join(c["shared"])))
|
|
3725
|
+
msg = ("%s was authored against %s%s but the masters are at %s%s, and what landed in "
|
|
3726
|
+
"between overlaps it: %s" % (
|
|
3727
|
+
cr, base_version or "—", "" if not base_ctx else " (context %s)" % base_ctx,
|
|
3728
|
+
current or "—", "" if not current_ctx else " (context %s)" % current_ctx,
|
|
3729
|
+
"; ".join(parts)))
|
|
3730
|
+
|
|
3731
|
+
print(json.dumps({
|
|
3732
|
+
"cr_id": cr, "ok": ok, "clean": clean, "moved": moved, "context_moved": ctx_moved,
|
|
3733
|
+
"base_version": base_version, "current_version": current,
|
|
3734
|
+
"base_context_version": base_ctx, "current_context_version": current_ctx,
|
|
3735
|
+
"aggregates": sorted(mine), "landed_since": rows,
|
|
3736
|
+
"conflicts": conflicts, "conflict_count": len(conflicts),
|
|
3737
|
+
"conflict_ids": ", ".join(c["cr_id"] for c in conflicts),
|
|
3738
|
+
"stamped": stamped, "message": msg,
|
|
3739
|
+
}))
|
|
3740
|
+
PY
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3299
3743
|
# ─────────────────────────────── cr-list ───────────────────────────────────
|
|
3300
3744
|
# The changes currently OPEN on a feature, in wave order. This is what lets a delta
|
|
3301
3745
|
# stage ASK which change it is working on instead of inferring one: many may be open at
|
|
@@ -3539,11 +3983,12 @@ PY
|
|
|
3539
3983
|
|
|
3540
3984
|
# ─────────────────────────────── cr-overlap ────────────────────────────────
|
|
3541
3985
|
# Which OTHER open changes touch the same aggregates as this one — read off the
|
|
3542
|
-
#
|
|
3543
|
-
# domain-model.md
|
|
3986
|
+
# `**Aggregates touched**:` header of each pending model delta. That header names the
|
|
3987
|
+
# `## Aggregate: <Name>` sections of domain-model.md the delta merges into; the delta
|
|
3988
|
+
# itself carries no such headings, the master does.
|
|
3544
3989
|
#
|
|
3545
3990
|
# DETERMINISTIC ON PURPOSE. Two deltas collide when they change the same aggregate, and
|
|
3546
|
-
# that is a fact about two files'
|
|
3991
|
+
# that is a fact about two files' headers — a regex answers it exactly, at no cost, and
|
|
3547
3992
|
# the same way every run. Handing the question to a model instead would make the one
|
|
3548
3993
|
# check that can be exact the one check that can hallucinate.
|
|
3549
3994
|
#
|
|
@@ -3586,14 +4031,27 @@ def read(p):
|
|
|
3586
4031
|
def wave_of(text, default=1):
|
|
3587
4032
|
m = re.search(r"^\*\*Wave\*\*:\s*(\d+)", text, re.M)
|
|
3588
4033
|
return int(m.group(1)) if m else default
|
|
3589
|
-
# The
|
|
3590
|
-
#
|
|
3591
|
-
# "
|
|
4034
|
+
# The aggregates a model delta declares, from its `**Aggregates touched**:` header — the
|
|
4035
|
+
# same list finalize-delta opens master sections for. Backticked names are the template's
|
|
4036
|
+
# form and win when present, so prose around them ("`Appointment` (lifecycle), `Patient`
|
|
4037
|
+
# (read)") adds nothing; a value with no backticks is split on , ; / & "and" and each
|
|
4038
|
+
# piece's leading name is taken. The template placeholder (`[Root]`) and "None — behaviour
|
|
4039
|
+
# only" name no aggregate, so an unfilled or model-free delta reports none rather than
|
|
4040
|
+
# colliding with every other change.
|
|
3592
4041
|
def aggregates_of(text):
|
|
4042
|
+
m = re.search(r"^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$", text, re.M)
|
|
4043
|
+
if not m:
|
|
4044
|
+
return []
|
|
4045
|
+
value = re.sub(r"<!--.*?(?:-->|$)", "", m.group(1)).strip()
|
|
4046
|
+
names = re.findall(r"`([^`]+)`", value)
|
|
4047
|
+
if not names:
|
|
4048
|
+
names = [p.group(0) for p in
|
|
4049
|
+
(re.match(r"[A-Za-z][A-Za-z0-9_]*", piece.strip())
|
|
4050
|
+
for piece in re.split(r"[,;/&]|\band\b", value)) if p]
|
|
3593
4051
|
out = []
|
|
3594
|
-
for
|
|
3595
|
-
name =
|
|
3596
|
-
if not name or name
|
|
4052
|
+
for name in names:
|
|
4053
|
+
name = name.strip()
|
|
4054
|
+
if not name or name[0] in "<[" or re.match(r"none\b", name, re.I):
|
|
3597
4055
|
continue
|
|
3598
4056
|
if name not in out:
|
|
3599
4057
|
out.append(name)
|
|
@@ -4049,17 +4507,28 @@ for f in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
|
|
|
4049
4507
|
blockers = [v for k, v in sorted(opens.items()) if k != cr and v["wave"] < wave]
|
|
4050
4508
|
|
|
4051
4509
|
# THE MASTERS CAN MOVE WITHOUT THE VERSION MOVING. finalize-delta merges and close-delta
|
|
4052
|
-
# bumps, so between the two
|
|
4053
|
-
# second delta sharing the same base_version would compare equal and merge onto masters
|
|
4054
|
-
# had already shifted under it.
|
|
4055
|
-
#
|
|
4056
|
-
#
|
|
4057
|
-
|
|
4510
|
+
# bumps, so between the two the masters carry a change the number does not yet reflect — and
|
|
4511
|
+
# a second delta sharing the same base_version would compare equal and merge onto masters
|
|
4512
|
+
# that had already shifted under it. The proof is the Change History row finalize-delta
|
|
4513
|
+
# appends, carrying the id in its CR cell.
|
|
4514
|
+
#
|
|
4515
|
+
# aw-crstate owns that test, for every reader of it. It replaces `cr in spec.md` — which
|
|
4516
|
+
# fired on an id quoted in prose and matched a prefix of a longer id — and it looks where a
|
|
4517
|
+
# merge can actually show: this feature's spec AND the context model every feature shares,
|
|
4518
|
+
# so a SIBLING feature's merged-unclosed change is no longer invisible. A sibling blocks
|
|
4519
|
+
# only where it touched an aggregate this delta also names (`blocking`); a merge in this
|
|
4520
|
+
# feature blocks outright, because it moved this delta's own master.
|
|
4521
|
+
merged_open = []
|
|
4058
4522
|
try:
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4523
|
+
import importlib.util
|
|
4524
|
+
_mod_path = os.path.join(root, ".appweaver", "scripts", "python", "aw-crstate.py")
|
|
4525
|
+
_spec = importlib.util.spec_from_file_location("aw_crstate", _mod_path)
|
|
4526
|
+
_m = importlib.util.module_from_spec(_spec)
|
|
4527
|
+
_spec.loader.exec_module(_m)
|
|
4528
|
+
merged_open = [r for r in _m.merged_open(ctx, fd, cr) if r["blocking"]]
|
|
4529
|
+
except Exception as _e:
|
|
4530
|
+
# Fail CLOSED on an unreadable helper: report the state as blocking rather than clear.
|
|
4531
|
+
merged_open = [{"cr_id": "(merged-open check failed: %s)" % _e, "blocking": True}]
|
|
4063
4532
|
|
|
4064
4533
|
print(json.dumps({
|
|
4065
4534
|
"ok": True, "cr_id": cr, "wave": wave,
|
|
@@ -4111,9 +4580,11 @@ cmd_cr_recall_check() {
|
|
|
4111
4580
|
[ -n "$cr_id" ] || { echo "cr-recall-check: --cr-id required" >&2; return 1; }
|
|
4112
4581
|
local repo_root; repo_root=$(get_repo_root)
|
|
4113
4582
|
local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-recall-check: no feature set and no --feature-dir" >&2; return 1; }
|
|
4114
|
-
|
|
4583
|
+
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
4584
|
+
AW_FD="$fd" AW_CR="$cr_id" AW_ROOT="$repo_root" AW_CTX="$ctx_dir" python3 - <<'PY'
|
|
4115
4585
|
import json, os, re
|
|
4116
4586
|
fd = os.environ["AW_FD"]; cr = os.environ["AW_CR"]
|
|
4587
|
+
root = os.environ.get("AW_ROOT", ""); ctx = os.environ.get("AW_CTX", "")
|
|
4117
4588
|
changes = os.path.join(fd, "changes")
|
|
4118
4589
|
cr_dir = os.path.join(changes, cr)
|
|
4119
4590
|
|
|
@@ -4126,13 +4597,24 @@ def wave_of(d):
|
|
|
4126
4597
|
return int(m.group(1))
|
|
4127
4598
|
return 1
|
|
4128
4599
|
|
|
4600
|
+
# MERGED = a Change History row whose CR cell IS this change, in this feature's spec or in
|
|
4601
|
+
# the context model every feature shares — aw-crstate owns that test for all three readers
|
|
4602
|
+
# of it. The old test was `cr in line`, which fired on an id quoted in prose and matched a
|
|
4603
|
+
# prefix of a longer id.
|
|
4129
4604
|
spec = os.path.join(fd, "spec.md")
|
|
4130
|
-
merged, evidence = False, ""
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4605
|
+
merged, evidence, merged_doc = False, "", ""
|
|
4606
|
+
try:
|
|
4607
|
+
import importlib.util
|
|
4608
|
+
_mod_path = os.path.join(root, ".appweaver", "scripts", "python", "aw-crstate.py")
|
|
4609
|
+
_spec = importlib.util.spec_from_file_location("aw_crstate", _mod_path)
|
|
4610
|
+
_m = importlib.util.module_from_spec(_spec)
|
|
4611
|
+
_spec.loader.exec_module(_m)
|
|
4612
|
+
evidence, merged_doc = _m.merged_in([spec, os.path.join(ctx, "domain-model.md")], cr)
|
|
4613
|
+
merged = bool(evidence)
|
|
4614
|
+
except Exception:
|
|
4615
|
+
# Fail CLOSED: an unreadable helper must not report "never merged", which is the
|
|
4616
|
+
# answer that lets close-delta archive a change nobody merged.
|
|
4617
|
+
merged, evidence = True, "(merged check unavailable — treated as merged)"
|
|
4136
4618
|
|
|
4137
4619
|
wave = wave_of(cr_dir) if os.path.isdir(cr_dir) else 1
|
|
4138
4620
|
dependants = []
|
|
@@ -4174,31 +4656,87 @@ PY
|
|
|
4174
4656
|
# WHY THE GATE AND NOT THE AUTHOR. Stamping at `plan` would record "this plan was authored
|
|
4175
4657
|
# from that spec". The question implement depends on is stronger — "were these plans PROVEN
|
|
4176
4658
|
# against this spec" — and a plan authored from the current spec can still be wrong. So the
|
|
4177
|
-
# receipt is written where the proof happens, by analyze-plan,
|
|
4178
|
-
#
|
|
4179
|
-
#
|
|
4659
|
+
# receipt is written where the proof happens, by analyze-plan, and it hashes every document
|
|
4660
|
+
# the gate actually read: the spec, the context's domain model (check 4 reads it) and each
|
|
4661
|
+
# lane plan that was present.
|
|
4662
|
+
#
|
|
4663
|
+
# THE RECEIPT CARRIES THE VERDICT, and every master-mode run writes one. A receipt written
|
|
4664
|
+
# only on a clean pass is never written on a feature whose spec misses a domain constraint —
|
|
4665
|
+
# a LIFT-FAILURE analyze-plan may not close — and then the staleness check has nothing to
|
|
4666
|
+
# compare. The verdict splits the gaps by who can close them (plan-gaps):
|
|
4667
|
+
# conformant no gaps
|
|
4668
|
+
# routed-gaps only gaps /appweaver specify must close — implement warns and generates
|
|
4669
|
+
# gaps at least one gap a plan rewrite closes — implement refuses
|
|
4180
4670
|
#
|
|
4181
4671
|
# The absence of a receipt is INFORMATION, not an error: it means this feature has never
|
|
4182
4672
|
# been through the gate, which every tree that predates this op is true of. It is reported
|
|
4183
|
-
# as untracked and blocks nothing.
|
|
4184
|
-
#
|
|
4673
|
+
# as untracked and blocks nothing. A receipt that EXISTS refuses when the bytes have moved
|
|
4674
|
+
# since, or when its verdict is `gaps`. A receipt written before the verdict existed was
|
|
4675
|
+
# written on a clean pass, and reads as `conformant`.
|
|
4185
4676
|
_plan_gate_file() { printf '%s/.aw-plan-gate.json' "$1"; }
|
|
4186
4677
|
|
|
4678
|
+
# Which analyze-plan gaps a plan rewrite can close, and which only /appweaver specify can.
|
|
4679
|
+
# A gap routes to specify when the analyze pass tagged it `route: specify` (a constraint the
|
|
4680
|
+
# spec never lifted, structure domain-model.md does not define, behaviour the spec does not
|
|
4681
|
+
# describe), or when its text opens with LIFT-FAILURE whatever the tag says. Anything else —
|
|
4682
|
+
# an untagged gap included — is a plan gap. Unreadable input is one plan gap: a verdict that
|
|
4683
|
+
# could not be read must not pass.
|
|
4684
|
+
cmd_plan_gaps() {
|
|
4685
|
+
local gaps=""
|
|
4686
|
+
while [ $# -gt 0 ]; do
|
|
4687
|
+
case "$1" in
|
|
4688
|
+
--gaps) gaps="$2"; shift 2 ;;
|
|
4689
|
+
--help|-h) echo "plan-gaps --gaps <json list> -> {verdict, plan_ok, plan_gaps, routed_gaps, routed[], routed_csv}"; return 0 ;;
|
|
4690
|
+
*) echo "plan-gaps: unknown arg '$1'" >&2; return 1 ;;
|
|
4691
|
+
esac
|
|
4692
|
+
done
|
|
4693
|
+
AW_GAPS="$gaps" python3 - <<'PY'
|
|
4694
|
+
import json, os
|
|
4695
|
+
raw = (os.environ.get("AW_GAPS") or "").strip()
|
|
4696
|
+
try:
|
|
4697
|
+
gaps = json.loads(raw) if raw else []
|
|
4698
|
+
if not isinstance(gaps, list):
|
|
4699
|
+
raise ValueError("not a list")
|
|
4700
|
+
readable = True
|
|
4701
|
+
except Exception:
|
|
4702
|
+
gaps, readable = [], False
|
|
4703
|
+
plan, routed = 0, []
|
|
4704
|
+
for g in gaps:
|
|
4705
|
+
d = g if isinstance(g, dict) else {"gap": str(g)}
|
|
4706
|
+
text = str(d.get("gap", "")).strip()
|
|
4707
|
+
if text.upper().startswith("LIFT-FAILURE") or str(d.get("route", "")).strip().lower() == "specify":
|
|
4708
|
+
ref = str(d.get("spec_ref", "") or "").strip()
|
|
4709
|
+
routed.append(("%s: %s" % (ref, text)) if ref and ref != "—" else text)
|
|
4710
|
+
else:
|
|
4711
|
+
plan += 1
|
|
4712
|
+
if not readable:
|
|
4713
|
+
plan += 1
|
|
4714
|
+
verdict = "gaps" if plan else ("routed-gaps" if routed else "conformant")
|
|
4715
|
+
print(json.dumps({"ok": True, "readable": readable, "verdict": verdict, "plan_ok": plan == 0,
|
|
4716
|
+
"plan_gaps": plan, "routed_gaps": len(routed), "routed": routed,
|
|
4717
|
+
"routed_csv": "; ".join(routed)}))
|
|
4718
|
+
PY
|
|
4719
|
+
}
|
|
4720
|
+
|
|
4187
4721
|
cmd_plan_gate_stamp() {
|
|
4188
|
-
local feature_dir_arg="" spec_version="" mode="master"
|
|
4722
|
+
local feature_dir_arg="" spec_version="" mode="master" verdict="conformant" plan_gaps="0" routed="[]"
|
|
4189
4723
|
while [ $# -gt 0 ]; do
|
|
4190
4724
|
case "$1" in
|
|
4191
4725
|
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
4192
4726
|
--spec-version) spec_version="$2"; shift 2 ;;
|
|
4193
4727
|
--mode) mode="$2"; shift 2 ;;
|
|
4194
|
-
--
|
|
4728
|
+
--verdict) verdict="$2"; shift 2 ;;
|
|
4729
|
+
--plan-gaps) plan_gaps="$2"; shift 2 ;;
|
|
4730
|
+
--routed) routed="$2"; shift 2 ;;
|
|
4731
|
+
--help|-h) echo "plan-gate-stamp [--feature-dir <path>] [--spec-version <v>] [--mode <text>] [--verdict conformant|routed-gaps|gaps] [--plan-gaps <n>] [--routed <json list>] -> {file, gated, verdict, hashed[], count}"; return 0 ;;
|
|
4195
4732
|
*) echo "plan-gate-stamp: unknown arg '$1'" >&2; return 1 ;;
|
|
4196
4733
|
esac
|
|
4197
4734
|
done
|
|
4198
4735
|
local repo_root; repo_root=$(get_repo_root)
|
|
4199
4736
|
local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "plan-gate-stamp: no feature set and no --feature-dir" >&2; return 1; }
|
|
4200
4737
|
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
4201
|
-
AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_VER="$spec_version" AW_MODE="$mode"
|
|
4738
|
+
AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_VER="$spec_version" AW_MODE="$mode" \
|
|
4739
|
+
AW_VERDICT="$verdict" AW_PLAN_GAPS="$plan_gaps" AW_ROUTED="$routed" python3 - <<'PY'
|
|
4202
4740
|
import datetime, hashlib, json, os
|
|
4203
4741
|
fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]
|
|
4204
4742
|
def sha(p):
|
|
@@ -4220,21 +4758,34 @@ plans = {}
|
|
|
4220
4758
|
for name in ("backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md"):
|
|
4221
4759
|
h = sha(os.path.join(fd, name))
|
|
4222
4760
|
if h: plans[name] = h
|
|
4761
|
+
# An unrecognised verdict is recorded as `gaps`, the one that refuses.
|
|
4762
|
+
verdict = os.environ.get("AW_VERDICT", "").strip()
|
|
4763
|
+
if verdict not in ("conformant", "routed-gaps", "gaps"):
|
|
4764
|
+
verdict = "gaps"
|
|
4765
|
+
try: plan_gaps = int(os.environ.get("AW_PLAN_GAPS", "0") or 0)
|
|
4766
|
+
except ValueError: plan_gaps = 0
|
|
4767
|
+
try: routed = [str(x) for x in json.loads(os.environ.get("AW_ROUTED") or "[]")]
|
|
4768
|
+
except Exception: routed = []
|
|
4223
4769
|
rec = {
|
|
4224
4770
|
"gated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
4225
4771
|
"gate_mode": os.environ.get("AW_MODE", "master"),
|
|
4226
4772
|
"spec_version": os.environ.get("AW_VER", "").strip(),
|
|
4773
|
+
"verdict": verdict,
|
|
4774
|
+
"plan_gaps": plan_gaps,
|
|
4775
|
+
"routed_gaps": routed,
|
|
4227
4776
|
"spec_sha": sha(os.path.join(fd, "spec.md")),
|
|
4228
4777
|
"model_sha": sha(os.path.join(ctx, "domain-model.md")),
|
|
4229
4778
|
"plans": plans,
|
|
4230
|
-
"note": ("Written by analyze-plan
|
|
4231
|
-
"
|
|
4232
|
-
"are stamped in lockstep by close-delta and cannot answer
|
|
4233
|
-
"reads it through plan-gate-check
|
|
4779
|
+
"note": ("Written by every master-mode analyze-plan run. It records the bytes that were "
|
|
4780
|
+
"checked against each other and the verdict on them, not a version — the plans' "
|
|
4781
|
+
"**Version**: headers are stamped in lockstep by close-delta and cannot answer "
|
|
4782
|
+
"this. implement reads it through plan-gate-check: moved bytes or verdict `gaps` "
|
|
4783
|
+
"refuse; `routed-gaps` lists what /appweaver specify still has to lift."),
|
|
4234
4784
|
}
|
|
4235
4785
|
p = os.path.join(fd, ".aw-plan-gate.json")
|
|
4236
4786
|
open(p, "w", encoding="utf-8").write(json.dumps(rec, indent=2) + "\n")
|
|
4237
4787
|
print(json.dumps({"file": rel(p), "gated": rec["gated"], "spec_version": rec["spec_version"],
|
|
4788
|
+
"verdict": verdict, "plan_gaps": plan_gaps, "routed_gaps": len(routed),
|
|
4238
4789
|
"hashed": sorted(plans.keys()), "count": len(plans), "ok": True}))
|
|
4239
4790
|
PY
|
|
4240
4791
|
}
|
|
@@ -4244,7 +4795,7 @@ cmd_plan_gate_check() {
|
|
|
4244
4795
|
while [ $# -gt 0 ]; do
|
|
4245
4796
|
case "$1" in
|
|
4246
4797
|
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
4247
|
-
--help|-h) echo "plan-gate-check [--feature-dir <path>] -> {tracked, gate_fresh, spec_moved, model_moved, plans_changed[], plans_changed_csv, gated, reason}"; return 0 ;;
|
|
4798
|
+
--help|-h) echo "plan-gate-check [--feature-dir <path>] -> {tracked, gate_fresh, verdict, may_generate, plan_gaps, routed_gaps[], routed_csv, spec_moved, model_moved, plans_changed[], plans_changed_csv, gated, reason}"; return 0 ;;
|
|
4248
4799
|
*) echo "plan-gate-check: unknown arg '$1'" >&2; return 1 ;;
|
|
4249
4800
|
esac
|
|
4250
4801
|
done
|
|
@@ -4263,16 +4814,22 @@ rec = None
|
|
|
4263
4814
|
try: rec = json.load(open(p, encoding="utf-8"))
|
|
4264
4815
|
except Exception: rec = None
|
|
4265
4816
|
out = {"file": os.path.relpath(p, root), "tracked": bool(rec), "gate_fresh": False,
|
|
4817
|
+
"verdict": "", "may_generate": True, "plan_gaps": 0, "routed_gaps": [], "routed_csv": "",
|
|
4266
4818
|
"spec_moved": False, "model_moved": False, "plans_changed": [],
|
|
4267
4819
|
"plans_changed_csv": "", "gated": "", "spec_version": "", "reason": "", "ok": True}
|
|
4268
4820
|
if not rec:
|
|
4269
4821
|
# Never gated. Not a failure and not a stale plan — an unknown, and the only honest
|
|
4270
4822
|
# thing to report about a tree that has not run analyze-plan since this op existed.
|
|
4271
|
-
out["reason"] = ("no .aw-plan-gate.json — this feature has not
|
|
4272
|
-
"since the gate began leaving a receipt")
|
|
4823
|
+
out["reason"] = ("no .aw-plan-gate.json — this feature has not been through analyze-plan "
|
|
4824
|
+
"in master mode since the gate began leaving a receipt")
|
|
4273
4825
|
else:
|
|
4274
4826
|
out["gated"] = rec.get("gated", "")
|
|
4275
4827
|
out["spec_version"] = rec.get("spec_version", "")
|
|
4828
|
+
# A receipt from before the verdict existed was only ever written on a clean pass.
|
|
4829
|
+
out["verdict"] = rec.get("verdict") or "conformant"
|
|
4830
|
+
out["plan_gaps"] = rec.get("plan_gaps") or 0
|
|
4831
|
+
out["routed_gaps"] = [str(x) for x in (rec.get("routed_gaps") or [])]
|
|
4832
|
+
out["routed_csv"] = "; ".join(out["routed_gaps"])
|
|
4276
4833
|
out["spec_moved"] = bool(rec.get("spec_sha")) and rec["spec_sha"] != sha(os.path.join(fd, "spec.md"))
|
|
4277
4834
|
# A model recorded as absent that is still absent has not moved. A context that
|
|
4278
4835
|
# GAINED a domain model since the gate has moved: check 4 never ran against it.
|
|
@@ -4284,18 +4841,48 @@ else:
|
|
|
4284
4841
|
out["plans_changed"] = changed
|
|
4285
4842
|
out["plans_changed_csv"] = ", ".join(changed)
|
|
4286
4843
|
out["gate_fresh"] = not (out["spec_moved"] or out["model_moved"] or changed)
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4844
|
+
# Moved bytes refuse whatever the verdict was; unmoved bytes refuse only on `gaps`.
|
|
4845
|
+
out["may_generate"] = out["gate_fresh"] and out["verdict"] != "gaps"
|
|
4846
|
+
if not out["gate_fresh"]:
|
|
4290
4847
|
bits = []
|
|
4291
4848
|
if out["spec_moved"]: bits.append("spec.md has changed")
|
|
4292
4849
|
if out["model_moved"]: bits.append("domain-model.md has changed")
|
|
4293
4850
|
if changed: bits.append("%s changed (a re-derive by `plan main`, or a hand edit)" % ", ".join(changed))
|
|
4294
|
-
out["reason"] = "since analyze-plan
|
|
4851
|
+
out["reason"] = "since analyze-plan checked them on %s: %s" % (out["gated"], "; ".join(bits))
|
|
4852
|
+
elif out["verdict"] == "gaps":
|
|
4853
|
+
out["reason"] = ("analyze-plan on %s left %s gap(s) a plan rewrite closes, unrepaired"
|
|
4854
|
+
% (out["gated"], out["plan_gaps"] or "some"))
|
|
4855
|
+
elif out["verdict"] == "routed-gaps":
|
|
4856
|
+
out["reason"] = ("the spec, the model and every gated plan are byte-identical to what "
|
|
4857
|
+
"analyze-plan checked on %s; it left %d gap(s) only /appweaver specify "
|
|
4858
|
+
"can close: %s" % (out["gated"], len(out["routed_gaps"]), out["routed_csv"]))
|
|
4859
|
+
else:
|
|
4860
|
+
out["reason"] = "the spec, the model and every gated plan are byte-identical to what analyze-plan passed on %s" % out["gated"]
|
|
4295
4861
|
print(json.dumps(out))
|
|
4296
4862
|
PY
|
|
4297
4863
|
}
|
|
4298
4864
|
|
|
4865
|
+
# ──────────────────────────────── plan-coverage ─────────────────────────────
|
|
4866
|
+
# Which of a lane plan's elements the generated code realises: each element the plan
|
|
4867
|
+
# names, whether the lane's code names it, and whether the generating model reported it
|
|
4868
|
+
# done. implement runs it after each lane generates, so a pass that stopped part-way is
|
|
4869
|
+
# caught instead of looking like a finished one. One implementation
|
|
4870
|
+
# (python/aw-plan-coverage.py), called identically by both shells.
|
|
4871
|
+
cmd_plan_coverage() {
|
|
4872
|
+
case "${1:-}" in
|
|
4873
|
+
--help|-h) echo "plan-coverage --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--source-path <p>] [--test-path <p>] [--reported <json list>]... [--list-only] -> {ok, total, names[], names_csv, elements[], gaps[], gap_names[], gap_count, gaps_text, message}"; return 0 ;;
|
|
4874
|
+
esac
|
|
4875
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
4876
|
+
local core="$SCRIPT_DIR/../python/aw-plan-coverage.py"
|
|
4877
|
+
if [ ! -f "$core" ]; then
|
|
4878
|
+
echo "plan-coverage: core not found at $core" >&2; return 1
|
|
4879
|
+
fi
|
|
4880
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
4881
|
+
echo "plan-coverage: python3 is required" >&2; return 1
|
|
4882
|
+
fi
|
|
4883
|
+
python3 "$core" --repo "$repo_root" "$@"
|
|
4884
|
+
}
|
|
4885
|
+
|
|
4299
4886
|
# Stamp a version into the MASTER documents' `**Version**:` headers — the
|
|
4300
4887
|
# deterministic twin of cr-archive's active_version.json bump. close-delta calls
|
|
4301
4888
|
# this with the version cr-archive just computed, so all THREE carriers of the
|
|
@@ -4438,6 +5025,8 @@ case "$sub" in
|
|
|
4438
5025
|
cr-overlap) cmd_cr_overlap "$@" ;;
|
|
4439
5026
|
cr-activate) cmd_cr_activate "$@" ;;
|
|
4440
5027
|
cr-wave) cmd_cr_wave "$@" ;;
|
|
5028
|
+
cr-wave-derive) cmd_cr_wave_derive "$@" ;;
|
|
5029
|
+
cr-rebase) cmd_cr_rebase "$@" ;;
|
|
4441
5030
|
cr-archive) cmd_cr_archive "$@" ;;
|
|
4442
5031
|
cr-recall-check) cmd_cr_recall_check "$@" ;;
|
|
4443
5032
|
cr-merge-check) cmd_cr_merge_check "$@" ;;
|
|
@@ -4446,8 +5035,10 @@ case "$sub" in
|
|
|
4446
5035
|
context-paths) cmd_context_paths "$@" ;;
|
|
4447
5036
|
model-scaffold) cmd_model_scaffold "$@" ;;
|
|
4448
5037
|
version-stamp) cmd_version_stamp "$@" ;;
|
|
5038
|
+
plan-gaps) cmd_plan_gaps "$@" ;;
|
|
4449
5039
|
plan-gate-stamp) cmd_plan_gate_stamp "$@" ;;
|
|
4450
5040
|
plan-gate-check) cmd_plan_gate_check "$@" ;;
|
|
5041
|
+
plan-coverage) cmd_plan_coverage "$@" ;;
|
|
4451
5042
|
checklist-read) cmd_checklist_read "$@" ;;
|
|
4452
5043
|
checklist-apply) cmd_checklist_apply "$@" ;;
|
|
4453
5044
|
detect-project) cmd_detect_project "$@" ;;
|