aw-spec-cli 0.1.20 → 0.1.22
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/ORCH-DIALECT.md +8 -3
- package/assets/.appweaver/STACK-PROFILES.md +87 -8
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +30 -0
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +22 -1
- package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +6 -6
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +144 -52
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +78 -10
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +283 -25
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +202 -40
- package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +557 -57
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +39 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +28 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +101 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +141 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +386 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +135 -0
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +772 -216
- package/assets/.appweaver/scripts/bash/aw-xref.sh +4 -1
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +708 -209
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +4 -1
- package/assets/.appweaver/scripts/python/aw-crstate.py +319 -0
- package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
- package/assets/.appweaver/scripts/python/aw-gaps.py +119 -0
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +19 -0
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +503 -0
- package/assets/.appweaver/scripts/python/aw-plangate.py +216 -0
- package/assets/.appweaver/scripts/python/aw-principles.py +108 -0
- package/assets/.appweaver/scripts/python/aw-stack.py +257 -28
- package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
- package/assets/.appweaver/scripts/python/aw-xref.py +141 -4
- package/assets/.appweaver/stack-profiles/angular.json +26 -0
- package/assets/.appweaver/stack-profiles/blazor-wasm.json +38 -0
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +39 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +20 -0
- package/assets/.appweaver/stack-profiles/nextjs.json +26 -0
- package/assets/.appweaver/stack-profiles/node-express.json +27 -0
- package/assets/.appweaver/stack-profiles/python-fastapi.json +16 -0
- package/assets/.appweaver/stack-profiles/react-vite.json +26 -0
- package/assets/.appweaver/stack-profiles/sveltekit.json +26 -0
- package/assets/.appweaver/stack-profiles/vue-vite.json +26 -0
- package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
- package/assets/.appweaver/templates/ui-model-template.md +67 -0
- package/assets/.appweaver/templates/workflow-model-template.md +62 -0
- 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 +114 -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>]
|
|
@@ -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
|
|
@@ -1089,9 +1100,12 @@ _cr_id_token() { # $1 = raw --cr-id value
|
|
|
1089
1100
|
}
|
|
1090
1101
|
|
|
1091
1102
|
# One change's wave, read from the documents that carry it: the delta-spec first, then
|
|
1092
|
-
# the model delta. Both are stamped together, so either answers
|
|
1093
|
-
#
|
|
1094
|
-
|
|
1103
|
+
# the model delta. Both are stamped together, so either answers. EMPTY when neither carries
|
|
1104
|
+
# a readable one — `**Wave**: [N]` is the unfilled template line, so "no wave" is a state
|
|
1105
|
+
# that happens, and a caller who WRITES the answer somewhere must not write a number nobody
|
|
1106
|
+
# derived. A phantom wave is worse than none: the merge and recall fences can see that a
|
|
1107
|
+
# wave is missing and say so, but a 1 that was invented downstream reads to them as fact.
|
|
1108
|
+
_wave_of_readable() { # $1 = abs feature dir, $2 = cr id, $3 = repo_root
|
|
1095
1109
|
local fd="$1" cr="$2" repo_root="$3" w=""
|
|
1096
1110
|
local dspec="$fd/changes/$cr/delta-spec.md"
|
|
1097
1111
|
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
@@ -1101,11 +1115,20 @@ _wave_of() { # $1 = abs feature dir, $2 = cr id, $3 = repo_root
|
|
|
1101
1115
|
w=$(sed -n -E 's/^\*\*Wave\*\*:[[:space:]]*([0-9]+).*$/\1/p' "$f" | head -1)
|
|
1102
1116
|
[ -n "$w" ] && break
|
|
1103
1117
|
done
|
|
1104
|
-
case "$w" in ''|*[!0-9]*) w=
|
|
1105
|
-
[ "$w"
|
|
1118
|
+
case "$w" in ''|*[!0-9]*) w="" ;; esac
|
|
1119
|
+
if [ -n "$w" ]; then
|
|
1120
|
+
[ "$w" -ge 1 ] 2>/dev/null || w=""
|
|
1121
|
+
fi
|
|
1106
1122
|
printf '%s' "$w"
|
|
1107
1123
|
}
|
|
1108
1124
|
|
|
1125
|
+
# The same reading with the floor applied, for the readers that must show a number — a
|
|
1126
|
+
# listing has a column to fill and 1 is the floor for a change opened before waves existed.
|
|
1127
|
+
_wave_of() { # $1 = abs feature dir, $2 = cr id, $3 = repo_root
|
|
1128
|
+
local w; w=$(_wave_of_readable "$1" "$2" "$3")
|
|
1129
|
+
printf '%s' "${w:-1}"
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1109
1132
|
# ─────────────────────────────── context-paths ─────────────────────────────
|
|
1110
1133
|
# Everything specify needs to know about the domain model of the context it is working
|
|
1111
1134
|
# in: where the master is, whether it exists yet, and EVERY open delta model beside it.
|
|
@@ -1905,15 +1928,23 @@ _foundation_stack() {
|
|
|
1905
1928
|
# ──────────────────────────── foundation-list ──────────────────────────────
|
|
1906
1929
|
# Enumerate available foundations (foundations/<stack>/<Name>/ that carry a real
|
|
1907
1930
|
# <Name>.pack.txt) so the workflow can let the user PICK one — deterministic, no LLM. An optional
|
|
1908
|
-
# --args "<Foundation> <App>
|
|
1931
|
+
# --args "<Foundation> <App>" is parsed as a direct-argument fast path:
|
|
1909
1932
|
# preselect_name is echoed back only when it names a real foundation; preselect_app
|
|
1910
|
-
#
|
|
1933
|
+
# carries any 2nd token through (empty otherwise).
|
|
1934
|
+
#
|
|
1935
|
+
# A THIRD token is no longer an output directory. A scaffold lands in the source path of
|
|
1936
|
+
# the lane it is scaffolding, which is the only thing that keeps generated code inside a
|
|
1937
|
+
# declared lane, so there is no caller-chosen destination left to accept. It comes back as
|
|
1938
|
+
# `rejected_out` for the workflow to refuse by name rather than be silently ignored —
|
|
1939
|
+
# a path argument that is quietly dropped is how someone believes they scaffolded a
|
|
1940
|
+
# sandbox and finds they overwrote their service.
|
|
1911
1941
|
cmd_foundation_list() {
|
|
1912
|
-
local raw=""
|
|
1942
|
+
local raw="" eco_filter=""
|
|
1913
1943
|
while [ $# -gt 0 ]; do
|
|
1914
1944
|
case "$1" in
|
|
1915
1945
|
--args) raw="$2"; shift 2 ;;
|
|
1916
|
-
--
|
|
1946
|
+
--ecosystem) eco_filter="$2"; shift 2 ;;
|
|
1947
|
+
--help|-h) echo "foundation-list [--args '<Foundation> <App>'] [--ecosystem <eco>] -> {foundations[], count, preselect_name, preselect_app, rejected_out}"; return 0 ;;
|
|
1917
1948
|
*) echo "foundation-list: unknown arg '$1'" >&2; return 1 ;;
|
|
1918
1949
|
esac
|
|
1919
1950
|
done
|
|
@@ -1922,13 +1953,19 @@ cmd_foundation_list() {
|
|
|
1922
1953
|
# WHICH foundations this repo may be offered. A foundation bundle is a whole
|
|
1923
1954
|
# compiling project in one ecosystem's languages and build system, so unpacking
|
|
1924
1955
|
# a .NET one into a Spring repo does not produce a scaffold — it produces a
|
|
1925
|
-
# second, foreign project beside the real one.
|
|
1926
|
-
#
|
|
1927
|
-
#
|
|
1928
|
-
#
|
|
1956
|
+
# second, foreign project beside the real one.
|
|
1957
|
+
#
|
|
1958
|
+
# --ecosystem narrows to the ONE lane being scaffolded, which is stricter than the
|
|
1959
|
+
# union of every declared lane: a repo with a .NET service and a Node UI must not be
|
|
1960
|
+
# offered its .NET bundle while the UI lane is the target. Without it the filter falls
|
|
1961
|
+
# back to every declared lane's ecosystem, and to nothing at all on a bare repo.
|
|
1929
1962
|
local -a lane_ecos=()
|
|
1930
1963
|
local e
|
|
1931
|
-
|
|
1964
|
+
if [ -n "$eco_filter" ]; then
|
|
1965
|
+
lane_ecos=("$eco_filter")
|
|
1966
|
+
else
|
|
1967
|
+
while IFS= read -r e; do [ -n "$e" ] && lane_ecos+=("$e"); done < <(_aw_ecosystems)
|
|
1968
|
+
fi
|
|
1932
1969
|
|
|
1933
1970
|
local -a foundations=() all=() eco_of=()
|
|
1934
1971
|
local rel name eco keep
|
|
@@ -1943,22 +1980,22 @@ cmd_foundation_list() {
|
|
|
1943
1980
|
fi
|
|
1944
1981
|
[ "$keep" = true ] && foundations+=("$name")
|
|
1945
1982
|
done < <(_foundation_dirs "$repo_root")
|
|
1946
|
-
# Optional direct-argument fast path: "<FoundationName> <AppName>
|
|
1947
|
-
local pre_name="" pre_app=""
|
|
1983
|
+
# Optional direct-argument fast path: "<FoundationName> <AppName>".
|
|
1984
|
+
local pre_name="" pre_app="" rejected_out=""
|
|
1948
1985
|
if [ -n "$raw" ]; then
|
|
1949
1986
|
# shellcheck disable=SC2086
|
|
1950
1987
|
set -- $raw
|
|
1951
|
-
[ -n "${1:-}" ] && pre_name="$1"; [ -n "${2:-}" ] && pre_app="$2"; [ -n "${3:-}" ] &&
|
|
1988
|
+
[ -n "${1:-}" ] && pre_name="$1"; [ -n "${2:-}" ] && pre_app="$2"; [ -n "${3:-}" ] && rejected_out="$3"
|
|
1952
1989
|
fi
|
|
1953
1990
|
# Only preselect a name that actually names an available foundation.
|
|
1954
1991
|
local match="" f
|
|
1955
1992
|
for f in "${foundations[@]}"; do [ "$f" = "$pre_name" ] && match="$pre_name"; done
|
|
1956
|
-
printf '{"foundations":%s,"count":%s,"all_foundations":%s,"all_count":%s,"ecosystems":%s,"lane_ecosystems":%s,"filtered":%s,"preselect_name":"%s","preselect_app":"%s","
|
|
1993
|
+
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' \
|
|
1957
1994
|
"$(json_str_array "${foundations[@]}")" "${#foundations[@]}" \
|
|
1958
1995
|
"$(json_str_array "${all[@]}")" "${#all[@]}" \
|
|
1959
1996
|
"$(json_str_array "${eco_of[@]}")" "$(json_str_array "${lane_ecos[@]}")" \
|
|
1960
1997
|
"$([ ${#lane_ecos[@]} -gt 0 ] && echo true || echo false)" \
|
|
1961
|
-
"$(json_escape "$match")" "$(json_escape "$pre_app")" "$(json_escape "$
|
|
1998
|
+
"$(json_escape "$match")" "$(json_escape "$pre_app")" "$(json_escape "$rejected_out")"
|
|
1962
1999
|
}
|
|
1963
2000
|
|
|
1964
2001
|
# ─────────────────────────── foundation-locate ─────────────────────────────
|
|
@@ -2021,17 +2058,23 @@ cmd_foundation_locate() {
|
|
|
2021
2058
|
|
|
2022
2059
|
# ─────────────────────────── foundation-unpack ─────────────────────────────
|
|
2023
2060
|
# Run the OS-selected unpacker for the foundation (or reference) pack.
|
|
2061
|
+
#
|
|
2062
|
+
# --out is the LANE'S SOURCE PATH, so every file a pack carries lands inside the subtree
|
|
2063
|
+
# its lane owns. --test-path is the lane's other subtree: a pack that carries its own test
|
|
2064
|
+
# project puts it under the source path like everything else, and aw-foundation.py lifts it
|
|
2065
|
+
# onto the path the lane actually keeps tests in. See that module for when it is a no-op.
|
|
2024
2066
|
cmd_foundation_unpack() {
|
|
2025
|
-
local name="" app="" out="." dry=false force="no" reference=false
|
|
2067
|
+
local name="" app="" out="." dry=false force="no" reference=false test_path=""
|
|
2026
2068
|
while [ $# -gt 0 ]; do
|
|
2027
2069
|
case "$1" in
|
|
2028
2070
|
--name) name="$2"; shift 2 ;;
|
|
2029
2071
|
--app) app="$2"; shift 2 ;;
|
|
2030
2072
|
--out) out="$2"; shift 2 ;;
|
|
2073
|
+
--test-path) test_path="$2"; shift 2 ;;
|
|
2031
2074
|
--dry-run) dry=true; shift ;;
|
|
2032
2075
|
--force) force="$2"; shift 2 ;;
|
|
2033
2076
|
--reference) reference=true; shift ;;
|
|
2034
|
-
--help|-h) echo "foundation-unpack --name <F> --app <A> --out <d> [--dry-run] [--force <yes|no>] [--reference]"; return 0 ;;
|
|
2077
|
+
--help|-h) echo "foundation-unpack --name <F> --app <A> --out <d> [--test-path <d>] [--dry-run] [--force <yes|no>] [--reference]"; return 0 ;;
|
|
2035
2078
|
*) echo "foundation-unpack: unknown arg '$1'" >&2; return 1 ;;
|
|
2036
2079
|
esac
|
|
2037
2080
|
done
|
|
@@ -2067,9 +2110,29 @@ cmd_foundation_unpack() {
|
|
|
2067
2110
|
[ -n "$written" ] || written=0
|
|
2068
2111
|
local has_coll=false
|
|
2069
2112
|
if [ "$dry" = true ] && [ $rc -ne 0 ]; then has_coll=true; fi
|
|
2070
|
-
|
|
2113
|
+
|
|
2114
|
+
# Put the pack's test tree on the lane's test path. Only after a REAL unpack that
|
|
2115
|
+
# worked: a dry run has written nothing to lift, and a failed one leaves a tree
|
|
2116
|
+
# nobody should be rearranging.
|
|
2117
|
+
local tests_moved=0 tests_msg="" redirect=""
|
|
2118
|
+
if [ "$dry" != true ] && [ $rc -eq 0 ] && [ -n "$test_path" ]; then
|
|
2119
|
+
local aw_fnd="$SCRIPT_DIR/../python/aw-foundation.py"
|
|
2120
|
+
if [ -f "$aw_fnd" ] && command -v python3 >/dev/null 2>&1; then
|
|
2121
|
+
local -a rargs=(--repo "$repo_root" redirect-tests --out "$out" --test-path "$test_path")
|
|
2122
|
+
{ [ "$force" = "yes" ] || [ "$reference" = true ]; } && rargs+=(--force)
|
|
2123
|
+
redirect=$(python3 "$aw_fnd" "${rargs[@]}" 2>/dev/null | tail -1)
|
|
2124
|
+
if [ -n "$redirect" ]; then
|
|
2125
|
+
tests_moved=$(printf '%s' "$redirect" | python3 -c 'import json,sys; print(json.loads(sys.stdin.readline()).get("moved",0))' 2>/dev/null || echo 0)
|
|
2126
|
+
tests_msg=$(printf '%s' "$redirect" | python3 -c 'import json,sys; print(json.loads(sys.stdin.readline()).get("message",""))' 2>/dev/null || echo "")
|
|
2127
|
+
fi
|
|
2128
|
+
fi
|
|
2129
|
+
fi
|
|
2130
|
+
[ -n "$tests_moved" ] || tests_moved=0
|
|
2131
|
+
|
|
2132
|
+
printf '{"ok":%s,"mode":"%s","written":%s,"has_collisions":%s,"tests_moved":%s,"tests_message":"%s","stdout":"%s"}\n' \
|
|
2071
2133
|
"$([ $rc -eq 0 ] && echo true || echo false)" "$([ "$dry" = true ] && echo dry || echo real)" \
|
|
2072
|
-
"$written" "$has_coll" "$
|
|
2134
|
+
"$written" "$has_coll" "$tests_moved" "$(json_escape "$tests_msg")" \
|
|
2135
|
+
"$(json_escape "$(printf '%s' "$outp" | tail -c 800)")"
|
|
2073
2136
|
}
|
|
2074
2137
|
|
|
2075
2138
|
# ─────────────────────── foundation-stash-dbcontext ────────────────────────
|
|
@@ -2086,7 +2149,9 @@ cmd_foundation_stash_dbcontext() {
|
|
|
2086
2149
|
done
|
|
2087
2150
|
local repo_root; repo_root=$(get_repo_root)
|
|
2088
2151
|
[[ "$out" != /* ]] && out="$repo_root/$out"
|
|
2089
|
-
|
|
2152
|
+
# Searched from the unpack root itself, which IS the lane's source path — a `src/`
|
|
2153
|
+
# segment underneath it is the old repo-root layout and is not there any more.
|
|
2154
|
+
local ctx; ctx=$(find "$out" -name "ApplicationDbContext.cs" 2>/dev/null | head -1)
|
|
2090
2155
|
if [ -z "$ctx" ]; then printf '{"found":false,"dbcontext":"","backup":""}\n'; return 0; fi
|
|
2091
2156
|
local bak="$ctx.foundation.bak"
|
|
2092
2157
|
cp "$ctx" "$bak"
|
|
@@ -2511,6 +2576,25 @@ print(json.dumps({"ok": ok, "exists": True, "issues": issues,
|
|
|
2511
2576
|
PY
|
|
2512
2577
|
}
|
|
2513
2578
|
|
|
2579
|
+
# ─────────────────────────────── principles-list ───────────────────────────
|
|
2580
|
+
# The ratified NON-NEGOTIABLE principles. A thin wrapper: the scan lives in
|
|
2581
|
+
# python/aw-principles.py, which BOTH shells invoke, so the list a gate cites cannot
|
|
2582
|
+
# differ between them. Defaults to the two halves specify loads; --file overrides.
|
|
2583
|
+
cmd_principles_list() {
|
|
2584
|
+
local rest=()
|
|
2585
|
+
while [ $# -gt 0 ]; do
|
|
2586
|
+
case "$1" in
|
|
2587
|
+
--help|-h) echo "principles-list [--file <path>]... -> {files[], missing[], principles[], count, titles_csv}"; return 0 ;;
|
|
2588
|
+
*) rest+=("$1"); shift ;;
|
|
2589
|
+
esac
|
|
2590
|
+
done
|
|
2591
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
2592
|
+
local core="$SCRIPT_DIR/../python/aw-principles.py"
|
|
2593
|
+
if [ ! -f "$core" ]; then echo "principles-list: core not found at $core" >&2; return 1; fi
|
|
2594
|
+
if ! command -v python3 >/dev/null 2>&1; then echo "principles-list: python3 is required" >&2; return 1; fi
|
|
2595
|
+
python3 "$core" list --root "$repo_root" "${rest[@]+"${rest[@]}"}"
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2514
2598
|
# ────────────────────────── agent-context-update ───────────────────────────
|
|
2515
2599
|
# Run the agent-context extension's updater (refreshes the managed AW-Spec block).
|
|
2516
2600
|
cmd_agent_context_update() {
|
|
@@ -2871,7 +2955,14 @@ cmd_delta_plan_scaffold() {
|
|
|
2871
2955
|
if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$target"; else touch "$target"; fi
|
|
2872
2956
|
created="true"
|
|
2873
2957
|
fi
|
|
2874
|
-
|
|
2958
|
+
# The lane plan carries the change's wave so a reader of the plan alone can see where it
|
|
2959
|
+
# sits. It is COPIED from the delta-spec, never decided here — so when the spec has no
|
|
2960
|
+
# readable wave the template's `[N]` is left standing, which is what the merge and recall
|
|
2961
|
+
# fences read as "not ordered yet" and refuse on, with the command that stamps it.
|
|
2962
|
+
local scaffold_wave; scaffold_wave=$(_wave_of_readable "$fd" "$cr_id" "$repo_root")
|
|
2963
|
+
if [ -n "$scaffold_wave" ]; then
|
|
2964
|
+
_stamp_header "$target" "Wave" "$scaffold_wave"
|
|
2965
|
+
fi
|
|
2875
2966
|
local override_active="false"
|
|
2876
2967
|
[ -f "$repo_root/.appweaver/templates/overrides/${template_arg}.md" ] && override_active="true"
|
|
2877
2968
|
local template_source; template_source=$(resolve_template "$template_arg" "$repo_root") || true
|
|
@@ -2944,13 +3035,17 @@ cmd_test_run() {
|
|
|
2944
3035
|
# feature's aggregate name (feature-dir basename) plus any --also
|
|
2945
3036
|
# referrer names — the per-feature integrity scope. .NET only; npm/none
|
|
2946
3037
|
# ignore it and run the full suite.
|
|
3038
|
+
# --also is REPEATABLE and the lists UNION. A merge's blast radius has two halves that
|
|
3039
|
+
# are computed separately — the referrer closure and the features that co-own the
|
|
3040
|
+
# aggregates the change touches — and the dialect cannot concatenate two variables into
|
|
3041
|
+
# one argument, so the caller passes each list as its own --also.
|
|
2947
3042
|
local feature_dir="" scope="repo" also=""
|
|
2948
3043
|
while [ $# -gt 0 ]; do
|
|
2949
3044
|
case "$1" in
|
|
2950
3045
|
--feature-dir) feature_dir="$2"; shift 2 ;;
|
|
2951
3046
|
--scope) scope="$2"; shift 2 ;;
|
|
2952
|
-
--also) also="$2"; shift 2 ;;
|
|
2953
|
-
--help|-h) echo "test-run [--feature-dir <path>] [--scope slice|repo] [--also <csv>]"; return 0 ;;
|
|
3047
|
+
--also) also="${also:+$also,}$2"; shift 2 ;;
|
|
3048
|
+
--help|-h) echo "test-run [--feature-dir <path>] [--scope slice|repo] [--also <csv>]... (--also repeatable; lists union)"; return 0 ;;
|
|
2954
3049
|
*) echo "test-run: unknown arg '$1'" >&2; return 1 ;;
|
|
2955
3050
|
esac
|
|
2956
3051
|
done
|
|
@@ -2973,7 +3068,9 @@ cmd_test_run() {
|
|
|
2973
3068
|
fi
|
|
2974
3069
|
|
|
2975
3070
|
local tool="none" ran=false ok=true cmd="" log; log=$(mktemp)
|
|
2976
|
-
|
|
3071
|
+
# The runner's exit code, kept because 126/127 is every shell's way of saying the
|
|
3072
|
+
# command itself could not be executed — a missing runner, not a failing suite.
|
|
3073
|
+
local matched=false scoped=false fence_green=false filter="" t2 rc=0
|
|
2977
3074
|
|
|
2978
3075
|
local dotnet_present=false
|
|
2979
3076
|
if ls "$repo_root"/*.sln >/dev/null 2>&1 || find "$repo_root" -maxdepth 5 -name "*.csproj" 2>/dev/null | grep -q .; then
|
|
@@ -3006,6 +3103,16 @@ print(json.dumps({
|
|
|
3006
3103
|
"scope": os.environ.get("AW_SCOPE", "repo"), "slices": os.environ.get("AW_SLICES", ""),
|
|
3007
3104
|
"scoped": False, "matched": bool(re.search(r"Total:\s*[1-9]", log)),
|
|
3008
3105
|
"fence_green": ok, "cmd": d.get("cmd", ""), "log_tail": log,
|
|
3106
|
+
# The failing lines of EVERY failing lane, budgeted per lane by run-phase — what the
|
|
3107
|
+
# fix loop reads, in place of a tail that may belong to a lane that passed.
|
|
3108
|
+
"failing_excerpt": d.get("failing_excerpt", ""),
|
|
3109
|
+
"failing_lanes": d.get("failing_lanes", []),
|
|
3110
|
+
# NOTHING ran a suite, because no lane has a test command wired up — a different
|
|
3111
|
+
# state from a red suite, and the one a fix loop cannot help with. (No apostrophes
|
|
3112
|
+
# in this block: it is a single-quoted python3 -c program.)
|
|
3113
|
+
"no_setup": bool(d.get("no_setup")),
|
|
3114
|
+
"setup_reason": d.get("setup_reason", ""),
|
|
3115
|
+
"setup_missing": d.get("setup_missing", []),
|
|
3009
3116
|
"lanes_run": d.get("lanes_run", []), "lanes_skipped": d.get("lanes_skipped", []),
|
|
3010
3117
|
}))
|
|
3011
3118
|
'
|
|
@@ -3024,16 +3131,16 @@ print(json.dumps({
|
|
|
3024
3131
|
else
|
|
3025
3132
|
for t2 in $slices; do filter="${filter:+$filter|}FullyQualifiedName~$t2"; done
|
|
3026
3133
|
scoped=true; cmd="dotnet test --nologo --filter \"$filter\""; ran=true
|
|
3027
|
-
if ( cd "$repo_root" && dotnet test --nologo --filter "$filter" ) >"$log" 2>&1; then ok=true; else ok=false; fi
|
|
3134
|
+
if ( cd "$repo_root" && dotnet test --nologo --filter "$filter" ) >"$log" 2>&1; then ok=true; else rc=$?; ok=false; fi
|
|
3028
3135
|
fi
|
|
3029
3136
|
else
|
|
3030
3137
|
cmd="dotnet test --nologo"; ran=true
|
|
3031
|
-
if ( cd "$repo_root" && dotnet test --nologo ) >"$log" 2>&1; then ok=true; else ok=false; fi
|
|
3138
|
+
if ( cd "$repo_root" && dotnet test --nologo ) >"$log" 2>&1; then ok=true; else rc=$?; ok=false; fi
|
|
3032
3139
|
fi
|
|
3033
3140
|
else ok=false; fi
|
|
3034
3141
|
elif [ -f "$repo_root/package.json" ] && command -v npm >/dev/null 2>&1; then
|
|
3035
3142
|
tool="npm"; cmd="npm test"; ran=true
|
|
3036
|
-
if ( cd "$repo_root" && npm test ) >"$log" 2>&1; then ok=true; else ok=false; fi
|
|
3143
|
+
if ( cd "$repo_root" && npm test ) >"$log" 2>&1; then ok=true; else rc=$?; ok=false; fi
|
|
3037
3144
|
else
|
|
3038
3145
|
tool="none"; ran=false; ok=true
|
|
3039
3146
|
fi
|
|
@@ -3053,10 +3160,30 @@ print(json.dumps({
|
|
|
3053
3160
|
fence_green=$ok
|
|
3054
3161
|
fi
|
|
3055
3162
|
|
|
3056
|
-
local t; t=$(tail -c 1400 "$log" 2>/dev/null || true)
|
|
3057
|
-
|
|
3163
|
+
local t; t=$(tail -c 1400 "$log" 2>/dev/null || true)
|
|
3164
|
+
# The lines that NAME the failure, which a positional tail does not reliably contain:
|
|
3165
|
+
# a .NET run opens with its restore banner and a runner closes with a summary, so the
|
|
3166
|
+
# last 1400 characters can be entirely green while the failure sits above them.
|
|
3167
|
+
local excerpt="" label no_setup=false setup_reason="" helper_out=""
|
|
3168
|
+
label=$(printf '%s' "$tool${cmd:+ · $cmd}" | tr '\n' ' ' | cut -c1-80)
|
|
3169
|
+
if [ "$fence_green" != true ]; then
|
|
3170
|
+
helper_out=$(python3 "$SCRIPT_DIR/../python/aw-testlog.py" --file "$log" \
|
|
3171
|
+
--label "$label" --exit "$rc" 2>/dev/null || true)
|
|
3172
|
+
excerpt=$(printf '%s' "$helper_out" | python3 -c 'import json,sys
|
|
3173
|
+
try: sys.stdout.write(json.load(sys.stdin).get("excerpt", ""))
|
|
3174
|
+
except Exception: pass' 2>/dev/null || true)
|
|
3175
|
+
# "The command never ran" — no test script, no runner, no test project. Not a red
|
|
3176
|
+
# suite, and the callers gate on it rather than sending a fix loop after nothing.
|
|
3177
|
+
setup_reason=$(printf '%s' "$helper_out" | python3 -c 'import json,sys
|
|
3178
|
+
try: sys.stdout.write(json.load(sys.stdin).get("reason", ""))
|
|
3179
|
+
except Exception: pass' 2>/dev/null || true)
|
|
3180
|
+
[ -n "$setup_reason" ] && no_setup=true
|
|
3181
|
+
fi
|
|
3182
|
+
rm -f "$log"
|
|
3183
|
+
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' \
|
|
3058
3184
|
"$ok" "$(json_escape "$tool")" "$ran" "$(json_escape "$scope")" "$(json_escape "$slices")" \
|
|
3059
|
-
"$scoped" "$matched" "$fence_green" "$(json_escape "$cmd")" "$(json_escape "$t")"
|
|
3185
|
+
"$scoped" "$matched" "$fence_green" "$(json_escape "$cmd")" "$(json_escape "$t")" \
|
|
3186
|
+
"$(json_escape "$excerpt")" "$no_setup" "$(json_escape "$setup_reason")"
|
|
3060
3187
|
}
|
|
3061
3188
|
|
|
3062
3189
|
cmd_run_migrate() {
|
|
@@ -3312,6 +3439,356 @@ cmd_cr_wave() {
|
|
|
3312
3439
|
"$(json_escape "$dspec")" "$(json_escape "$dmodel")"
|
|
3313
3440
|
}
|
|
3314
3441
|
|
|
3442
|
+
# ──────────────────────────── cr-wave-derive ───────────────────────────────
|
|
3443
|
+
# THE WAVE A CHANGE'S OWN DOCUMENTS IMPLY, from their `## Depends on` sections.
|
|
3444
|
+
#
|
|
3445
|
+
# cr-setup stamps a new change at the highest wave already open, which is right for a
|
|
3446
|
+
# change that stands alone and wrong for one that builds on another: a chained delta lands
|
|
3447
|
+
# at its predecessor's wave, where the blocker fence reads the two as independent and lets
|
|
3448
|
+
# them merge in either order. The delta documents already say otherwise — an author writes
|
|
3449
|
+
# the predecessor under `## Depends on`, with a reason — and nothing acted on it.
|
|
3450
|
+
#
|
|
3451
|
+
# This makes that declaration the ordering authority: required wave = the highest wave
|
|
3452
|
+
# among the changes named there, plus one; 1 when it names none. `specify` stamps it once
|
|
3453
|
+
# the model delta is authored, and the merge fence checks it, so the number and the prose
|
|
3454
|
+
# cannot drift apart. A change sitting HIGHER than required is fine and left alone — that
|
|
3455
|
+
# is deliberate slack, not a contradiction.
|
|
3456
|
+
cmd_cr_wave_derive() {
|
|
3457
|
+
local cr_id="" feature_dir_arg="" stamp=false
|
|
3458
|
+
while [ $# -gt 0 ]; do
|
|
3459
|
+
case "$1" in
|
|
3460
|
+
--cr-id) cr_id="$2"; shift 2 ;;
|
|
3461
|
+
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
3462
|
+
--stamp) stamp=true; shift ;;
|
|
3463
|
+
--help|-h) echo "cr-wave-derive --cr-id <id> [--feature-dir <path>] [--stamp] -> {wave, required_wave, ok, depends_on[], unknown[], stamped, message}"; return 0 ;;
|
|
3464
|
+
*) echo "cr-wave-derive: unknown arg '$1'" >&2; return 1 ;;
|
|
3465
|
+
esac
|
|
3466
|
+
done
|
|
3467
|
+
cr_id=$(_cr_id_token "$cr_id")
|
|
3468
|
+
[ -n "$cr_id" ] || { echo "cr-wave-derive: --cr-id is required" >&2; return 1; }
|
|
3469
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
3470
|
+
local fd="$feature_dir_arg"
|
|
3471
|
+
[ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
|
|
3472
|
+
[ -n "$fd" ] || { echo "cr-wave-derive: no feature set and no --feature-dir" >&2; return 1; }
|
|
3473
|
+
[[ "$fd" != /* ]] && fd="$repo_root/$fd"
|
|
3474
|
+
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
3475
|
+
AW_CR="$cr_id" AW_FD="$fd" AW_CTX="$ctx_dir" AW_STAMP="$stamp" python3 - <<'PY'
|
|
3476
|
+
import glob, json, os, re
|
|
3477
|
+
|
|
3478
|
+
cr = os.environ["AW_CR"]
|
|
3479
|
+
fd = os.environ["AW_FD"]
|
|
3480
|
+
ctx = os.environ["AW_CTX"]
|
|
3481
|
+
stamp = os.environ.get("AW_STAMP", "") == "true"
|
|
3482
|
+
|
|
3483
|
+
CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
|
|
3484
|
+
WAVE_RX = re.compile(r"^\*\*Wave\*\*:\s*(\d+)", re.M)
|
|
3485
|
+
|
|
3486
|
+
|
|
3487
|
+
def read(path):
|
|
3488
|
+
try:
|
|
3489
|
+
return open(path, encoding="utf-8").read()
|
|
3490
|
+
except Exception:
|
|
3491
|
+
return ""
|
|
3492
|
+
|
|
3493
|
+
|
|
3494
|
+
def wave_of(cr_id):
|
|
3495
|
+
"""A change states its wave in the header of its own delta documents."""
|
|
3496
|
+
for p in (os.path.join(ctx, "domain-model.%s.delta.md" % cr_id),
|
|
3497
|
+
os.path.join(fd, "changes", cr_id, "delta-spec.md")):
|
|
3498
|
+
m = WAVE_RX.search(read(p))
|
|
3499
|
+
if m:
|
|
3500
|
+
return int(m.group(1))
|
|
3501
|
+
for p in glob.glob(os.path.join(glob.escape(os.path.join(fd, "changes", cr_id)), "delta-*.md")):
|
|
3502
|
+
m = WAVE_RX.search(read(p))
|
|
3503
|
+
if m:
|
|
3504
|
+
return int(m.group(1))
|
|
3505
|
+
return None
|
|
3506
|
+
|
|
3507
|
+
|
|
3508
|
+
def depends_ids(text):
|
|
3509
|
+
"""The CR ids named under `## Depends on`, up to the next heading of that level."""
|
|
3510
|
+
m = re.search(r"^##\s+Depends on\s*$(.*?)(?=^##\s|\Z)", text, re.M | re.S)
|
|
3511
|
+
if not m:
|
|
3512
|
+
return []
|
|
3513
|
+
body = re.sub(r"<!--.*?-->", " ", m.group(1), flags=re.S)
|
|
3514
|
+
return [i for i in CR_RX.findall(body) if i != cr]
|
|
3515
|
+
|
|
3516
|
+
|
|
3517
|
+
change_dir = os.path.join(fd, "changes", cr)
|
|
3518
|
+
docs = [os.path.join(ctx, "domain-model.%s.delta.md" % cr),
|
|
3519
|
+
os.path.join(change_dir, "delta-spec.md")]
|
|
3520
|
+
docs += sorted(glob.glob(os.path.join(glob.escape(change_dir), "delta-*plan*.md")))
|
|
3521
|
+
present = [p for p in docs if os.path.isfile(p)]
|
|
3522
|
+
|
|
3523
|
+
deps, seen = [], set()
|
|
3524
|
+
for p in present:
|
|
3525
|
+
for i in depends_ids(read(p)):
|
|
3526
|
+
if i not in seen:
|
|
3527
|
+
seen.add(i)
|
|
3528
|
+
deps.append(i)
|
|
3529
|
+
|
|
3530
|
+
known, unknown = [], []
|
|
3531
|
+
for d in deps:
|
|
3532
|
+
w = wave_of(d)
|
|
3533
|
+
(known if w is not None else unknown).append({"cr_id": d, "wave": w})
|
|
3534
|
+
required = max([d["wave"] for d in known], default=0) + 1
|
|
3535
|
+
current = wave_of(cr)
|
|
3536
|
+
ok = current is not None and current >= required
|
|
3537
|
+
|
|
3538
|
+
# A change with NO readable wave is stamped too, not only one sitting too low. The merge
|
|
3539
|
+
# and recall fences refuse on an unordered change and send the operator here, so the one
|
|
3540
|
+
# command they are given has to be able to write the first wave as well as correct a wrong
|
|
3541
|
+
# one — a remedy that declines the case it was named for is no remedy.
|
|
3542
|
+
stamped = []
|
|
3543
|
+
if stamp and (current is None or current < required):
|
|
3544
|
+
line = "**Wave**: %d" % required
|
|
3545
|
+
pat = re.compile(r"^\*\*Wave\*\*:.*$", re.M)
|
|
3546
|
+
for p in present:
|
|
3547
|
+
text = read(p)
|
|
3548
|
+
if pat.search(text):
|
|
3549
|
+
open(p, "w", encoding="utf-8").write(pat.sub(lambda _m: line, text, count=1))
|
|
3550
|
+
stamped.append(os.path.basename(p))
|
|
3551
|
+
if stamped:
|
|
3552
|
+
current, ok = required, True
|
|
3553
|
+
|
|
3554
|
+
if stamped and deps:
|
|
3555
|
+
msg = "%s depends on %s, so it was raised to wave %d in %s" % (
|
|
3556
|
+
cr, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
|
|
3557
|
+
required, ", ".join(stamped))
|
|
3558
|
+
elif stamped:
|
|
3559
|
+
msg = "%s declares no predecessor, so it was stamped at wave %d — the floor — in %s" % (
|
|
3560
|
+
cr, required, ", ".join(stamped))
|
|
3561
|
+
elif not deps:
|
|
3562
|
+
msg = "%s declares no predecessor, so wave %d is the floor; it carries %s" % (
|
|
3563
|
+
cr, required, current if current is not None else "no wave")
|
|
3564
|
+
elif ok:
|
|
3565
|
+
msg = "%s is at wave %s, at or above the %d its `## Depends on` requires" % (
|
|
3566
|
+
cr, current, required)
|
|
3567
|
+
else:
|
|
3568
|
+
msg = ("%s is at wave %s but depends on %s, so it must sit at wave %d — a predecessor "
|
|
3569
|
+
"at the same or a higher wave is not ordered behind it at all" % (
|
|
3570
|
+
cr, current, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
|
|
3571
|
+
required))
|
|
3572
|
+
if unknown:
|
|
3573
|
+
msg += ". Named but not found open: %s" % ", ".join(u["cr_id"] for u in unknown)
|
|
3574
|
+
|
|
3575
|
+
print(json.dumps({
|
|
3576
|
+
"cr_id": cr, "wave": current, "required_wave": required, "ok": ok,
|
|
3577
|
+
"depends_on": known, "unknown": unknown, "depends_count": len(deps),
|
|
3578
|
+
"stamped": stamped, "documents": [os.path.basename(p) for p in present],
|
|
3579
|
+
"message": msg,
|
|
3580
|
+
}))
|
|
3581
|
+
PY
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
# ────────────────────────────── cr-rebase ──────────────────────────────────
|
|
3585
|
+
# RE-STAMP a change's base versions onto the masters it is being merged into — but only
|
|
3586
|
+
# when nothing that landed in between touched what this delta touches.
|
|
3587
|
+
#
|
|
3588
|
+
# `**Base version**` is written once, by cr-setup, and the merge fence refuses when it no
|
|
3589
|
+
# longer matches the master. Nothing could ever update it: every other site reads it, and
|
|
3590
|
+
# every authoring prompt forbids the model from writing it, so the only documented way out
|
|
3591
|
+
# of that refusal was hand-editing the delta-spec in the middle of an automated loop.
|
|
3592
|
+
#
|
|
3593
|
+
# WHAT "APPLIES CLEANLY" MEANS HERE, because a stamp that is not earned is worse than a
|
|
3594
|
+
# refusal: the masters moved, and every change that landed since this delta was stamped
|
|
3595
|
+
# touched OTHER aggregates than the ones this delta names. Then the delta still describes
|
|
3596
|
+
# the document it was written against, and the stamp is bookkeeping. A landed change
|
|
3597
|
+
# sharing an aggregate — or one whose own model delta cannot be read — leaves it refused,
|
|
3598
|
+
# and a human rebases. `**Base context version**` is compared and re-stamped the same way,
|
|
3599
|
+
# which is the first time anything reads it.
|
|
3600
|
+
cmd_cr_rebase() {
|
|
3601
|
+
local cr_id="" feature_dir_arg="" stamp=false
|
|
3602
|
+
while [ $# -gt 0 ]; do
|
|
3603
|
+
case "$1" in
|
|
3604
|
+
--cr-id) cr_id="$2"; shift 2 ;;
|
|
3605
|
+
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
3606
|
+
--stamp) stamp=true; shift ;;
|
|
3607
|
+
--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 ;;
|
|
3608
|
+
*) echo "cr-rebase: unknown arg '$1'" >&2; return 1 ;;
|
|
3609
|
+
esac
|
|
3610
|
+
done
|
|
3611
|
+
cr_id=$(_cr_id_token "$cr_id")
|
|
3612
|
+
[ -n "$cr_id" ] || { echo "cr-rebase: --cr-id is required" >&2; return 1; }
|
|
3613
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
3614
|
+
local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-rebase: no feature set and no --feature-dir" >&2; return 1; }
|
|
3615
|
+
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
3616
|
+
AW_CR="$cr_id" AW_FD="$fd" AW_CTX="$ctx_dir" AW_STAMP="$stamp" python3 - <<'PY'
|
|
3617
|
+
import glob, json, os, re
|
|
3618
|
+
|
|
3619
|
+
cr = os.environ["AW_CR"]
|
|
3620
|
+
fd = os.environ["AW_FD"]
|
|
3621
|
+
ctx = os.environ["AW_CTX"]
|
|
3622
|
+
stamp = os.environ.get("AW_STAMP", "") == "true"
|
|
3623
|
+
|
|
3624
|
+
CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
|
|
3625
|
+
TICK_RX = re.compile(r"`([^`]+)`")
|
|
3626
|
+
|
|
3627
|
+
|
|
3628
|
+
def read(path):
|
|
3629
|
+
try:
|
|
3630
|
+
return open(path, encoding="utf-8").read()
|
|
3631
|
+
except Exception:
|
|
3632
|
+
return ""
|
|
3633
|
+
|
|
3634
|
+
|
|
3635
|
+
def header(text, name):
|
|
3636
|
+
m = re.search(r"^\*\*%s\*\*:[ \t]*(.+?)[ \t]*(?:<!--.*)?$" % re.escape(name), text, re.M)
|
|
3637
|
+
if not m:
|
|
3638
|
+
return ""
|
|
3639
|
+
v = m.group(1).strip()
|
|
3640
|
+
return "" if (v.startswith("[") and v.endswith("]")) else v
|
|
3641
|
+
|
|
3642
|
+
|
|
3643
|
+
def norm(v):
|
|
3644
|
+
v = (v or "").strip()
|
|
3645
|
+
return "" if not v else (v if v.startswith("v") else "v" + v)
|
|
3646
|
+
|
|
3647
|
+
|
|
3648
|
+
def as_tuple(v):
|
|
3649
|
+
parts = re.findall(r"\d+", v or "")
|
|
3650
|
+
return tuple(int(p) for p in parts) if parts else None
|
|
3651
|
+
|
|
3652
|
+
|
|
3653
|
+
def aggregates(text):
|
|
3654
|
+
"""The `**Aggregates touched**:` header — the list finalize-delta opens sections for."""
|
|
3655
|
+
m = re.search(r"^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$", text, re.M)
|
|
3656
|
+
if not m:
|
|
3657
|
+
return set()
|
|
3658
|
+
value = re.sub(r"<!--.*?(?:-->|$)", "", m.group(1)).strip()
|
|
3659
|
+
names = TICK_RX.findall(value)
|
|
3660
|
+
if not names:
|
|
3661
|
+
names = [p.strip() for p in re.split(r"[,;/&]|\band\b", value) if p.strip()]
|
|
3662
|
+
names = [re.match(r"[A-Za-z_][A-Za-z0-9_]*", n).group(0)
|
|
3663
|
+
for n in names if re.match(r"[A-Za-z_][A-Za-z0-9_]*", n)]
|
|
3664
|
+
return {n for n in names if not n.startswith(("<", "[")) and not re.match(r"(?i)none\b", n)}
|
|
3665
|
+
|
|
3666
|
+
|
|
3667
|
+
def landed_since(text, base):
|
|
3668
|
+
"""CR ids in Change History rows whose version is ABOVE `base` — what moved the master
|
|
3669
|
+
since this delta was stamped. A row with no readable version counts, because a row this
|
|
3670
|
+
cannot place is one it cannot prove is older."""
|
|
3671
|
+
ids, base_t = [], as_tuple(base)
|
|
3672
|
+
for line in text.split("\n"):
|
|
3673
|
+
if not line.strip().startswith("|"):
|
|
3674
|
+
continue
|
|
3675
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
3676
|
+
if len(cells) < 2 or set("".join(cells)) <= set("-: "):
|
|
3677
|
+
continue
|
|
3678
|
+
row_t = as_tuple(cells[0]) if re.match(r"^v?\d", cells[0]) else None
|
|
3679
|
+
if row_t is None:
|
|
3680
|
+
continue
|
|
3681
|
+
if base_t is not None and row_t <= base_t:
|
|
3682
|
+
continue
|
|
3683
|
+
for i in CR_RX.findall(line):
|
|
3684
|
+
if i != cr and i not in ids:
|
|
3685
|
+
ids.append(i)
|
|
3686
|
+
return ids
|
|
3687
|
+
|
|
3688
|
+
|
|
3689
|
+
def model_delta_of(cr_id):
|
|
3690
|
+
"""A change's model half: beside the master while open, in its archive once closed."""
|
|
3691
|
+
p = os.path.join(ctx, "domain-model.%s.delta.md" % cr_id)
|
|
3692
|
+
if os.path.isfile(p):
|
|
3693
|
+
return read(p)
|
|
3694
|
+
for feat in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
|
|
3695
|
+
a = os.path.join(ctx, feat, "changes", "archive", cr_id, "domain-model.delta.md")
|
|
3696
|
+
if os.path.isfile(a):
|
|
3697
|
+
return read(a)
|
|
3698
|
+
return ""
|
|
3699
|
+
|
|
3700
|
+
|
|
3701
|
+
my_spec = os.path.join(fd, "changes", cr, "delta-spec.md")
|
|
3702
|
+
my_model = os.path.join(ctx, "domain-model.%s.delta.md" % cr)
|
|
3703
|
+
spec_master = os.path.join(fd, "spec.md")
|
|
3704
|
+
model_master = os.path.join(ctx, "domain-model.md")
|
|
3705
|
+
|
|
3706
|
+
spec_text, model_text = read(my_spec), read(my_model)
|
|
3707
|
+
base_version = norm(header(spec_text, "Base version"))
|
|
3708
|
+
base_ctx = norm(header(model_text, "Base context version"))
|
|
3709
|
+
|
|
3710
|
+
current = ""
|
|
3711
|
+
try:
|
|
3712
|
+
current = norm(json.load(open(os.path.join(fd, "active_version.json"), encoding="utf-8")).get("version", ""))
|
|
3713
|
+
except Exception:
|
|
3714
|
+
current = ""
|
|
3715
|
+
if not current:
|
|
3716
|
+
current = norm(header(read(spec_master), "Version"))
|
|
3717
|
+
current_ctx = norm(header(read(model_master), "Version"))
|
|
3718
|
+
|
|
3719
|
+
moved = bool(base_version and current and base_version != current)
|
|
3720
|
+
ctx_moved = bool(base_ctx and current_ctx and base_ctx != current_ctx)
|
|
3721
|
+
|
|
3722
|
+
# WHAT LANDED IN BETWEEN, and whether any of it touched what this delta touches. A delta
|
|
3723
|
+
# whose aggregates nobody else moved still applies to the master it was written against;
|
|
3724
|
+
# one whose aggregates a landed change rewrote does not, and no stamp can make it.
|
|
3725
|
+
mine = aggregates(model_text)
|
|
3726
|
+
intervening = landed_since(read(spec_master), base_version) if moved else []
|
|
3727
|
+
if ctx_moved:
|
|
3728
|
+
for i in landed_since(read(model_master), base_ctx):
|
|
3729
|
+
if i not in intervening:
|
|
3730
|
+
intervening.append(i)
|
|
3731
|
+
|
|
3732
|
+
rows, conflicts = [], []
|
|
3733
|
+
for i in intervening:
|
|
3734
|
+
theirs = aggregates(model_delta_of(i))
|
|
3735
|
+
shared = sorted(mine & theirs) if (mine and theirs) else []
|
|
3736
|
+
# Unknown aggregates fail CLOSED: a change this cannot read is one it cannot clear.
|
|
3737
|
+
unknown = not theirs
|
|
3738
|
+
rows.append({"cr_id": i, "aggregates": sorted(theirs), "shared": shared, "unknown": unknown})
|
|
3739
|
+
if shared or unknown:
|
|
3740
|
+
conflicts.append({"cr_id": i, "shared": shared, "unknown": unknown})
|
|
3741
|
+
|
|
3742
|
+
clean = (moved or ctx_moved) and not conflicts
|
|
3743
|
+
stamped = []
|
|
3744
|
+
if stamp and clean:
|
|
3745
|
+
if moved and os.path.isfile(my_spec):
|
|
3746
|
+
open(my_spec, "w", encoding="utf-8").write(
|
|
3747
|
+
re.sub(r"^\*\*Base version\*\*:.*$", "**Base version**: %s" % current,
|
|
3748
|
+
spec_text, count=1, flags=re.M))
|
|
3749
|
+
stamped.append({"file": os.path.basename(my_spec), "header": "Base version",
|
|
3750
|
+
"from": base_version, "to": current})
|
|
3751
|
+
if ctx_moved and os.path.isfile(my_model):
|
|
3752
|
+
open(my_model, "w", encoding="utf-8").write(
|
|
3753
|
+
re.sub(r"^\*\*Base context version\*\*:.*$", "**Base context version**: %s" % current_ctx,
|
|
3754
|
+
model_text, count=1, flags=re.M))
|
|
3755
|
+
stamped.append({"file": os.path.basename(my_model), "header": "Base context version",
|
|
3756
|
+
"from": base_ctx, "to": current_ctx})
|
|
3757
|
+
if stamped:
|
|
3758
|
+
moved = ctx_moved = False
|
|
3759
|
+
|
|
3760
|
+
ok = not (moved or ctx_moved)
|
|
3761
|
+
if not (base_version or base_ctx):
|
|
3762
|
+
msg = "%s carries no base stamp, so there is nothing to compare — treated as not moved" % cr
|
|
3763
|
+
elif ok and not stamped:
|
|
3764
|
+
msg = "%s is stamped against the current masters (%s, context %s)" % (cr, current or "—", current_ctx or "—")
|
|
3765
|
+
elif stamped:
|
|
3766
|
+
msg = "%s was re-stamped: %s. Nothing that landed in between (%s) touched its aggregates (%s)" % (
|
|
3767
|
+
cr, "; ".join("%s %s → %s" % (s["header"], s["from"], s["to"]) for s in stamped),
|
|
3768
|
+
", ".join(r["cr_id"] for r in rows) or "no recorded change", ", ".join(sorted(mine)) or "none named")
|
|
3769
|
+
else:
|
|
3770
|
+
parts = []
|
|
3771
|
+
for c in conflicts:
|
|
3772
|
+
parts.append("%s (%s)" % (c["cr_id"], "aggregates unreadable" if c["unknown"]
|
|
3773
|
+
else "also touched " + ", ".join(c["shared"])))
|
|
3774
|
+
msg = ("%s was authored against %s%s but the masters are at %s%s, and what landed in "
|
|
3775
|
+
"between overlaps it: %s" % (
|
|
3776
|
+
cr, base_version or "—", "" if not base_ctx else " (context %s)" % base_ctx,
|
|
3777
|
+
current or "—", "" if not current_ctx else " (context %s)" % current_ctx,
|
|
3778
|
+
"; ".join(parts)))
|
|
3779
|
+
|
|
3780
|
+
print(json.dumps({
|
|
3781
|
+
"cr_id": cr, "ok": ok, "clean": clean, "moved": moved, "context_moved": ctx_moved,
|
|
3782
|
+
"base_version": base_version, "current_version": current,
|
|
3783
|
+
"base_context_version": base_ctx, "current_context_version": current_ctx,
|
|
3784
|
+
"aggregates": sorted(mine), "landed_since": rows,
|
|
3785
|
+
"conflicts": conflicts, "conflict_count": len(conflicts),
|
|
3786
|
+
"conflict_ids": ", ".join(c["cr_id"] for c in conflicts),
|
|
3787
|
+
"stamped": stamped, "message": msg,
|
|
3788
|
+
}))
|
|
3789
|
+
PY
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3315
3792
|
# ─────────────────────────────── cr-list ───────────────────────────────────
|
|
3316
3793
|
# The changes currently OPEN on a feature, in wave order. This is what lets a delta
|
|
3317
3794
|
# stage ASK which change it is working on instead of inferring one: many may be open at
|
|
@@ -3555,11 +4032,12 @@ PY
|
|
|
3555
4032
|
|
|
3556
4033
|
# ─────────────────────────────── cr-overlap ────────────────────────────────
|
|
3557
4034
|
# Which OTHER open changes touch the same aggregates as this one — read off the
|
|
3558
|
-
#
|
|
3559
|
-
# domain-model.md
|
|
4035
|
+
# `**Aggregates touched**:` header of each pending model delta. That header names the
|
|
4036
|
+
# `## Aggregate: <Name>` sections of domain-model.md the delta merges into; the delta
|
|
4037
|
+
# itself carries no such headings, the master does.
|
|
3560
4038
|
#
|
|
3561
4039
|
# DETERMINISTIC ON PURPOSE. Two deltas collide when they change the same aggregate, and
|
|
3562
|
-
# that is a fact about two files'
|
|
4040
|
+
# that is a fact about two files' headers — a regex answers it exactly, at no cost, and
|
|
3563
4041
|
# the same way every run. Handing the question to a model instead would make the one
|
|
3564
4042
|
# check that can be exact the one check that can hallucinate.
|
|
3565
4043
|
#
|
|
@@ -3602,14 +4080,27 @@ def read(p):
|
|
|
3602
4080
|
def wave_of(text, default=1):
|
|
3603
4081
|
m = re.search(r"^\*\*Wave\*\*:\s*(\d+)", text, re.M)
|
|
3604
4082
|
return int(m.group(1)) if m else default
|
|
3605
|
-
# The
|
|
3606
|
-
#
|
|
3607
|
-
# "
|
|
4083
|
+
# The aggregates a model delta declares, from its `**Aggregates touched**:` header — the
|
|
4084
|
+
# same list finalize-delta opens master sections for. Backticked names are the template's
|
|
4085
|
+
# form and win when present, so prose around them ("`Appointment` (lifecycle), `Patient`
|
|
4086
|
+
# (read)") adds nothing; a value with no backticks is split on , ; / & "and" and each
|
|
4087
|
+
# piece's leading name is taken. The template placeholder (`[Root]`) and "None — behaviour
|
|
4088
|
+
# only" name no aggregate, so an unfilled or model-free delta reports none rather than
|
|
4089
|
+
# colliding with every other change.
|
|
3608
4090
|
def aggregates_of(text):
|
|
4091
|
+
m = re.search(r"^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$", text, re.M)
|
|
4092
|
+
if not m:
|
|
4093
|
+
return []
|
|
4094
|
+
value = re.sub(r"<!--.*?(?:-->|$)", "", m.group(1)).strip()
|
|
4095
|
+
names = re.findall(r"`([^`]+)`", value)
|
|
4096
|
+
if not names:
|
|
4097
|
+
names = [p.group(0) for p in
|
|
4098
|
+
(re.match(r"[A-Za-z][A-Za-z0-9_]*", piece.strip())
|
|
4099
|
+
for piece in re.split(r"[,;/&]|\band\b", value)) if p]
|
|
3609
4100
|
out = []
|
|
3610
|
-
for
|
|
3611
|
-
name =
|
|
3612
|
-
if not name or name
|
|
4101
|
+
for name in names:
|
|
4102
|
+
name = name.strip()
|
|
4103
|
+
if not name or name[0] in "<[" or re.match(r"none\b", name, re.I):
|
|
3613
4104
|
continue
|
|
3614
4105
|
if name not in out:
|
|
3615
4106
|
out.append(name)
|
|
@@ -3992,7 +4483,7 @@ cmd_cr_merge_check() {
|
|
|
3992
4483
|
case "$1" in
|
|
3993
4484
|
--cr-id) cr_id="$2"; shift 2 ;;
|
|
3994
4485
|
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
3995
|
-
--help|-h) echo "cr-merge-check --cr-id <id> [--feature-dir <path>] -> {rebase_needed, base_version, current_version, blockers[], blocker_count, wave, …}"; return 0 ;;
|
|
4486
|
+
--help|-h) echo "cr-merge-check --cr-id <id> [--feature-dir <path>] -> {rebase_needed, base_version, current_version, blockers[], blocker_count, wave, wave_ordering_ok, unordered[], …}"; return 0 ;;
|
|
3996
4487
|
*) echo "cr-merge-check: unknown arg '$1'" >&2; return 1 ;;
|
|
3997
4488
|
esac
|
|
3998
4489
|
done
|
|
@@ -4020,14 +4511,8 @@ def norm(v):
|
|
|
4020
4511
|
if not v: return ""
|
|
4021
4512
|
return v if v.startswith("v") else "v" + v
|
|
4022
4513
|
|
|
4023
|
-
# THIS change's own spec half
|
|
4514
|
+
# THIS change's own spec half.
|
|
4024
4515
|
my_spec = os.path.join(fd, "changes", cr, "delta-spec.md")
|
|
4025
|
-
def wave_of(p, default=1):
|
|
4026
|
-
try: t = open(p, encoding="utf-8").read()
|
|
4027
|
-
except Exception: return default
|
|
4028
|
-
m = re.search(r"^\*\*Wave\*\*:[ \t]*(\d+)", t, re.M)
|
|
4029
|
-
return int(m.group(1)) if m else default
|
|
4030
|
-
wave = wave_of(my_spec)
|
|
4031
4516
|
base_version = norm(header(my_spec, "Base version"))
|
|
4032
4517
|
|
|
4033
4518
|
# The master's CURRENT version, from the ledger — the same authority version-read reports,
|
|
@@ -4044,41 +4529,65 @@ if not current:
|
|
|
4044
4529
|
# absence of evidence and is not treated as a move.
|
|
4045
4530
|
rebase_needed = bool(base_version and current and base_version != current)
|
|
4046
4531
|
|
|
4047
|
-
#
|
|
4048
|
-
#
|
|
4049
|
-
# a
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
#
|
|
4068
|
-
#
|
|
4069
|
-
|
|
4070
|
-
# had already shifted under it. An id appearing in spec.md is the on-disk proof that the
|
|
4071
|
-
# merge ran (it is the Change History row finalize-delta appends), which is the same
|
|
4072
|
-
# evidence cr-recall-check reads for the same question.
|
|
4073
|
-
spec_text = ""
|
|
4532
|
+
# BOTH CONTEXT-WIDE QUESTIONS COME FROM aw-crstate, for every reader of them and for both
|
|
4533
|
+
# shells: which open changes this one is ordered against, and which of them have already
|
|
4534
|
+
# merged. Each was once a copy of the scan living in this payload and another in its
|
|
4535
|
+
# PowerShell twin, which is how the two came to answer the same question differently.
|
|
4536
|
+
#
|
|
4537
|
+
# PREDECESSORS. Every open change in the context, both halves — changes/<id>/ under each
|
|
4538
|
+
# feature is the authority for "open", and the pending model deltas are unioned in so a
|
|
4539
|
+
# model-only artifact of a half-built change is still seen. A change whose `**Wave**:` is
|
|
4540
|
+
# unreadable is reported as unordered rather than assumed to be wave 1, which is what let an
|
|
4541
|
+
# unstamped delta pose as a predecessor and let an unstamped SUBJECT skip this fence
|
|
4542
|
+
# entirely.
|
|
4543
|
+
#
|
|
4544
|
+
# MERGED-OPEN. The masters can move without the version moving: finalize-delta merges and
|
|
4545
|
+
# close-delta bumps, so between the two the masters carry a change the number does not yet
|
|
4546
|
+
# reflect, and a second delta sharing the same base_version would compare equal and merge
|
|
4547
|
+
# onto masters that had already shifted under it. The proof is the Change History row
|
|
4548
|
+
# finalize-delta appends, carrying the id in its CR cell — which replaced `cr in spec.md`,
|
|
4549
|
+
# a test that fired on an id quoted in prose and matched a prefix of a longer id. It looks
|
|
4550
|
+
# where a merge can actually show: this feature's spec AND the context model every feature
|
|
4551
|
+
# shares, so a SIBLING feature's merged-unclosed change is no longer invisible. A sibling
|
|
4552
|
+
# blocks only where it touched an aggregate this delta also names (`blocking`); a merge in
|
|
4553
|
+
# this feature blocks outright, because it moved this delta's own master.
|
|
4554
|
+
_m, _why = None, ""
|
|
4074
4555
|
try:
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4556
|
+
import importlib.util
|
|
4557
|
+
_mod_path = os.path.join(root, ".appweaver", "scripts", "python", "aw-crstate.py")
|
|
4558
|
+
_spec = importlib.util.spec_from_file_location("aw_crstate", _mod_path)
|
|
4559
|
+
_loaded = importlib.util.module_from_spec(_spec)
|
|
4560
|
+
_spec.loader.exec_module(_loaded)
|
|
4561
|
+
_m = _loaded
|
|
4562
|
+
except Exception as _e:
|
|
4563
|
+
_why = str(_e)
|
|
4564
|
+
|
|
4565
|
+
order, merged_open, merged_text = None, None, ""
|
|
4566
|
+
if _m is not None:
|
|
4567
|
+
try:
|
|
4568
|
+
order = _m.wave_order(ctx, fd, cr, "lower", "context")
|
|
4569
|
+
merged_open = [r for r in _m.merged_open(ctx, fd, cr) if r["blocking"]]
|
|
4570
|
+
merged_text = _m.merged_open_text(merged_open)
|
|
4571
|
+
except Exception as _e:
|
|
4572
|
+
order, merged_open, _why = None, None, str(_e)
|
|
4573
|
+
|
|
4574
|
+
# Fail CLOSED on an unreadable helper: report the state as blocking rather than clear, and
|
|
4575
|
+
# say which check could not run rather than which condition was found.
|
|
4576
|
+
if order is None:
|
|
4577
|
+
order = {"wave": None, "wave_known": False, "related": [], "related_count": 0,
|
|
4578
|
+
"related_ids": "", "unknown": [], "unknown_count": 0, "unknown_ids": "",
|
|
4579
|
+
"unknown_blocks": True, "wave_ordering_ok": False,
|
|
4580
|
+
"wave_problem": "the wave reader could not be run (%s), so no open change in "
|
|
4581
|
+
"this context is ordered against %s at all" % (_why, cr)}
|
|
4582
|
+
if merged_open is None:
|
|
4583
|
+
merged_open = [{"cr_id": "(merged-open check failed: %s)" % _why, "blocking": True}]
|
|
4584
|
+
merged_text = merged_open[0]["cr_id"]
|
|
4585
|
+
blockers = order["related"]
|
|
4079
4586
|
|
|
4080
4587
|
print(json.dumps({
|
|
4081
|
-
"ok": True, "cr_id": cr,
|
|
4588
|
+
"ok": True, "cr_id": cr,
|
|
4589
|
+
"wave": order["wave"] if order["wave"] is not None else "",
|
|
4590
|
+
"wave_known": order["wave_known"],
|
|
4082
4591
|
"exists": os.path.isdir(os.path.join(fd, "changes", cr)),
|
|
4083
4592
|
# REBASE half — the masters moved because a change CLOSED and bumped the number.
|
|
4084
4593
|
"base_version": base_version, "current_version": current,
|
|
@@ -4087,14 +4596,20 @@ print(json.dumps({
|
|
|
4087
4596
|
# PREDECESSOR half. Lower wave = committed to land first.
|
|
4088
4597
|
"blockers": blockers, "blocker_count": len(blockers),
|
|
4089
4598
|
"has_blockers": len(blockers) > 0,
|
|
4090
|
-
"blocker_ids":
|
|
4599
|
+
"blocker_ids": order["related_ids"],
|
|
4600
|
+
# The changes this one cannot be ordered against at all, in either direction.
|
|
4601
|
+
"unordered": order["unknown"], "unordered_count": order["unknown_count"],
|
|
4602
|
+
"unordered_ids": order["unknown_ids"],
|
|
4603
|
+
"wave_ordering_ok": order["wave_ordering_ok"],
|
|
4604
|
+
"wave_problem": order["wave_problem"],
|
|
4091
4605
|
# The other way the masters move: a change MERGED and has not closed yet.
|
|
4092
4606
|
"merged_open": merged_open, "merged_open_count": len(merged_open),
|
|
4093
4607
|
"has_merged_open": len(merged_open) > 0,
|
|
4094
|
-
"merged_open_ids":
|
|
4095
|
-
# All
|
|
4096
|
-
# and a gate that must read
|
|
4097
|
-
"merge_ok": (not rebase_needed) and
|
|
4608
|
+
"merged_open_ids": merged_text,
|
|
4609
|
+
# All four refusals as ONE positive flag, because the engine takes no compound condition
|
|
4610
|
+
# and a gate that must read four booleans would need four passes over one question.
|
|
4611
|
+
"merge_ok": (not rebase_needed) and order["wave_ordering_ok"] and len(blockers) == 0
|
|
4612
|
+
and len(merged_open) == 0,
|
|
4098
4613
|
}))
|
|
4099
4614
|
PY
|
|
4100
4615
|
}
|
|
@@ -4119,7 +4634,7 @@ cmd_cr_recall_check() {
|
|
|
4119
4634
|
case "$1" in
|
|
4120
4635
|
--cr-id) cr_id="$2"; shift 2 ;;
|
|
4121
4636
|
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
4122
|
-
--help|-h) echo "cr-recall-check --cr-id <id> [--feature-dir <path>] -> {merged, dependants[], dependant_count, wave, …}"; return 0 ;;
|
|
4637
|
+
--help|-h) echo "cr-recall-check --cr-id <id> [--feature-dir <path>] -> {merged, dependants[], dependant_count, wave, wave_ordering_ok, unordered[], …}"; return 0 ;;
|
|
4123
4638
|
*) echo "cr-recall-check: unknown arg '$1'" >&2; return 1 ;;
|
|
4124
4639
|
esac
|
|
4125
4640
|
done
|
|
@@ -4127,47 +4642,62 @@ cmd_cr_recall_check() {
|
|
|
4127
4642
|
[ -n "$cr_id" ] || { echo "cr-recall-check: --cr-id required" >&2; return 1; }
|
|
4128
4643
|
local repo_root; repo_root=$(get_repo_root)
|
|
4129
4644
|
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; }
|
|
4130
|
-
|
|
4645
|
+
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
4646
|
+
AW_FD="$fd" AW_CR="$cr_id" AW_ROOT="$repo_root" AW_CTX="$ctx_dir" python3 - <<'PY'
|
|
4131
4647
|
import json, os, re
|
|
4132
4648
|
fd = os.environ["AW_FD"]; cr = os.environ["AW_CR"]
|
|
4649
|
+
root = os.environ.get("AW_ROOT", ""); ctx = os.environ.get("AW_CTX", "")
|
|
4133
4650
|
changes = os.path.join(fd, "changes")
|
|
4134
4651
|
cr_dir = os.path.join(changes, cr)
|
|
4135
4652
|
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
m = re.search(r'^\*\*Wave\*\*:[ \t]*(\d+)', open(p, encoding="utf-8").read(), re.M)
|
|
4141
|
-
if m:
|
|
4142
|
-
return int(m.group(1))
|
|
4143
|
-
return 1
|
|
4144
|
-
|
|
4653
|
+
# MERGED = a Change History row whose CR cell IS this change, in this feature's spec or in
|
|
4654
|
+
# the context model every feature shares — aw-crstate owns that test for all three readers
|
|
4655
|
+
# of it. The old test was `cr in line`, which fired on an id quoted in prose and matched a
|
|
4656
|
+
# prefix of a longer id.
|
|
4145
4657
|
spec = os.path.join(fd, "spec.md")
|
|
4146
|
-
merged, evidence = False, ""
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4658
|
+
merged, evidence, merged_doc = False, "", ""
|
|
4659
|
+
order, _why = None, ""
|
|
4660
|
+
try:
|
|
4661
|
+
import importlib.util
|
|
4662
|
+
_mod_path = os.path.join(root, ".appweaver", "scripts", "python", "aw-crstate.py")
|
|
4663
|
+
_spec = importlib.util.spec_from_file_location("aw_crstate", _mod_path)
|
|
4664
|
+
_m = importlib.util.module_from_spec(_spec)
|
|
4665
|
+
_spec.loader.exec_module(_m)
|
|
4666
|
+
evidence, merged_doc = _m.merged_in([spec, os.path.join(ctx, "domain-model.md")], cr)
|
|
4667
|
+
merged = bool(evidence)
|
|
4668
|
+
# DEPENDANTS, from the same reader the merge fence uses in the opposite direction. An
|
|
4669
|
+
# unreadable `**Wave**:` is not wave 1 here either: it used to mean `w > wave` was false
|
|
4670
|
+
# and the change was not counted, so recall withdrew the ground from under a dependant
|
|
4671
|
+
# it could not see. Waves have no ceiling, so any unordered change could sit above this
|
|
4672
|
+
# one and every one of them holds recall.
|
|
4673
|
+
order = _m.wave_order(ctx, fd, cr, "higher", "feature")
|
|
4674
|
+
except Exception as _e:
|
|
4675
|
+
# Fail CLOSED: an unreadable helper must not report "never merged", which is the
|
|
4676
|
+
# answer that lets close-delta archive a change nobody merged.
|
|
4677
|
+
merged, evidence, _why = True, "(merged check unavailable — treated as merged)", str(_e)
|
|
4678
|
+
|
|
4679
|
+
if order is None:
|
|
4680
|
+
order = {"wave": None, "wave_known": False, "related": [], "related_count": 0,
|
|
4681
|
+
"related_ids": "", "unknown": [], "unknown_count": 0, "unknown_ids": "",
|
|
4682
|
+
"unknown_blocks": True, "wave_ordering_ok": False,
|
|
4683
|
+
"wave_problem": "the wave reader could not be run (%s), so no open change in "
|
|
4684
|
+
"this feature is ordered against %s at all" % (_why, cr)}
|
|
4685
|
+
dependants = order["related"]
|
|
4163
4686
|
|
|
4164
4687
|
print(json.dumps({
|
|
4165
|
-
"ok": True, "cr_id": cr, "exists": os.path.isdir(cr_dir),
|
|
4688
|
+
"ok": True, "cr_id": cr, "exists": os.path.isdir(cr_dir),
|
|
4689
|
+
"wave": order["wave"] if order["wave"] is not None else "",
|
|
4690
|
+
"wave_known": order["wave_known"],
|
|
4166
4691
|
"merged": merged, "merge_evidence": evidence,
|
|
4167
4692
|
"dependants": dependants,
|
|
4168
|
-
"dependant_ids":
|
|
4693
|
+
"dependant_ids": order["related_ids"],
|
|
4169
4694
|
"dependant_count": len(dependants),
|
|
4170
4695
|
"has_dependants": len(dependants) > 0,
|
|
4696
|
+
# The changes this one cannot be ordered against at all, in either direction.
|
|
4697
|
+
"unordered": order["unknown"], "unordered_count": order["unknown_count"],
|
|
4698
|
+
"unordered_ids": order["unknown_ids"],
|
|
4699
|
+
"wave_ordering_ok": order["wave_ordering_ok"],
|
|
4700
|
+
"wave_problem": order["wave_problem"],
|
|
4171
4701
|
}))
|
|
4172
4702
|
PY
|
|
4173
4703
|
}
|
|
@@ -4175,6 +4705,12 @@ PY
|
|
|
4175
4705
|
# ──────────────────── plan-gate-stamp / plan-gate-check ────────────────────
|
|
4176
4706
|
# THE RECEIPT THAT analyze-plan PASSED, and over WHICH BYTES.
|
|
4177
4707
|
#
|
|
4708
|
+
# THE RULE ITSELF LIVES IN python/aw-plangate.py, which both shells invoke — these two are
|
|
4709
|
+
# thin wrappers that resolve the feature and hand over. It is a set of sha comparisons with
|
|
4710
|
+
# three deliberate asymmetries in it, and while it was embedded twice (a heredoc here, a
|
|
4711
|
+
# native function there) every change to it had to be re-derived by hand on the other side
|
|
4712
|
+
# and kept in step by eye. It changed twice that way. Now there is nothing to keep in step.
|
|
4713
|
+
#
|
|
4178
4714
|
# implement generates from the lane plans and deliberately does NOT read the spec — its
|
|
4179
4715
|
# own header says "analyze-plan already conformed the plans to it". That sentence was an
|
|
4180
4716
|
# assumption about the order the operator ran things in, not a fact anything checked: the
|
|
@@ -4190,69 +4726,86 @@ PY
|
|
|
4190
4726
|
# WHY THE GATE AND NOT THE AUTHOR. Stamping at `plan` would record "this plan was authored
|
|
4191
4727
|
# from that spec". The question implement depends on is stronger — "were these plans PROVEN
|
|
4192
4728
|
# against this spec" — and a plan authored from the current spec can still be wrong. So the
|
|
4193
|
-
# receipt is written where the proof happens, by analyze-plan,
|
|
4194
|
-
#
|
|
4195
|
-
#
|
|
4729
|
+
# receipt is written where the proof happens, by analyze-plan, and it hashes every document
|
|
4730
|
+
# the gate actually read: the spec, the context's domain model (check 4 reads it) and each
|
|
4731
|
+
# lane plan that was present.
|
|
4732
|
+
#
|
|
4733
|
+
# THE RECEIPT CARRIES THE VERDICT, and every master-mode run writes one. A receipt written
|
|
4734
|
+
# only on a clean pass is never written on a feature whose spec misses a domain constraint —
|
|
4735
|
+
# a LIFT-FAILURE analyze-plan may not close — and then the staleness check has nothing to
|
|
4736
|
+
# compare. The verdict splits the gaps by who can close them (plan-gaps):
|
|
4737
|
+
# conformant no gaps
|
|
4738
|
+
# routed-gaps only gaps /appweaver specify must close — implement warns and generates
|
|
4739
|
+
# gaps at least one gap a plan rewrite closes — implement refuses
|
|
4196
4740
|
#
|
|
4197
4741
|
# The absence of a receipt is INFORMATION, not an error: it means this feature has never
|
|
4198
4742
|
# been through the gate, which every tree that predates this op is true of. It is reported
|
|
4199
|
-
# as untracked and blocks nothing.
|
|
4200
|
-
#
|
|
4743
|
+
# as untracked and blocks nothing. A receipt that EXISTS refuses when the bytes have moved
|
|
4744
|
+
# since, or when its verdict is `gaps`. A receipt written before the verdict existed was
|
|
4745
|
+
# written on a clean pass, and reads as `conformant`.
|
|
4201
4746
|
_plan_gate_file() { printf '%s/.aw-plan-gate.json' "$1"; }
|
|
4202
4747
|
|
|
4748
|
+
# Which analyze-plan gaps a plan rewrite can close, and which only /appweaver specify can.
|
|
4749
|
+
# A gap routes to specify when the analyze pass tagged it `route: specify` (a constraint the
|
|
4750
|
+
# spec never lifted, structure domain-model.md does not define, behaviour the spec does not
|
|
4751
|
+
# describe), or when its text opens with LIFT-FAILURE whatever the tag says. Anything else —
|
|
4752
|
+
# an untagged gap included — is a plan gap. Unreadable input is one plan gap: a verdict that
|
|
4753
|
+
# could not be read must not pass.
|
|
4754
|
+
cmd_plan_gaps() {
|
|
4755
|
+
local gaps=""
|
|
4756
|
+
while [ $# -gt 0 ]; do
|
|
4757
|
+
case "$1" in
|
|
4758
|
+
--gaps) gaps="$2"; shift 2 ;;
|
|
4759
|
+
--help|-h) echo "plan-gaps --gaps <json list> -> {verdict, plan_ok, plan_gaps, routed_gaps, routed[], routed_csv}"; return 0 ;;
|
|
4760
|
+
*) echo "plan-gaps: unknown arg '$1'" >&2; return 1 ;;
|
|
4761
|
+
esac
|
|
4762
|
+
done
|
|
4763
|
+
AW_GAPS="$gaps" python3 - <<'PY'
|
|
4764
|
+
import json, os
|
|
4765
|
+
raw = (os.environ.get("AW_GAPS") or "").strip()
|
|
4766
|
+
try:
|
|
4767
|
+
gaps = json.loads(raw) if raw else []
|
|
4768
|
+
if not isinstance(gaps, list):
|
|
4769
|
+
raise ValueError("not a list")
|
|
4770
|
+
readable = True
|
|
4771
|
+
except Exception:
|
|
4772
|
+
gaps, readable = [], False
|
|
4773
|
+
plan, routed = 0, []
|
|
4774
|
+
for g in gaps:
|
|
4775
|
+
d = g if isinstance(g, dict) else {"gap": str(g)}
|
|
4776
|
+
text = str(d.get("gap", "")).strip()
|
|
4777
|
+
if text.upper().startswith("LIFT-FAILURE") or str(d.get("route", "")).strip().lower() == "specify":
|
|
4778
|
+
ref = str(d.get("spec_ref", "") or "").strip()
|
|
4779
|
+
routed.append(("%s: %s" % (ref, text)) if ref and ref != "—" else text)
|
|
4780
|
+
else:
|
|
4781
|
+
plan += 1
|
|
4782
|
+
if not readable:
|
|
4783
|
+
plan += 1
|
|
4784
|
+
verdict = "gaps" if plan else ("routed-gaps" if routed else "conformant")
|
|
4785
|
+
print(json.dumps({"ok": True, "readable": readable, "verdict": verdict, "plan_ok": plan == 0,
|
|
4786
|
+
"plan_gaps": plan, "routed_gaps": len(routed), "routed": routed,
|
|
4787
|
+
"routed_csv": "; ".join(routed)}))
|
|
4788
|
+
PY
|
|
4789
|
+
}
|
|
4790
|
+
|
|
4203
4791
|
cmd_plan_gate_stamp() {
|
|
4204
|
-
|
|
4792
|
+
case "${1:-}" in
|
|
4793
|
+
--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 ;;
|
|
4794
|
+
esac
|
|
4795
|
+
local feature_dir_arg="" rest=()
|
|
4205
4796
|
while [ $# -gt 0 ]; do
|
|
4206
4797
|
case "$1" in
|
|
4207
4798
|
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
4208
|
-
|
|
4209
|
-
--mode) mode="$2"; shift 2 ;;
|
|
4210
|
-
--help|-h) echo "plan-gate-stamp [--feature-dir <path>] [--spec-version <v>] [--mode <text>] -> {file, gated, hashed[], count}"; return 0 ;;
|
|
4211
|
-
*) echo "plan-gate-stamp: unknown arg '$1'" >&2; return 1 ;;
|
|
4799
|
+
*) rest+=("$1"); shift ;;
|
|
4212
4800
|
esac
|
|
4213
4801
|
done
|
|
4214
4802
|
local repo_root; repo_root=$(get_repo_root)
|
|
4215
4803
|
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; }
|
|
4216
4804
|
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
try:
|
|
4222
|
-
with open(p, "rb") as f: return hashlib.sha256(f.read()).hexdigest()
|
|
4223
|
-
except Exception: return ""
|
|
4224
|
-
def rel(p): return os.path.relpath(p, root)
|
|
4225
|
-
# Every document the gate READ. A lane that is not declared has no plan on disk and is
|
|
4226
|
-
# simply absent from the map — the check treats a name it does not carry as not gated,
|
|
4227
|
-
# never as changed, so declaring a second lane later does not read as tampering.
|
|
4228
|
-
#
|
|
4229
|
-
# THE LEGACY NAMES ARE HERE FOR THE SAME REASON version-stamp CARRIES THEM. feature-paths
|
|
4230
|
-
# resolves design.md / design.ui.md under the backend_plan / frontend_plan keys, so a tree
|
|
4231
|
-
# that has not been through `plan` since the rename — every brownfield feature `adopt`
|
|
4232
|
-
# produces — is GATED on those documents. Hashing only the new names would leave such a
|
|
4233
|
-
# feature with an empty plan map: a receipt that looks tracked and fresh while the document
|
|
4234
|
-
# implement actually generates from is unwatched.
|
|
4235
|
-
plans = {}
|
|
4236
|
-
for name in ("backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md"):
|
|
4237
|
-
h = sha(os.path.join(fd, name))
|
|
4238
|
-
if h: plans[name] = h
|
|
4239
|
-
rec = {
|
|
4240
|
-
"gated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
4241
|
-
"gate_mode": os.environ.get("AW_MODE", "master"),
|
|
4242
|
-
"spec_version": os.environ.get("AW_VER", "").strip(),
|
|
4243
|
-
"spec_sha": sha(os.path.join(fd, "spec.md")),
|
|
4244
|
-
"model_sha": sha(os.path.join(ctx, "domain-model.md")),
|
|
4245
|
-
"plans": plans,
|
|
4246
|
-
"note": ("Written by analyze-plan when it PASSED. It records the bytes that were "
|
|
4247
|
-
"proven against each other, not a version — the plans' **Version**: headers "
|
|
4248
|
-
"are stamped in lockstep by close-delta and cannot answer this. implement "
|
|
4249
|
-
"reads it through plan-gate-check."),
|
|
4250
|
-
}
|
|
4251
|
-
p = os.path.join(fd, ".aw-plan-gate.json")
|
|
4252
|
-
open(p, "w", encoding="utf-8").write(json.dumps(rec, indent=2) + "\n")
|
|
4253
|
-
print(json.dumps({"file": rel(p), "gated": rec["gated"], "spec_version": rec["spec_version"],
|
|
4254
|
-
"hashed": sorted(plans.keys()), "count": len(plans), "ok": True}))
|
|
4255
|
-
PY
|
|
4805
|
+
local core="$SCRIPT_DIR/../python/aw-plangate.py"
|
|
4806
|
+
if [ ! -f "$core" ]; then echo "plan-gate-stamp: core not found at $core" >&2; return 1; fi
|
|
4807
|
+
if ! command -v python3 >/dev/null 2>&1; then echo "plan-gate-stamp: python3 is required" >&2; return 1; fi
|
|
4808
|
+
python3 "$core" stamp --feature-dir "$fd" --context-dir "$ctx_dir" --root "$repo_root" "${rest[@]+"${rest[@]}"}"
|
|
4256
4809
|
}
|
|
4257
4810
|
|
|
4258
4811
|
cmd_plan_gate_check() {
|
|
@@ -4260,56 +4813,53 @@ cmd_plan_gate_check() {
|
|
|
4260
4813
|
while [ $# -gt 0 ]; do
|
|
4261
4814
|
case "$1" in
|
|
4262
4815
|
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
4263
|
-
--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 ;;
|
|
4816
|
+
--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 ;;
|
|
4264
4817
|
*) echo "plan-gate-check: unknown arg '$1'" >&2; return 1 ;;
|
|
4265
4818
|
esac
|
|
4266
4819
|
done
|
|
4267
4820
|
local repo_root; repo_root=$(get_repo_root)
|
|
4268
4821
|
local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "plan-gate-check: no feature set and no --feature-dir" >&2; return 1; }
|
|
4269
4822
|
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
if
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
out["reason"] = "since analyze-plan passed on %s: %s" % (out["gated"], "; ".join(bits))
|
|
4311
|
-
print(json.dumps(out))
|
|
4312
|
-
PY
|
|
4823
|
+
local core="$SCRIPT_DIR/../python/aw-plangate.py"
|
|
4824
|
+
if [ ! -f "$core" ]; then echo "plan-gate-check: core not found at $core" >&2; return 1; fi
|
|
4825
|
+
if ! command -v python3 >/dev/null 2>&1; then echo "plan-gate-check: python3 is required" >&2; return 1; fi
|
|
4826
|
+
python3 "$core" check --feature-dir "$fd" --context-dir "$ctx_dir" --root "$repo_root"
|
|
4827
|
+
}
|
|
4828
|
+
|
|
4829
|
+
# ─────────────────────────────── gap-route ─────────────────────────────────
|
|
4830
|
+
# Split a gate's findings by the command that CLOSES each one. The checks already tag
|
|
4831
|
+
# every finding with `fix`; this turns that tag into the routing a gate message prints,
|
|
4832
|
+
# so a plan defect is never sent to `implement` — which generates FROM the plan and would
|
|
4833
|
+
# reproduce it. Thin wrapper: the logic is in aw-gaps.py, called identically by both shells.
|
|
4834
|
+
cmd_gap_route() {
|
|
4835
|
+
case "${1:-}" in
|
|
4836
|
+
--help|-h) echo "gap-route --gaps '<json>'... [--cr <id>] -> {total, high_count, <route>_count, <route>_refs, <route>_text, summary, routes}"; return 0 ;;
|
|
4837
|
+
esac
|
|
4838
|
+
local core="$SCRIPT_DIR/../python/aw-gaps.py"
|
|
4839
|
+
if [ ! -f "$core" ]; then echo "gap-route: core not found at $core" >&2; return 1; fi
|
|
4840
|
+
if ! command -v python3 >/dev/null 2>&1; then echo "gap-route: python3 is required" >&2; return 1; fi
|
|
4841
|
+
python3 "$core" route "$@"
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4844
|
+
# ──────────────────────────────── plan-coverage ─────────────────────────────
|
|
4845
|
+
# Which of a lane plan's elements the generated code realises: each element the plan
|
|
4846
|
+
# names, whether the lane's code names it, and whether the generating model reported it
|
|
4847
|
+
# done. implement runs it after each lane generates, so a pass that stopped part-way is
|
|
4848
|
+
# caught instead of looking like a finished one. One implementation
|
|
4849
|
+
# (python/aw-plan-coverage.py), called identically by both shells.
|
|
4850
|
+
cmd_plan_coverage() {
|
|
4851
|
+
case "${1:-}" in
|
|
4852
|
+
--help|-h) echo "plan-coverage --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--source-path <p>] [--test-path <p>] [--reported <json list>]... [--generated <json list>] [--list-only] -> {ok, outcome, total, names[], names_csv, elements[], gaps[], gap_names[], gap_count, gaps_text, message}"; return 0 ;;
|
|
4853
|
+
esac
|
|
4854
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
4855
|
+
local core="$SCRIPT_DIR/../python/aw-plan-coverage.py"
|
|
4856
|
+
if [ ! -f "$core" ]; then
|
|
4857
|
+
echo "plan-coverage: core not found at $core" >&2; return 1
|
|
4858
|
+
fi
|
|
4859
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
4860
|
+
echo "plan-coverage: python3 is required" >&2; return 1
|
|
4861
|
+
fi
|
|
4862
|
+
python3 "$core" --repo "$repo_root" "$@"
|
|
4313
4863
|
}
|
|
4314
4864
|
|
|
4315
4865
|
# Stamp a version into the MASTER documents' `**Version**:` headers — the
|
|
@@ -4454,6 +5004,8 @@ case "$sub" in
|
|
|
4454
5004
|
cr-overlap) cmd_cr_overlap "$@" ;;
|
|
4455
5005
|
cr-activate) cmd_cr_activate "$@" ;;
|
|
4456
5006
|
cr-wave) cmd_cr_wave "$@" ;;
|
|
5007
|
+
cr-wave-derive) cmd_cr_wave_derive "$@" ;;
|
|
5008
|
+
cr-rebase) cmd_cr_rebase "$@" ;;
|
|
4457
5009
|
cr-archive) cmd_cr_archive "$@" ;;
|
|
4458
5010
|
cr-recall-check) cmd_cr_recall_check "$@" ;;
|
|
4459
5011
|
cr-merge-check) cmd_cr_merge_check "$@" ;;
|
|
@@ -4462,8 +5014,11 @@ case "$sub" in
|
|
|
4462
5014
|
context-paths) cmd_context_paths "$@" ;;
|
|
4463
5015
|
model-scaffold) cmd_model_scaffold "$@" ;;
|
|
4464
5016
|
version-stamp) cmd_version_stamp "$@" ;;
|
|
5017
|
+
plan-gaps) cmd_plan_gaps "$@" ;;
|
|
4465
5018
|
plan-gate-stamp) cmd_plan_gate_stamp "$@" ;;
|
|
4466
5019
|
plan-gate-check) cmd_plan_gate_check "$@" ;;
|
|
5020
|
+
plan-coverage) cmd_plan_coverage "$@" ;;
|
|
5021
|
+
gap-route) cmd_gap_route "$@" ;;
|
|
4467
5022
|
checklist-read) cmd_checklist_read "$@" ;;
|
|
4468
5023
|
checklist-apply) cmd_checklist_apply "$@" ;;
|
|
4469
5024
|
detect-project) cmd_detect_project "$@" ;;
|
|
@@ -4479,6 +5034,7 @@ case "$sub" in
|
|
|
4479
5034
|
setup-constitution) cmd_setup_constitution "$@" ;;
|
|
4480
5035
|
compose-constitution) cmd_compose_constitution "$@" ;;
|
|
4481
5036
|
constitution-verify) cmd_constitution_verify "$@" ;;
|
|
5037
|
+
principles-list) cmd_principles_list "$@" ;;
|
|
4482
5038
|
agent-context-update) cmd_agent_context_update "$@" ;;
|
|
4483
5039
|
""|--help|-h)
|
|
4484
5040
|
sed -n '2,37p' "$0" | sed 's/^#\{0,1\} \{0,1\}//' ;;
|