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
|
@@ -586,6 +586,7 @@ function Invoke-StackList {
|
|
|
586
586
|
switch ($Rest[$i]) {
|
|
587
587
|
'--kind' { $fwd += '--kind'; $fwd += $Rest[++$i] }
|
|
588
588
|
'--preselect' { $fwd += '--preselect'; $fwd += $Rest[++$i] }
|
|
589
|
+
'--profile' { $fwd += '--profile'; $fwd += $Rest[++$i] }
|
|
589
590
|
'--include-none' { $fwd += '--include-none' }
|
|
590
591
|
'--suggest' { $fwd += '--suggest' }
|
|
591
592
|
default { Write-Error "stack-list: unknown arg '$($Rest[$i])'"; exit 1 }
|
|
@@ -684,6 +685,8 @@ function Invoke-LaneProbe {
|
|
|
684
685
|
# (case-insensitive) — (1) specs/ sub-folders (already scaffolded/adopted) and
|
|
685
686
|
# (2) the OKF bundle's Aggregate concepts (the brownfield feature map, resolved by
|
|
686
687
|
# the SAME shared python core the bash twin calls). Empty source 2 on greenfield.
|
|
688
|
+
# Both are scoped to the ACTIVE BOUNDED CONTEXT — source 1 by the directory listed,
|
|
689
|
+
# source 2 by the `bounded_context:` key onboard stamps on every concept it writes.
|
|
687
690
|
# ─────────────────────── context.json (bounded context) ───────────────────
|
|
688
691
|
# Twin of the bash read_context_json_context_directory / feature_base_rel pair. A
|
|
689
692
|
# bounded context is the container a feature lives in — specs/<Context>/<Feature>/ —
|
|
@@ -861,13 +864,31 @@ function Invoke-ListFeatures {
|
|
|
861
864
|
if (-not $bundle) { $bundle = 'knowledge' }
|
|
862
865
|
if (-not [System.IO.Path]::IsPathRooted($bundle)) { $bundle = Join-Path $repo $bundle }
|
|
863
866
|
|
|
867
|
+
# The OKF half is scoped to the active bounded context the same way. onboard runs
|
|
868
|
+
# once per context and stamps each concept it writes, so a bundle that has seen two
|
|
869
|
+
# runs holds two contexts' aggregates; unfiltered, standing inside one context would
|
|
870
|
+
# offer the other's features and scaffold them into the wrong tree. Empty when no
|
|
871
|
+
# context is active, which keeps the flat layout listing everything.
|
|
872
|
+
$activeCtx = ''
|
|
873
|
+
if ($baseRel -ne 'specs') { $activeCtx = ($baseRel -split '/')[-1] }
|
|
874
|
+
|
|
864
875
|
# Best-effort OKF aggregate list (brownfield feature map). Empty on greenfield.
|
|
865
876
|
$okfAggs = @()
|
|
866
877
|
$py = Get-Py; $invpy = Join-Path $PSScriptRoot '..' 'python' 'aw-inventory.py'
|
|
867
878
|
if ($py -and (Test-Path $bundle) -and (Test-Path $invpy)) {
|
|
868
879
|
try {
|
|
869
|
-
|
|
870
|
-
|
|
880
|
+
# --context only when one is active: Windows PowerShell drops an empty-string
|
|
881
|
+
# argument to a native command, which would leave --context with no value.
|
|
882
|
+
$ctxArgs = @(); if ($activeCtx) { $ctxArgs = @('--context', $activeCtx) }
|
|
883
|
+
$okfRaw = & $py $invpy list-aggregates --bundle $bundle --root $repo @ctxArgs 2>$null
|
|
884
|
+
if ($okfRaw) {
|
|
885
|
+
# The FEATURES the map puts on each concept, not the aggregates themselves —
|
|
886
|
+
# the same list the bash twin reads.
|
|
887
|
+
$okfJson = $okfRaw | ConvertFrom-Json
|
|
888
|
+
# An EMPTY `pickable` is an answer (nothing to offer); only its absence
|
|
889
|
+
# falls back to the raw aggregates.
|
|
890
|
+
$okfAggs = if ($okfJson.PSObject.Properties.Name -contains 'pickable') { @($okfJson.pickable) } else { @($okfJson.aggregates) }
|
|
891
|
+
}
|
|
871
892
|
} catch { $okfAggs = @() }
|
|
872
893
|
}
|
|
873
894
|
|
|
@@ -901,7 +922,7 @@ function Invoke-ListFeatures {
|
|
|
901
922
|
}
|
|
902
923
|
}
|
|
903
924
|
}
|
|
904
|
-
# Merge OKF-discovered
|
|
925
|
+
# Merge the OKF-discovered FEATURES not already scaffolded under specs/.
|
|
905
926
|
foreach ($agg in $okfAggs) {
|
|
906
927
|
$nm = "$($agg.name)".Trim()
|
|
907
928
|
if (-not $nm -or $seen.ContainsKey($nm.ToLower())) { continue }
|
|
@@ -944,11 +965,11 @@ function Invoke-SetFeature {
|
|
|
944
965
|
}
|
|
945
966
|
if (-not $raw) { Write-Error 'set-feature: --feature is required'; exit 1 }
|
|
946
967
|
|
|
947
|
-
# Normalize to a PascalCase
|
|
968
|
+
# Normalize to a PascalCase identifier.
|
|
948
969
|
$cleaned = ($raw -replace '[^A-Za-z0-9]+', ' ').Trim()
|
|
949
970
|
$feature = ConvertTo-Pascal -In $cleaned
|
|
950
971
|
if ($feature -notmatch '^[A-Za-z][A-Za-z0-9]*$') {
|
|
951
|
-
Write-Error "set-feature: '$raw' is not a valid feature name (need a letter-led alphanumeric
|
|
972
|
+
Write-Error "set-feature: '$raw' is not a valid feature name (need a letter-led alphanumeric name, e.g. Order or PatientRegistration)"; exit 1
|
|
952
973
|
}
|
|
953
974
|
|
|
954
975
|
$repo = Get-RepoRoot
|
|
@@ -1449,36 +1470,37 @@ function Get-FoundationDirs {
|
|
|
1449
1470
|
|
|
1450
1471
|
# ──────────────────────────── foundation-list ──────────────────────────────
|
|
1451
1472
|
# List available foundations (foundations/<stack>/<Name>/ with a real <Name>.pack.txt)
|
|
1452
|
-
# for the user to PICK — deterministic, no LLM. Optional --args fast path mirrors bash
|
|
1473
|
+
# for the user to PICK — deterministic, no LLM. Optional --args fast path mirrors bash,
|
|
1474
|
+
# including a third token coming back as `rejected_out` rather than as a destination:
|
|
1475
|
+
# see the bash twin for why a scaffold has no caller-chosen output directory.
|
|
1453
1476
|
function Invoke-FoundationList {
|
|
1454
1477
|
param([string[]]$Rest)
|
|
1455
|
-
$raw = ''
|
|
1456
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { '--args' { $raw = $Rest[++$i] } } }
|
|
1478
|
+
$raw = ''; $ecoFilter = ''
|
|
1479
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { '--args' { $raw = $Rest[++$i] } '--ecosystem' { $ecoFilter = $Rest[++$i] } } }
|
|
1457
1480
|
$repo = Get-RepoRoot
|
|
1458
1481
|
|
|
1459
1482
|
# WHICH foundations this repo may be offered. A foundation bundle is a whole
|
|
1460
1483
|
# compiling project in one ecosystem's languages and build system, so unpacking a
|
|
1461
1484
|
# .NET one into a Spring repo does not produce a scaffold — it produces a second,
|
|
1462
|
-
# foreign project beside the real one.
|
|
1463
|
-
#
|
|
1464
|
-
#
|
|
1465
|
-
|
|
1466
|
-
$laneEcos = @(Get-Ecosystems)
|
|
1485
|
+
# foreign project beside the real one. --ecosystem narrows to the ONE lane being
|
|
1486
|
+
# scaffolded; without it the filter is every declared lane's ecosystem, and nothing
|
|
1487
|
+
# at all on a bare repo.
|
|
1488
|
+
$laneEcos = if ($ecoFilter) { @($ecoFilter) } else { @(Get-Ecosystems) }
|
|
1467
1489
|
$foundations = @(); $all = @(); $ecoOf = @()
|
|
1468
1490
|
foreach ($f in @(Get-FoundationDirs $repo)) {
|
|
1469
1491
|
if (-not (Test-Path (Join-Path $f.Dir "$($f.Name).pack.txt"))) { continue }
|
|
1470
1492
|
$all += $f.Name; $ecoOf += $f.Stack
|
|
1471
1493
|
if ($laneEcos.Count -eq 0 -or $laneEcos -contains $f.Stack) { $foundations += $f.Name }
|
|
1472
1494
|
}
|
|
1473
|
-
$preName = ''; $preApp = ''; $
|
|
1474
|
-
if ($raw) { $t = $raw -split '\s+' | Where-Object { $_ }; if ($t.Count -ge 1) { $preName = $t[0] }; if ($t.Count -ge 2) { $preApp = $t[1] }; if ($t.Count -ge 3) { $
|
|
1495
|
+
$preName = ''; $preApp = ''; $rejectedOut = ''
|
|
1496
|
+
if ($raw) { $t = $raw -split '\s+' | Where-Object { $_ }; if ($t.Count -ge 1) { $preName = $t[0] }; if ($t.Count -ge 2) { $preApp = $t[1] }; if ($t.Count -ge 3) { $rejectedOut = $t[2] } }
|
|
1475
1497
|
$match = if ($foundations -contains $preName) { $preName } else { '' }
|
|
1476
1498
|
Write-Json ([ordered]@{
|
|
1477
1499
|
foundations = @($foundations); count = $foundations.Count
|
|
1478
1500
|
all_foundations = @($all); all_count = $all.Count
|
|
1479
1501
|
ecosystems = @($ecoOf); lane_ecosystems = @($laneEcos)
|
|
1480
1502
|
filtered = ($laneEcos.Count -gt 0)
|
|
1481
|
-
preselect_name = $match; preselect_app = $preApp;
|
|
1503
|
+
preselect_name = $match; preselect_app = $preApp; rejected_out = $rejectedOut
|
|
1482
1504
|
})
|
|
1483
1505
|
}
|
|
1484
1506
|
|
|
@@ -1515,11 +1537,14 @@ function Invoke-FoundationLocate {
|
|
|
1515
1537
|
}
|
|
1516
1538
|
|
|
1517
1539
|
# ─────────────────────────── foundation-unpack ─────────────────────────────
|
|
1540
|
+
# --out is the LANE'S SOURCE PATH and --test-path its other subtree; the pack's own test
|
|
1541
|
+
# tree is lifted onto the latter by aw-foundation.py. Twin of the bash cmd_foundation_unpack
|
|
1542
|
+
# — see it for why a scaffold goes into a lane rather than into the repo root.
|
|
1518
1543
|
function Invoke-FoundationUnpack {
|
|
1519
1544
|
param([string[]]$Rest)
|
|
1520
|
-
$name = ''; $app = ''; $out = '.'; $dry = $false; $force = 'no'; $reference = $false
|
|
1545
|
+
$name = ''; $app = ''; $out = '.'; $dry = $false; $force = 'no'; $reference = $false; $testPath = ''
|
|
1521
1546
|
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
1522
|
-
switch ($Rest[$i]) { '--name' { $name = $Rest[++$i] } '--app' { $app = $Rest[++$i] } '--out' { $out = $Rest[++$i] } '--dry-run' { $dry = $true } '--force' { $force = $Rest[++$i] } '--reference' { $reference = $true } }
|
|
1547
|
+
switch ($Rest[$i]) { '--name' { $name = $Rest[++$i] } '--app' { $app = $Rest[++$i] } '--out' { $out = $Rest[++$i] } '--test-path' { $testPath = $Rest[++$i] } '--dry-run' { $dry = $true } '--force' { $force = $Rest[++$i] } '--reference' { $reference = $true } }
|
|
1523
1548
|
}
|
|
1524
1549
|
if (-not $name -or -not $app) { Write-Error 'foundation-unpack: --name and --app required'; exit 1 }
|
|
1525
1550
|
$repo = Get-RepoRoot
|
|
@@ -1544,7 +1569,28 @@ function Invoke-FoundationUnpack {
|
|
|
1544
1569
|
# unpackers say "Unpacked N file(s) to …" / "DryRun: N file(s) would be written …"
|
|
1545
1570
|
$written = 0; $wm = [regex]::Match($outp, '(?i)([0-9]+)\s+file'); if ($wm.Success) { $written = [int]$wm.Groups[1].Value }
|
|
1546
1571
|
$hasColl = ($dry -and $rc -ne 0)
|
|
1547
|
-
|
|
1572
|
+
|
|
1573
|
+
# Put the pack's test tree on the lane's test path. Only after a REAL unpack that
|
|
1574
|
+
# worked: a dry run has written nothing to lift, and a failed one leaves a tree
|
|
1575
|
+
# nobody should be rearranging.
|
|
1576
|
+
$testsMoved = 0; $testsMsg = ''
|
|
1577
|
+
if (-not $dry -and $rc -eq 0 -and $testPath) {
|
|
1578
|
+
$awFnd = Join-Path $PSScriptRoot '..' 'python' 'aw-foundation.py'
|
|
1579
|
+
$py = Get-Py
|
|
1580
|
+
if ($py -and (Test-Path $awFnd)) {
|
|
1581
|
+
$rargs = @($awFnd, '--repo', $repo, 'redirect-tests', '--out', $out, '--test-path', $testPath)
|
|
1582
|
+
if ($force -eq 'yes' -or $reference) { $rargs += '--force' }
|
|
1583
|
+
$rout = (& $py @rargs 2>$null | Select-Object -Last 1)
|
|
1584
|
+
if ($rout) {
|
|
1585
|
+
try {
|
|
1586
|
+
$rj = $rout | ConvertFrom-Json
|
|
1587
|
+
$testsMoved = [int]$rj.moved; $testsMsg = [string]$rj.message
|
|
1588
|
+
} catch { $testsMoved = 0; $testsMsg = '' }
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
Write-Json ([ordered]@{ ok = ($rc -eq 0); mode = $(if ($dry) { 'dry' } else { 'real' }); written = $written; has_collisions = $hasColl; tests_moved = $testsMoved; tests_message = $testsMsg; stdout = ($outp.Substring([Math]::Max(0,$outp.Length-800))) })
|
|
1548
1594
|
}
|
|
1549
1595
|
|
|
1550
1596
|
# ─────────────────────── foundation-stash-dbcontext ────────────────────────
|
|
@@ -1554,7 +1600,9 @@ function Invoke-FoundationStashDbContext {
|
|
|
1554
1600
|
for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--out') { $out = $Rest[++$i] } }
|
|
1555
1601
|
$repo = Get-RepoRoot
|
|
1556
1602
|
if (-not [System.IO.Path]::IsPathRooted($out)) { $out = Join-Path $repo $out }
|
|
1557
|
-
|
|
1603
|
+
# Searched from the unpack root itself, which IS the lane's source path — a `src/`
|
|
1604
|
+
# segment underneath it is the old repo-root layout and is not there any more.
|
|
1605
|
+
$ctx = Get-ChildItem -LiteralPath $out -Recurse -Filter 'ApplicationDbContext.cs' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
|
|
1558
1606
|
if (-not $ctx) { Write-Json ([ordered]@{ found = $false; dbcontext = ''; backup = '' }); return }
|
|
1559
1607
|
$bak = "$ctx.foundation.bak"
|
|
1560
1608
|
Copy-Item $ctx $bak
|
|
@@ -2393,6 +2441,9 @@ function Invoke-TestRun {
|
|
|
2393
2441
|
|
|
2394
2442
|
$tool = 'none'; $ran = $false; $ok = $true; $cmd = ''; $log = ''
|
|
2395
2443
|
$matched = $false; $scoped = $false; $fenceGreen = $false; $filter = ''
|
|
2444
|
+
# The runner's exit code, kept because 126/127 is every shell's way of saying the
|
|
2445
|
+
# command itself could not be executed — a missing runner, not a failing suite.
|
|
2446
|
+
$rc = 0
|
|
2396
2447
|
$hasSln = @(Get-ChildItem -LiteralPath $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
|
|
2397
2448
|
$hasCsproj = @(Get-ChildItem -LiteralPath $repo -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
|
|
2398
2449
|
$dotnetPresent = ($hasSln -or $hasCsproj)
|
|
@@ -2430,6 +2481,16 @@ function Invoke-TestRun {
|
|
|
2430
2481
|
fence_green = $dOk
|
|
2431
2482
|
cmd = $(if ($d -and $d.cmd) { [string]$d.cmd } else { '' })
|
|
2432
2483
|
log_tail = $dLog
|
|
2484
|
+
# The failing lines of EVERY failing lane, budgeted per lane by run-phase —
|
|
2485
|
+
# what the fix loop reads, in place of a tail that may belong to a lane that
|
|
2486
|
+
# passed. Twin of the bash branch.
|
|
2487
|
+
failing_excerpt = $(if ($d -and $d.failing_excerpt) { [string]$d.failing_excerpt } else { '' })
|
|
2488
|
+
failing_lanes = @(if ($d -and $d.failing_lanes) { $d.failing_lanes } else { @() })
|
|
2489
|
+
# NOTHING ran a suite because no lane's test command is wired up — a different
|
|
2490
|
+
# state from a red suite, and the one a fix loop cannot help with.
|
|
2491
|
+
no_setup = [bool]($d -and $d.no_setup)
|
|
2492
|
+
setup_reason = $(if ($d -and $d.setup_reason) { [string]$d.setup_reason } else { '' })
|
|
2493
|
+
setup_missing = @(if ($d -and $d.setup_missing) { $d.setup_missing } else { @() })
|
|
2433
2494
|
lanes_run = @(if ($d) { $d.lanes_run } else { @() })
|
|
2434
2495
|
lanes_skipped = @(if ($d) { $d.lanes_skipped } else { @() })
|
|
2435
2496
|
})
|
|
@@ -2448,16 +2509,16 @@ function Invoke-TestRun {
|
|
|
2448
2509
|
} else {
|
|
2449
2510
|
$filter = ($slices | ForEach-Object { "FullyQualifiedName~$_" }) -join '|'
|
|
2450
2511
|
$scoped = $true; $cmd = "dotnet test --nologo --filter `"$filter`""; $ran = $true
|
|
2451
|
-
Push-Location $repo; try { $log = (dotnet test --nologo --filter $filter 2>&1 | Out-String); $ok = ($
|
|
2512
|
+
Push-Location $repo; try { $log = (dotnet test --nologo --filter $filter 2>&1 | Out-String); $rc = $LASTEXITCODE; $ok = ($rc -eq 0) } finally { Pop-Location }
|
|
2452
2513
|
}
|
|
2453
2514
|
} else {
|
|
2454
2515
|
$cmd = 'dotnet test --nologo'; $ran = $true
|
|
2455
|
-
Push-Location $repo; try { $log = (dotnet test --nologo 2>&1 | Out-String); $ok = ($
|
|
2516
|
+
Push-Location $repo; try { $log = (dotnet test --nologo 2>&1 | Out-String); $rc = $LASTEXITCODE; $ok = ($rc -eq 0) } finally { Pop-Location }
|
|
2456
2517
|
}
|
|
2457
2518
|
} else { $ok = $false }
|
|
2458
2519
|
} elseif ((Test-Path (Join-Path $repo 'package.json')) -and (Get-Command npm -ErrorAction SilentlyContinue)) {
|
|
2459
2520
|
$tool = 'npm'; $cmd = 'npm test'; $ran = $true
|
|
2460
|
-
Push-Location $repo; try { $log = (npm test 2>&1 | Out-String); $ok = ($
|
|
2521
|
+
Push-Location $repo; try { $log = (npm test 2>&1 | Out-String); $rc = $LASTEXITCODE; $ok = ($rc -eq 0) } finally { Pop-Location }
|
|
2461
2522
|
}
|
|
2462
2523
|
|
|
2463
2524
|
# matched = at least one test was actually selected + run. Read from the LOG, never the
|
|
@@ -2471,7 +2532,35 @@ function Invoke-TestRun {
|
|
|
2471
2532
|
if ($scoped) { $fenceGreen = ($ok -and $matched) } else { $fenceGreen = $ok }
|
|
2472
2533
|
|
|
2473
2534
|
$tail = if ($log.Length -gt 1400) { $log.Substring($log.Length - 1400) } else { $log }
|
|
2474
|
-
|
|
2535
|
+
# The lines that NAME the failure, which a positional tail does not reliably contain:
|
|
2536
|
+
# a .NET run opens with its restore banner and a runner closes with a summary, so the
|
|
2537
|
+
# last 1400 characters can be entirely green while the failure sits above them.
|
|
2538
|
+
$excerpt = ''; $noSetup = $false; $setupReason = ''
|
|
2539
|
+
if (-not $fenceGreen) {
|
|
2540
|
+
$py = Get-Py
|
|
2541
|
+
if ($py) {
|
|
2542
|
+
$helper = Join-Path $PSScriptRoot '..' 'python' 'aw-testlog.py'
|
|
2543
|
+
if (Test-Path $helper) {
|
|
2544
|
+
$label = $tool; if ($cmd) { $label = "$tool · $cmd" }
|
|
2545
|
+
$label = ($label -replace '\s+', ' ')
|
|
2546
|
+
if ($label.Length -gt 80) { $label = $label.Substring(0, 80) }
|
|
2547
|
+
$tmp = [System.IO.Path]::GetTempFileName()
|
|
2548
|
+
try {
|
|
2549
|
+
Set-Content -LiteralPath $tmp -Value $log -NoNewline -Encoding utf8
|
|
2550
|
+
$line = (& $py $helper '--file' $tmp '--label' $label '--exit' "$rc" | Select-Object -First 1)
|
|
2551
|
+
if ($line) {
|
|
2552
|
+
$h = $line | ConvertFrom-Json
|
|
2553
|
+
$excerpt = [string]$h.excerpt
|
|
2554
|
+
# "The command never ran" — no test script, no runner, no test
|
|
2555
|
+
# project. Not a red suite; the callers gate on it.
|
|
2556
|
+
$setupReason = [string]$h.reason
|
|
2557
|
+
$noSetup = [bool]$h.no_setup
|
|
2558
|
+
}
|
|
2559
|
+
} catch { $excerpt = '' } finally { Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue }
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
Write-Json ([ordered]@{ ok = $ok; tool = $tool; ran = $ran; scope = $scope; slices = ($slices -join ' '); scoped = $scoped; matched = $matched; fence_green = $fenceGreen; cmd = $cmd; log_tail = $tail; failing_excerpt = $excerpt; failing_lanes = @(); no_setup = $noSetup; setup_reason = $setupReason; setup_missing = @() })
|
|
2475
2564
|
}
|
|
2476
2565
|
|
|
2477
2566
|
function Invoke-RunMigrate {
|
|
@@ -2940,26 +3029,41 @@ function Invoke-CrPaths {
|
|
|
2940
3029
|
|
|
2941
3030
|
# ─────────────────────────── cr-overlap ────────────────────────────────────
|
|
2942
3031
|
# Which OTHER open changes touch the same aggregates as this one — read off the
|
|
2943
|
-
#
|
|
2944
|
-
# domain-model.md
|
|
2945
|
-
#
|
|
2946
|
-
#
|
|
3032
|
+
# `**Aggregates touched**:` header of each pending model delta. That header names the
|
|
3033
|
+
# `## Aggregate: <Name>` sections of domain-model.md the delta merges into; the delta itself
|
|
3034
|
+
# carries no such headings, the master does. Deterministic on purpose: two deltas collide
|
|
3035
|
+
# when they change the same aggregate, and that is a fact about two files' headers, so a
|
|
3036
|
+
# regex answers it exactly and the same way every run. Early warning, not enforcement — an
|
|
2947
3037
|
# overlap is ordinary, and what matters is that the second change is PLANNED knowing about
|
|
2948
3038
|
# the first. The relation follows the wave: a LOWER wave is a predecessor (a dependency to
|
|
2949
3039
|
# declare), the SAME wave a sibling (a conflict to design around), a HIGHER wave a
|
|
2950
3040
|
# successor (it plans on top of this change — information, not an obligation). Twin of
|
|
2951
3041
|
# bash cmd_cr_overlap.
|
|
2952
3042
|
#
|
|
2953
|
-
# The aggregate names one model delta declares, in the order
|
|
2954
|
-
#
|
|
2955
|
-
#
|
|
3043
|
+
# The aggregate names one model delta declares, in the order its `**Aggregates touched**:`
|
|
3044
|
+
# header lists them — the same list finalize-delta opens master sections for. Backticked
|
|
3045
|
+
# names are the template's form and win when present, so prose around them adds nothing; a
|
|
3046
|
+
# value with no backticks is split on , ; / & "and" and each piece's leading name is taken.
|
|
3047
|
+
# The template placeholder (`[Root]`) and "None — behaviour only" name no aggregate, so an
|
|
3048
|
+
# unfilled or model-free delta reports none rather than colliding with every other change.
|
|
2956
3049
|
function Get-DeltaAggregates {
|
|
2957
3050
|
param([string]$Text)
|
|
2958
3051
|
$out = @()
|
|
2959
3052
|
if (-not $Text) { return $out }
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
3053
|
+
$m = [regex]::Match($Text, '(?m)^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$')
|
|
3054
|
+
if (-not $m.Success) { return $out }
|
|
3055
|
+
$value = ([regex]::Replace($m.Groups[1].Value, '<!--.*?(?:-->|$)', '')).Trim()
|
|
3056
|
+
$names = @([regex]::Matches($value, '`([^`]+)`') | ForEach-Object { $_.Groups[1].Value })
|
|
3057
|
+
if ($names.Count -eq 0) {
|
|
3058
|
+
$names = @(foreach ($piece in [regex]::Split($value, '[,;/&]|\band\b')) {
|
|
3059
|
+
$pm = [regex]::Match($piece.Trim(), '^[A-Za-z][A-Za-z0-9_]*')
|
|
3060
|
+
if ($pm.Success) { $pm.Value }
|
|
3061
|
+
})
|
|
3062
|
+
}
|
|
3063
|
+
foreach ($name in $names) {
|
|
3064
|
+
$name = $name.Trim()
|
|
3065
|
+
if (-not $name -or $name.StartsWith('<') -or $name.StartsWith('[') -or
|
|
3066
|
+
[regex]::IsMatch($name, '^none\b', 'IgnoreCase')) { continue }
|
|
2963
3067
|
if ($out -notcontains $name) { $out += $name }
|
|
2964
3068
|
}
|
|
2965
3069
|
return $out
|
|
@@ -3086,6 +3190,337 @@ function Invoke-CrWave {
|
|
|
3086
3190
|
Write-Json ([ordered]@{ cr_id = $crId; wave = [int]$wave; stamped = $stamped; delta_spec = $dspec; delta_model = $dmodel })
|
|
3087
3191
|
}
|
|
3088
3192
|
|
|
3193
|
+
$PyCrWaveDerive = @'
|
|
3194
|
+
import glob, json, os, re
|
|
3195
|
+
|
|
3196
|
+
cr = os.environ["AW_CR"]
|
|
3197
|
+
fd = os.environ["AW_FD"]
|
|
3198
|
+
ctx = os.environ["AW_CTX"]
|
|
3199
|
+
stamp = os.environ.get("AW_STAMP", "") == "true"
|
|
3200
|
+
|
|
3201
|
+
CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
|
|
3202
|
+
WAVE_RX = re.compile(r"^\*\*Wave\*\*:\s*(\d+)", re.M)
|
|
3203
|
+
|
|
3204
|
+
|
|
3205
|
+
def read(path):
|
|
3206
|
+
try:
|
|
3207
|
+
return open(path, encoding="utf-8").read()
|
|
3208
|
+
except Exception:
|
|
3209
|
+
return ""
|
|
3210
|
+
|
|
3211
|
+
|
|
3212
|
+
def wave_of(cr_id):
|
|
3213
|
+
"""A change states its wave in the header of its own delta documents."""
|
|
3214
|
+
for p in (os.path.join(ctx, "domain-model.%s.delta.md" % cr_id),
|
|
3215
|
+
os.path.join(fd, "changes", cr_id, "delta-spec.md")):
|
|
3216
|
+
m = WAVE_RX.search(read(p))
|
|
3217
|
+
if m:
|
|
3218
|
+
return int(m.group(1))
|
|
3219
|
+
for p in glob.glob(os.path.join(glob.escape(os.path.join(fd, "changes", cr_id)), "delta-*.md")):
|
|
3220
|
+
m = WAVE_RX.search(read(p))
|
|
3221
|
+
if m:
|
|
3222
|
+
return int(m.group(1))
|
|
3223
|
+
return None
|
|
3224
|
+
|
|
3225
|
+
|
|
3226
|
+
def depends_ids(text):
|
|
3227
|
+
"""The CR ids named under `## Depends on`, up to the next heading of that level."""
|
|
3228
|
+
m = re.search(r"^##\s+Depends on\s*$(.*?)(?=^##\s|\Z)", text, re.M | re.S)
|
|
3229
|
+
if not m:
|
|
3230
|
+
return []
|
|
3231
|
+
body = re.sub(r"<!--.*?-->", " ", m.group(1), flags=re.S)
|
|
3232
|
+
return [i for i in CR_RX.findall(body) if i != cr]
|
|
3233
|
+
|
|
3234
|
+
|
|
3235
|
+
change_dir = os.path.join(fd, "changes", cr)
|
|
3236
|
+
docs = [os.path.join(ctx, "domain-model.%s.delta.md" % cr),
|
|
3237
|
+
os.path.join(change_dir, "delta-spec.md")]
|
|
3238
|
+
docs += sorted(glob.glob(os.path.join(glob.escape(change_dir), "delta-*plan*.md")))
|
|
3239
|
+
present = [p for p in docs if os.path.isfile(p)]
|
|
3240
|
+
|
|
3241
|
+
deps, seen = [], set()
|
|
3242
|
+
for p in present:
|
|
3243
|
+
for i in depends_ids(read(p)):
|
|
3244
|
+
if i not in seen:
|
|
3245
|
+
seen.add(i)
|
|
3246
|
+
deps.append(i)
|
|
3247
|
+
|
|
3248
|
+
known, unknown = [], []
|
|
3249
|
+
for d in deps:
|
|
3250
|
+
w = wave_of(d)
|
|
3251
|
+
(known if w is not None else unknown).append({"cr_id": d, "wave": w})
|
|
3252
|
+
required = max([d["wave"] for d in known], default=0) + 1
|
|
3253
|
+
current = wave_of(cr)
|
|
3254
|
+
ok = current is not None and current >= required
|
|
3255
|
+
|
|
3256
|
+
stamped = []
|
|
3257
|
+
if stamp and current is not None and current < required:
|
|
3258
|
+
line = "**Wave**: %d" % required
|
|
3259
|
+
pat = re.compile(r"^\*\*Wave\*\*:.*$", re.M)
|
|
3260
|
+
for p in present:
|
|
3261
|
+
text = read(p)
|
|
3262
|
+
if pat.search(text):
|
|
3263
|
+
open(p, "w", encoding="utf-8").write(pat.sub(lambda _m: line, text, count=1))
|
|
3264
|
+
stamped.append(os.path.basename(p))
|
|
3265
|
+
if stamped:
|
|
3266
|
+
current, ok = required, True
|
|
3267
|
+
|
|
3268
|
+
if not deps:
|
|
3269
|
+
msg = "%s declares no predecessor, so wave %d is the floor; it carries %s" % (
|
|
3270
|
+
cr, required, current if current is not None else "no wave")
|
|
3271
|
+
elif stamped:
|
|
3272
|
+
msg = "%s depends on %s, so it was raised to wave %d in %s" % (
|
|
3273
|
+
cr, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
|
|
3274
|
+
required, ", ".join(stamped))
|
|
3275
|
+
elif ok:
|
|
3276
|
+
msg = "%s is at wave %s, at or above the %d its `## Depends on` requires" % (
|
|
3277
|
+
cr, current, required)
|
|
3278
|
+
else:
|
|
3279
|
+
msg = ("%s is at wave %s but depends on %s, so it must sit at wave %d — a predecessor "
|
|
3280
|
+
"at the same or a higher wave is not ordered behind it at all" % (
|
|
3281
|
+
cr, current, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
|
|
3282
|
+
required))
|
|
3283
|
+
if unknown:
|
|
3284
|
+
msg += ". Named but not found open: %s" % ", ".join(u["cr_id"] for u in unknown)
|
|
3285
|
+
|
|
3286
|
+
print(json.dumps({
|
|
3287
|
+
"cr_id": cr, "wave": current, "required_wave": required, "ok": ok,
|
|
3288
|
+
"depends_on": known, "unknown": unknown, "depends_count": len(deps),
|
|
3289
|
+
"stamped": stamped, "documents": [os.path.basename(p) for p in present],
|
|
3290
|
+
"message": msg,
|
|
3291
|
+
}))
|
|
3292
|
+
'@
|
|
3293
|
+
|
|
3294
|
+
# THE WAVE A CHANGE'S OWN DOCUMENTS IMPLY, from their `## Depends on` sections. Twin of the
|
|
3295
|
+
# bash cmd_cr_wave_derive, running the SAME python payload — see the bash comment for why
|
|
3296
|
+
# the declaration, not cr-setup's default, is the ordering authority.
|
|
3297
|
+
function Invoke-CrWaveDerive {
|
|
3298
|
+
param([string[]]$Rest)
|
|
3299
|
+
$crId = ''; $fdArg = ''; $stamp = $false
|
|
3300
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
3301
|
+
switch ($Rest[$i]) {
|
|
3302
|
+
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
3303
|
+
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
3304
|
+
{ $_ -in '--stamp','-Stamp' } { $stamp = $true }
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
3308
|
+
if (-not $crId) { Write-Error 'cr-wave-derive: --cr-id is required'; exit 1 }
|
|
3309
|
+
$py = Get-Py
|
|
3310
|
+
if (-not $py) { Write-Error 'cr-wave-derive: python3 is required'; exit 1 }
|
|
3311
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
3312
|
+
if (-not $fd) { Write-Error 'cr-wave-derive: no feature set and no --feature-dir'; exit 1 }
|
|
3313
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
3314
|
+
$env:AW_CR = $crId; $env:AW_FD = $fd; $env:AW_CTX = $ctx
|
|
3315
|
+
$env:AW_STAMP = $(if ($stamp) { 'true' } else { 'false' })
|
|
3316
|
+
try { $PyCrWaveDerive | & $py - } finally {
|
|
3317
|
+
Remove-Item Env:AW_CR, Env:AW_FD, Env:AW_CTX, Env:AW_STAMP -ErrorAction SilentlyContinue
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
$PyCrRebase = @'
|
|
3322
|
+
import glob, json, os, re
|
|
3323
|
+
|
|
3324
|
+
cr = os.environ["AW_CR"]
|
|
3325
|
+
fd = os.environ["AW_FD"]
|
|
3326
|
+
ctx = os.environ["AW_CTX"]
|
|
3327
|
+
stamp = os.environ.get("AW_STAMP", "") == "true"
|
|
3328
|
+
|
|
3329
|
+
CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
|
|
3330
|
+
TICK_RX = re.compile(r"`([^`]+)`")
|
|
3331
|
+
|
|
3332
|
+
|
|
3333
|
+
def read(path):
|
|
3334
|
+
try:
|
|
3335
|
+
return open(path, encoding="utf-8").read()
|
|
3336
|
+
except Exception:
|
|
3337
|
+
return ""
|
|
3338
|
+
|
|
3339
|
+
|
|
3340
|
+
def header(text, name):
|
|
3341
|
+
m = re.search(r"^\*\*%s\*\*:[ \t]*(.+?)[ \t]*(?:<!--.*)?$" % re.escape(name), text, re.M)
|
|
3342
|
+
if not m:
|
|
3343
|
+
return ""
|
|
3344
|
+
v = m.group(1).strip()
|
|
3345
|
+
return "" if (v.startswith("[") and v.endswith("]")) else v
|
|
3346
|
+
|
|
3347
|
+
|
|
3348
|
+
def norm(v):
|
|
3349
|
+
v = (v or "").strip()
|
|
3350
|
+
return "" if not v else (v if v.startswith("v") else "v" + v)
|
|
3351
|
+
|
|
3352
|
+
|
|
3353
|
+
def as_tuple(v):
|
|
3354
|
+
parts = re.findall(r"\d+", v or "")
|
|
3355
|
+
return tuple(int(p) for p in parts) if parts else None
|
|
3356
|
+
|
|
3357
|
+
|
|
3358
|
+
def aggregates(text):
|
|
3359
|
+
"""The `**Aggregates touched**:` header — the list finalize-delta opens sections for."""
|
|
3360
|
+
m = re.search(r"^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$", text, re.M)
|
|
3361
|
+
if not m:
|
|
3362
|
+
return set()
|
|
3363
|
+
value = re.sub(r"<!--.*?(?:-->|$)", "", m.group(1)).strip()
|
|
3364
|
+
names = TICK_RX.findall(value)
|
|
3365
|
+
if not names:
|
|
3366
|
+
names = [p.strip() for p in re.split(r"[,;/&]|\band\b", value) if p.strip()]
|
|
3367
|
+
names = [re.match(r"[A-Za-z_][A-Za-z0-9_]*", n).group(0)
|
|
3368
|
+
for n in names if re.match(r"[A-Za-z_][A-Za-z0-9_]*", n)]
|
|
3369
|
+
return {n for n in names if not n.startswith(("<", "[")) and not re.match(r"(?i)none\b", n)}
|
|
3370
|
+
|
|
3371
|
+
|
|
3372
|
+
def landed_since(text, base):
|
|
3373
|
+
"""CR ids in Change History rows whose version is ABOVE `base` — what moved the master
|
|
3374
|
+
since this delta was stamped. A row with no readable version counts, because a row this
|
|
3375
|
+
cannot place is one it cannot prove is older."""
|
|
3376
|
+
ids, base_t = [], as_tuple(base)
|
|
3377
|
+
for line in text.split("\n"):
|
|
3378
|
+
if not line.strip().startswith("|"):
|
|
3379
|
+
continue
|
|
3380
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
3381
|
+
if len(cells) < 2 or set("".join(cells)) <= set("-: "):
|
|
3382
|
+
continue
|
|
3383
|
+
row_t = as_tuple(cells[0]) if re.match(r"^v?\d", cells[0]) else None
|
|
3384
|
+
if row_t is None:
|
|
3385
|
+
continue
|
|
3386
|
+
if base_t is not None and row_t <= base_t:
|
|
3387
|
+
continue
|
|
3388
|
+
for i in CR_RX.findall(line):
|
|
3389
|
+
if i != cr and i not in ids:
|
|
3390
|
+
ids.append(i)
|
|
3391
|
+
return ids
|
|
3392
|
+
|
|
3393
|
+
|
|
3394
|
+
def model_delta_of(cr_id):
|
|
3395
|
+
"""A change's model half: beside the master while open, in its archive once closed."""
|
|
3396
|
+
p = os.path.join(ctx, "domain-model.%s.delta.md" % cr_id)
|
|
3397
|
+
if os.path.isfile(p):
|
|
3398
|
+
return read(p)
|
|
3399
|
+
for feat in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
|
|
3400
|
+
a = os.path.join(ctx, feat, "changes", "archive", cr_id, "domain-model.delta.md")
|
|
3401
|
+
if os.path.isfile(a):
|
|
3402
|
+
return read(a)
|
|
3403
|
+
return ""
|
|
3404
|
+
|
|
3405
|
+
|
|
3406
|
+
my_spec = os.path.join(fd, "changes", cr, "delta-spec.md")
|
|
3407
|
+
my_model = os.path.join(ctx, "domain-model.%s.delta.md" % cr)
|
|
3408
|
+
spec_master = os.path.join(fd, "spec.md")
|
|
3409
|
+
model_master = os.path.join(ctx, "domain-model.md")
|
|
3410
|
+
|
|
3411
|
+
spec_text, model_text = read(my_spec), read(my_model)
|
|
3412
|
+
base_version = norm(header(spec_text, "Base version"))
|
|
3413
|
+
base_ctx = norm(header(model_text, "Base context version"))
|
|
3414
|
+
|
|
3415
|
+
current = ""
|
|
3416
|
+
try:
|
|
3417
|
+
current = norm(json.load(open(os.path.join(fd, "active_version.json"), encoding="utf-8")).get("version", ""))
|
|
3418
|
+
except Exception:
|
|
3419
|
+
current = ""
|
|
3420
|
+
if not current:
|
|
3421
|
+
current = norm(header(read(spec_master), "Version"))
|
|
3422
|
+
current_ctx = norm(header(read(model_master), "Version"))
|
|
3423
|
+
|
|
3424
|
+
moved = bool(base_version and current and base_version != current)
|
|
3425
|
+
ctx_moved = bool(base_ctx and current_ctx and base_ctx != current_ctx)
|
|
3426
|
+
|
|
3427
|
+
# WHAT LANDED IN BETWEEN, and whether any of it touched what this delta touches. A delta
|
|
3428
|
+
# whose aggregates nobody else moved still applies to the master it was written against;
|
|
3429
|
+
# one whose aggregates a landed change rewrote does not, and no stamp can make it.
|
|
3430
|
+
mine = aggregates(model_text)
|
|
3431
|
+
intervening = landed_since(read(spec_master), base_version) if moved else []
|
|
3432
|
+
if ctx_moved:
|
|
3433
|
+
for i in landed_since(read(model_master), base_ctx):
|
|
3434
|
+
if i not in intervening:
|
|
3435
|
+
intervening.append(i)
|
|
3436
|
+
|
|
3437
|
+
rows, conflicts = [], []
|
|
3438
|
+
for i in intervening:
|
|
3439
|
+
theirs = aggregates(model_delta_of(i))
|
|
3440
|
+
shared = sorted(mine & theirs) if (mine and theirs) else []
|
|
3441
|
+
# Unknown aggregates fail CLOSED: a change this cannot read is one it cannot clear.
|
|
3442
|
+
unknown = not theirs
|
|
3443
|
+
rows.append({"cr_id": i, "aggregates": sorted(theirs), "shared": shared, "unknown": unknown})
|
|
3444
|
+
if shared or unknown:
|
|
3445
|
+
conflicts.append({"cr_id": i, "shared": shared, "unknown": unknown})
|
|
3446
|
+
|
|
3447
|
+
clean = (moved or ctx_moved) and not conflicts
|
|
3448
|
+
stamped = []
|
|
3449
|
+
if stamp and clean:
|
|
3450
|
+
if moved and os.path.isfile(my_spec):
|
|
3451
|
+
open(my_spec, "w", encoding="utf-8").write(
|
|
3452
|
+
re.sub(r"^\*\*Base version\*\*:.*$", "**Base version**: %s" % current,
|
|
3453
|
+
spec_text, count=1, flags=re.M))
|
|
3454
|
+
stamped.append({"file": os.path.basename(my_spec), "header": "Base version",
|
|
3455
|
+
"from": base_version, "to": current})
|
|
3456
|
+
if ctx_moved and os.path.isfile(my_model):
|
|
3457
|
+
open(my_model, "w", encoding="utf-8").write(
|
|
3458
|
+
re.sub(r"^\*\*Base context version\*\*:.*$", "**Base context version**: %s" % current_ctx,
|
|
3459
|
+
model_text, count=1, flags=re.M))
|
|
3460
|
+
stamped.append({"file": os.path.basename(my_model), "header": "Base context version",
|
|
3461
|
+
"from": base_ctx, "to": current_ctx})
|
|
3462
|
+
if stamped:
|
|
3463
|
+
moved = ctx_moved = False
|
|
3464
|
+
|
|
3465
|
+
ok = not (moved or ctx_moved)
|
|
3466
|
+
if not (base_version or base_ctx):
|
|
3467
|
+
msg = "%s carries no base stamp, so there is nothing to compare — treated as not moved" % cr
|
|
3468
|
+
elif ok and not stamped:
|
|
3469
|
+
msg = "%s is stamped against the current masters (%s, context %s)" % (cr, current or "—", current_ctx or "—")
|
|
3470
|
+
elif stamped:
|
|
3471
|
+
msg = "%s was re-stamped: %s. Nothing that landed in between (%s) touched its aggregates (%s)" % (
|
|
3472
|
+
cr, "; ".join("%s %s → %s" % (s["header"], s["from"], s["to"]) for s in stamped),
|
|
3473
|
+
", ".join(r["cr_id"] for r in rows) or "no recorded change", ", ".join(sorted(mine)) or "none named")
|
|
3474
|
+
else:
|
|
3475
|
+
parts = []
|
|
3476
|
+
for c in conflicts:
|
|
3477
|
+
parts.append("%s (%s)" % (c["cr_id"], "aggregates unreadable" if c["unknown"]
|
|
3478
|
+
else "also touched " + ", ".join(c["shared"])))
|
|
3479
|
+
msg = ("%s was authored against %s%s but the masters are at %s%s, and what landed in "
|
|
3480
|
+
"between overlaps it: %s" % (
|
|
3481
|
+
cr, base_version or "—", "" if not base_ctx else " (context %s)" % base_ctx,
|
|
3482
|
+
current or "—", "" if not current_ctx else " (context %s)" % current_ctx,
|
|
3483
|
+
"; ".join(parts)))
|
|
3484
|
+
|
|
3485
|
+
print(json.dumps({
|
|
3486
|
+
"cr_id": cr, "ok": ok, "clean": clean, "moved": moved, "context_moved": ctx_moved,
|
|
3487
|
+
"base_version": base_version, "current_version": current,
|
|
3488
|
+
"base_context_version": base_ctx, "current_context_version": current_ctx,
|
|
3489
|
+
"aggregates": sorted(mine), "landed_since": rows,
|
|
3490
|
+
"conflicts": conflicts, "conflict_count": len(conflicts),
|
|
3491
|
+
"conflict_ids": ", ".join(c["cr_id"] for c in conflicts),
|
|
3492
|
+
"stamped": stamped, "message": msg,
|
|
3493
|
+
}))
|
|
3494
|
+
'@
|
|
3495
|
+
|
|
3496
|
+
|
|
3497
|
+
# RE-STAMP a change's base versions when nothing that landed in between touched its
|
|
3498
|
+
# aggregates. Twin of the bash cmd_cr_rebase, running the SAME python payload — see the
|
|
3499
|
+
# bash comment for what "applies cleanly" means and why an unreadable delta fails closed.
|
|
3500
|
+
function Invoke-CrRebase {
|
|
3501
|
+
param([string[]]$Rest)
|
|
3502
|
+
$crId = ''; $fdArg = ''; $stamp = $false
|
|
3503
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
3504
|
+
switch ($Rest[$i]) {
|
|
3505
|
+
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
3506
|
+
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
3507
|
+
{ $_ -in '--stamp','-Stamp' } { $stamp = $true }
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
3511
|
+
if (-not $crId) { Write-Error 'cr-rebase: --cr-id is required'; exit 1 }
|
|
3512
|
+
$py = Get-Py
|
|
3513
|
+
if (-not $py) { Write-Error 'cr-rebase: python3 is required'; exit 1 }
|
|
3514
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
3515
|
+
if (-not $fd) { Write-Error 'cr-rebase: no feature set and no --feature-dir'; exit 1 }
|
|
3516
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
3517
|
+
$env:AW_CR = $crId; $env:AW_FD = $fd; $env:AW_CTX = $ctx
|
|
3518
|
+
$env:AW_STAMP = $(if ($stamp) { 'true' } else { 'false' })
|
|
3519
|
+
try { $PyCrRebase | & $py - } finally {
|
|
3520
|
+
Remove-Item Env:AW_CR, Env:AW_FD, Env:AW_CTX, Env:AW_STAMP -ErrorAction SilentlyContinue
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3089
3524
|
function Invoke-CrSetup {
|
|
3090
3525
|
param([string[]]$Rest)
|
|
3091
3526
|
$crId = ''; $fdArg = ''; $title = ''; $force = ''; $wave = ''
|
|
@@ -3548,12 +3983,24 @@ function Invoke-CrMergeCheck {
|
|
|
3548
3983
|
# masters that had already shifted under it. An id appearing in spec.md is the on-disk
|
|
3549
3984
|
# proof that the merge ran (the Change History row finalize-delta appends), the same
|
|
3550
3985
|
# evidence cr-recall-check reads for the same question.
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3986
|
+
# aw-crstate owns the test, for both shells and all three readers of it: a change is
|
|
3987
|
+
# MERGED when its id is the CR cell of a Change History row — in this feature's spec or
|
|
3988
|
+
# in the context model every feature shares, so a SIBLING feature's merged-unclosed
|
|
3989
|
+
# change is no longer invisible. A sibling blocks only where it touched an aggregate
|
|
3990
|
+
# this delta also names. Fails CLOSED: an unreadable helper reports blocking.
|
|
3991
|
+
$mergedOpen = @()
|
|
3992
|
+
$pyMerged = Get-Py
|
|
3993
|
+
$crstate = Join-Path $PSScriptRoot '..' 'python' 'aw-crstate.py'
|
|
3994
|
+
if ($pyMerged -and (Test-Path $crstate)) {
|
|
3995
|
+
try {
|
|
3996
|
+
$line = (& $pyMerged $crstate 'merged-open' '--cr' $crId '--feature-dir' $fd '--context-dir' $ctx | Select-Object -First 1)
|
|
3997
|
+
if ($line) { $mergedOpen = @(($line | ConvertFrom-Json).blocking) }
|
|
3998
|
+
} catch {
|
|
3999
|
+
$mergedOpen = @([pscustomobject]@{ cr_id = '(merged-open check failed)'; blocking = $true })
|
|
4000
|
+
}
|
|
4001
|
+
} else {
|
|
4002
|
+
$mergedOpen = @([pscustomobject]@{ cr_id = '(merged-open check unavailable)'; blocking = $true })
|
|
4003
|
+
}
|
|
3557
4004
|
|
|
3558
4005
|
Write-Json ([ordered]@{
|
|
3559
4006
|
ok = $true; cr_id = $crId; wave = $wave
|
|
@@ -3587,6 +4034,7 @@ function Invoke-CrRecallCheck {
|
|
|
3587
4034
|
$crId = Get-CrIdToken -Raw $crId
|
|
3588
4035
|
if (-not $crId) { Write-Error 'cr-recall-check: --cr-id required'; exit 1 }
|
|
3589
4036
|
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
4037
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
3590
4038
|
if (-not $fd) { Write-Error 'cr-recall-check: no feature set and no --feature-dir'; exit 1 }
|
|
3591
4039
|
$changes = Join-Path $fd 'changes'
|
|
3592
4040
|
$crDir = Join-Path $changes $crId
|
|
@@ -3600,12 +4048,22 @@ function Invoke-CrRecallCheck {
|
|
|
3600
4048
|
return 1
|
|
3601
4049
|
}
|
|
3602
4050
|
|
|
4051
|
+
# MERGED = a Change History row whose CR cell IS this change, in this feature's spec or
|
|
4052
|
+
# in the shared context model — aw-crstate owns that test for all three readers of it.
|
|
4053
|
+
# Fails CLOSED: an unreadable helper reports merged, never "never merged", which is the
|
|
4054
|
+
# answer that would let close-delta archive a change nobody merged.
|
|
3603
4055
|
$spec = Join-Path $fd 'spec.md'
|
|
3604
|
-
$merged = $
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
4056
|
+
$merged = $true; $evidence = '(merged check unavailable — treated as merged)'
|
|
4057
|
+
$pyMerged = Get-Py
|
|
4058
|
+
$crstate = Join-Path $PSScriptRoot '..' 'python' 'aw-crstate.py'
|
|
4059
|
+
if ($pyMerged -and (Test-Path $crstate)) {
|
|
4060
|
+
try {
|
|
4061
|
+
$line = (& $pyMerged $crstate 'merged' '--cr' $crId '--doc' $spec '--doc' (Join-Path $ctx 'domain-model.md') | Select-Object -First 1)
|
|
4062
|
+
if ($line) {
|
|
4063
|
+
$h = $line | ConvertFrom-Json
|
|
4064
|
+
$merged = [bool]$h.merged; $evidence = [string]$h.evidence
|
|
4065
|
+
}
|
|
4066
|
+
} catch { }
|
|
3609
4067
|
}
|
|
3610
4068
|
|
|
3611
4069
|
$wave = if (Test-Path $crDir) { Get-WaveOf $crDir } else { 1 }
|
|
@@ -3649,14 +4107,65 @@ function Invoke-CrRecallCheck {
|
|
|
3649
4107
|
# WHY THE GATE AND NOT THE AUTHOR. Stamping at `plan` would record "this plan was authored
|
|
3650
4108
|
# from that spec". The question implement depends on is stronger — "were these plans PROVEN
|
|
3651
4109
|
# against this spec" — and a plan authored from the current spec can still be wrong. So the
|
|
3652
|
-
# receipt is written where the proof happens, by analyze-plan,
|
|
3653
|
-
#
|
|
3654
|
-
#
|
|
4110
|
+
# receipt is written where the proof happens, by analyze-plan, and it hashes every document
|
|
4111
|
+
# the gate actually read: the spec, the context's domain model (check 4 reads it) and each
|
|
4112
|
+
# lane plan that was present.
|
|
4113
|
+
#
|
|
4114
|
+
# THE RECEIPT CARRIES THE VERDICT, and every master-mode run writes one. A receipt written
|
|
4115
|
+
# only on a clean pass is never written on a feature whose spec misses a domain constraint —
|
|
4116
|
+
# a LIFT-FAILURE analyze-plan may not close — and then the staleness check has nothing to
|
|
4117
|
+
# compare. The verdict splits the gaps by who can close them (plan-gaps):
|
|
4118
|
+
# conformant no gaps
|
|
4119
|
+
# routed-gaps only gaps /appweaver specify must close — implement warns and generates
|
|
4120
|
+
# gaps at least one gap a plan rewrite closes — implement refuses
|
|
3655
4121
|
#
|
|
3656
4122
|
# The absence of a receipt is INFORMATION, not an error: it means this feature has never
|
|
3657
4123
|
# been through the gate, which every tree that predates this op is true of. It is reported
|
|
3658
|
-
# as untracked and blocks nothing.
|
|
3659
|
-
#
|
|
4124
|
+
# as untracked and blocks nothing. A receipt that EXISTS refuses when the bytes have moved
|
|
4125
|
+
# since, or when its verdict is `gaps`. A receipt written before the verdict existed was
|
|
4126
|
+
# written on a clean pass, and reads as `conformant`. Twins of the bash ops.
|
|
4127
|
+
|
|
4128
|
+
# Which analyze-plan gaps a plan rewrite can close, and which only /appweaver specify can.
|
|
4129
|
+
# A gap routes to specify when the analyze pass tagged it `route: specify` (a constraint the
|
|
4130
|
+
# spec never lifted, structure domain-model.md does not define, behaviour the spec does not
|
|
4131
|
+
# describe), or when its text opens with LIFT-FAILURE whatever the tag says. Anything else —
|
|
4132
|
+
# an untagged gap included — is a plan gap. Unreadable input is one plan gap: a verdict that
|
|
4133
|
+
# could not be read must not pass.
|
|
4134
|
+
function Invoke-PlanGaps {
|
|
4135
|
+
param([string[]]$Rest)
|
|
4136
|
+
$raw = ''
|
|
4137
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
4138
|
+
switch ($Rest[$i]) { { $_ -in '--gaps','-Gaps' } { $raw = $Rest[++$i] } }
|
|
4139
|
+
}
|
|
4140
|
+
$gaps = @(); $readable = $true
|
|
4141
|
+
if ("$raw".Trim()) {
|
|
4142
|
+
try {
|
|
4143
|
+
$parsed = ConvertFrom-Json -InputObject $raw -NoEnumerate -ErrorAction Stop
|
|
4144
|
+
if ($parsed -is [array]) { $gaps = @($parsed) } else { $readable = $false }
|
|
4145
|
+
} catch { $readable = $false }
|
|
4146
|
+
}
|
|
4147
|
+
$plan = 0; $routed = @()
|
|
4148
|
+
foreach ($g in $gaps) {
|
|
4149
|
+
if ($g -is [System.Management.Automation.PSCustomObject]) {
|
|
4150
|
+
$text = "$($g.gap)".Trim(); $route = "$($g.route)".Trim().ToLower(); $ref = "$($g.spec_ref)".Trim()
|
|
4151
|
+
} else {
|
|
4152
|
+
$text = "$g".Trim(); $route = ''; $ref = ''
|
|
4153
|
+
}
|
|
4154
|
+
if ($text.ToUpper().StartsWith('LIFT-FAILURE') -or $route -eq 'specify') {
|
|
4155
|
+
$routed += $(if ($ref -and $ref -ne '—') { '{0}: {1}' -f $ref, $text } else { $text })
|
|
4156
|
+
} else {
|
|
4157
|
+
$plan++
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4160
|
+
if (-not $readable) { $plan++ }
|
|
4161
|
+
$verdict = if ($plan -gt 0) { 'gaps' } elseif ($routed.Count -gt 0) { 'routed-gaps' } else { 'conformant' }
|
|
4162
|
+
Write-Json ([ordered]@{
|
|
4163
|
+
ok = $true; readable = $readable; verdict = $verdict; plan_ok = ($plan -eq 0)
|
|
4164
|
+
plan_gaps = $plan; routed_gaps = $routed.Count; routed = @($routed)
|
|
4165
|
+
routed_csv = ($routed -join '; ')
|
|
4166
|
+
})
|
|
4167
|
+
}
|
|
4168
|
+
|
|
3660
4169
|
function Get-FileSha {
|
|
3661
4170
|
# -LiteralPath throughout: -Path reads [ and ] as a wildcard character class, so a
|
|
3662
4171
|
# repo living under a bracketed directory name would silently hash nothing.
|
|
@@ -3667,14 +4176,27 @@ function Get-FileSha {
|
|
|
3667
4176
|
|
|
3668
4177
|
function Invoke-PlanGateStamp {
|
|
3669
4178
|
param([string[]]$Rest)
|
|
3670
|
-
$fdArg = ''; $specVersion = ''; $mode = 'master'
|
|
4179
|
+
$fdArg = ''; $specVersion = ''; $mode = 'master'; $verdict = 'conformant'; $planGaps = '0'; $routedRaw = '[]'
|
|
3671
4180
|
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
3672
4181
|
switch ($Rest[$i]) {
|
|
3673
4182
|
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
3674
4183
|
{ $_ -in '--spec-version','-SpecVersion' } { $specVersion = $Rest[++$i] }
|
|
3675
4184
|
{ $_ -in '--mode','-Mode' } { $mode = $Rest[++$i] }
|
|
4185
|
+
{ $_ -in '--verdict','-Verdict' } { $verdict = $Rest[++$i] }
|
|
4186
|
+
{ $_ -in '--plan-gaps','-PlanGaps' } { $planGaps = $Rest[++$i] }
|
|
4187
|
+
{ $_ -in '--routed','-Routed' } { $routedRaw = $Rest[++$i] }
|
|
3676
4188
|
}
|
|
3677
4189
|
}
|
|
4190
|
+
# An unrecognised verdict is recorded as `gaps`, the one that refuses.
|
|
4191
|
+
$verdict = "$verdict".Trim()
|
|
4192
|
+
if ($verdict -notin @('conformant','routed-gaps','gaps')) { $verdict = 'gaps' }
|
|
4193
|
+
$planGapCount = 0
|
|
4194
|
+
if (-not [int]::TryParse("$planGaps", [ref]$planGapCount)) { $planGapCount = 0 }
|
|
4195
|
+
$routed = @()
|
|
4196
|
+
try {
|
|
4197
|
+
$parsedRouted = ConvertFrom-Json -InputObject $(if ("$routedRaw".Trim()) { $routedRaw } else { '[]' }) -NoEnumerate -ErrorAction Stop
|
|
4198
|
+
$routed = @($parsedRouted | ForEach-Object { "$_" })
|
|
4199
|
+
} catch { $routed = @() }
|
|
3678
4200
|
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
3679
4201
|
if (-not $fd) { Write-Error 'plan-gate-stamp: no feature set and no --feature-dir'; exit 1 }
|
|
3680
4202
|
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
@@ -3697,19 +4219,24 @@ function Invoke-PlanGateStamp {
|
|
|
3697
4219
|
gated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
|
3698
4220
|
gate_mode = $mode
|
|
3699
4221
|
spec_version = $specVersion.Trim()
|
|
4222
|
+
verdict = $verdict
|
|
4223
|
+
plan_gaps = $planGapCount
|
|
4224
|
+
routed_gaps = @($routed)
|
|
3700
4225
|
spec_sha = (Get-FileSha -Path (Join-Path $fd 'spec.md'))
|
|
3701
4226
|
model_sha = (Get-FileSha -Path (Join-Path $ctx 'domain-model.md'))
|
|
3702
4227
|
plans = $plans
|
|
3703
|
-
note = ('Written by analyze-plan
|
|
3704
|
-
'
|
|
3705
|
-
'are stamped in lockstep by close-delta and cannot answer
|
|
3706
|
-
'reads it through plan-gate-check
|
|
4228
|
+
note = ('Written by every master-mode analyze-plan run. It records the bytes that were ' +
|
|
4229
|
+
'checked against each other and the verdict on them, not a version — the plans'' ' +
|
|
4230
|
+
'**Version**: headers are stamped in lockstep by close-delta and cannot answer ' +
|
|
4231
|
+
'this. implement reads it through plan-gate-check: moved bytes or verdict `gaps` ' +
|
|
4232
|
+
'refuse; `routed-gaps` lists what /appweaver specify still has to lift.')
|
|
3707
4233
|
}
|
|
3708
4234
|
$p = Join-Path $fd '.aw-plan-gate.json'
|
|
3709
|
-
(
|
|
4235
|
+
(ConvertTo-Json -InputObject $rec -Depth 6) + "`n" | Set-Content -LiteralPath $p -NoNewline -Encoding utf8
|
|
3710
4236
|
Write-Json ([ordered]@{
|
|
3711
4237
|
file = (ConvertTo-RepoRelative -Path $p -RepoRoot $repo); gated = $rec.gated
|
|
3712
|
-
spec_version = $rec.spec_version;
|
|
4238
|
+
spec_version = $rec.spec_version; verdict = $verdict; plan_gaps = $planGapCount
|
|
4239
|
+
routed_gaps = $routed.Count; hashed = @($plans.Keys | Sort-Object)
|
|
3713
4240
|
count = $plans.Count; ok = $true
|
|
3714
4241
|
})
|
|
3715
4242
|
}
|
|
@@ -3730,17 +4257,27 @@ function Invoke-PlanGateCheck {
|
|
|
3730
4257
|
}
|
|
3731
4258
|
$out = [ordered]@{
|
|
3732
4259
|
file = (ConvertTo-RepoRelative -Path $p -RepoRoot $repo); tracked = [bool]$rec
|
|
3733
|
-
gate_fresh = $false;
|
|
4260
|
+
gate_fresh = $false; verdict = ''; may_generate = $true; plan_gaps = 0
|
|
4261
|
+
routed_gaps = @(); routed_csv = ''; spec_moved = $false; model_moved = $false
|
|
3734
4262
|
plans_changed = @(); plans_changed_csv = ''; gated = ''; spec_version = ''
|
|
3735
4263
|
reason = ''; ok = $true
|
|
3736
4264
|
}
|
|
3737
4265
|
if (-not $rec) {
|
|
3738
4266
|
# Never gated. Not a failure and not a stale plan — an unknown, and the only honest
|
|
3739
4267
|
# thing to report about a tree that has not run analyze-plan since this op existed.
|
|
3740
|
-
$out.reason = 'no .aw-plan-gate.json — this feature has not
|
|
4268
|
+
$out.reason = 'no .aw-plan-gate.json — this feature has not been through analyze-plan in master mode since the gate began leaving a receipt'
|
|
3741
4269
|
} else {
|
|
3742
|
-
|
|
4270
|
+
# ConvertFrom-Json turns an ISO timestamp into a DateTime; render it back in the
|
|
4271
|
+
# form the receipt was written in, as the bash twin reports it.
|
|
4272
|
+
$out.gated = $(if ($rec.gated -is [datetime]) { $rec.gated.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { [string]$rec.gated })
|
|
3743
4273
|
$out.spec_version = [string]$rec.spec_version
|
|
4274
|
+
# A receipt from before the verdict existed was only ever written on a clean pass.
|
|
4275
|
+
$out.verdict = $(if ("$($rec.verdict)") { [string]$rec.verdict } else { 'conformant' })
|
|
4276
|
+
$gapCount = 0
|
|
4277
|
+
if ($null -ne $rec.plan_gaps) { [void][int]::TryParse("$($rec.plan_gaps)", [ref]$gapCount) }
|
|
4278
|
+
$out.plan_gaps = $gapCount
|
|
4279
|
+
$out.routed_gaps = @(@($rec.routed_gaps) | Where-Object { $null -ne $_ } | ForEach-Object { "$_" })
|
|
4280
|
+
$out.routed_csv = ($out.routed_gaps -join '; ')
|
|
3744
4281
|
$specSha = [string]$rec.spec_sha
|
|
3745
4282
|
$out.spec_moved = ($specSha -and $specSha -ne (Get-FileSha -Path (Join-Path $fd 'spec.md')))
|
|
3746
4283
|
# A model recorded as absent that is still absent has not moved. A context that
|
|
@@ -3755,19 +4292,39 @@ function Invoke-PlanGateCheck {
|
|
|
3755
4292
|
$out.plans_changed = @($changed)
|
|
3756
4293
|
$out.plans_changed_csv = ($changed -join ', ')
|
|
3757
4294
|
$out.gate_fresh = (-not ($out.spec_moved -or $out.model_moved -or $changed.Count -gt 0))
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
4295
|
+
# Moved bytes refuse whatever the verdict was; unmoved bytes refuse only on `gaps`.
|
|
4296
|
+
$out.may_generate = ($out.gate_fresh -and $out.verdict -ne 'gaps')
|
|
4297
|
+
if (-not $out.gate_fresh) {
|
|
3761
4298
|
$bits = @()
|
|
3762
4299
|
if ($out.spec_moved) { $bits += 'spec.md has changed' }
|
|
3763
4300
|
if ($out.model_moved) { $bits += 'domain-model.md has changed' }
|
|
3764
4301
|
if ($changed.Count -gt 0) { $bits += ('{0} changed (a re-derive by `plan main`, or a hand edit)' -f ($changed -join ', ')) }
|
|
3765
|
-
$out.reason = 'since analyze-plan
|
|
4302
|
+
$out.reason = 'since analyze-plan checked them on {0}: {1}' -f $out.gated, ($bits -join '; ')
|
|
4303
|
+
} elseif ($out.verdict -eq 'gaps') {
|
|
4304
|
+
$n = $(if ($out.plan_gaps) { $out.plan_gaps } else { 'some' })
|
|
4305
|
+
$out.reason = 'analyze-plan on {0} left {1} gap(s) a plan rewrite closes, unrepaired' -f $out.gated, $n
|
|
4306
|
+
} elseif ($out.verdict -eq 'routed-gaps') {
|
|
4307
|
+
$out.reason = 'the spec, the model and every gated plan are byte-identical to what analyze-plan checked on {0}; it left {1} gap(s) only /appweaver specify can close: {2}' -f $out.gated, $out.routed_gaps.Count, $out.routed_csv
|
|
4308
|
+
} else {
|
|
4309
|
+
$out.reason = 'the spec, the model and every gated plan are byte-identical to what analyze-plan passed on {0}' -f $out.gated
|
|
3766
4310
|
}
|
|
3767
4311
|
}
|
|
3768
4312
|
Write-Json $out
|
|
3769
4313
|
}
|
|
3770
4314
|
|
|
4315
|
+
# Which of a lane plan's elements the generated code realises. One implementation
|
|
4316
|
+
# (python/aw-plan-coverage.py), called identically by both shells — see the bash twin.
|
|
4317
|
+
function Invoke-PlanCoverage {
|
|
4318
|
+
param([string[]]$Rest)
|
|
4319
|
+
$py = Get-Py
|
|
4320
|
+
if (-not $py) { Write-Error 'plan-coverage: python3 is required'; exit 1 }
|
|
4321
|
+
$core = Join-Path $PSScriptRoot '..' 'python' 'aw-plan-coverage.py'
|
|
4322
|
+
if (-not (Test-Path $core)) { Write-Error "plan-coverage: core not found at $core"; exit 1 }
|
|
4323
|
+
$repo = Get-RepoRoot
|
|
4324
|
+
& $py $core '--repo' $repo @Rest
|
|
4325
|
+
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
4326
|
+
}
|
|
4327
|
+
|
|
3771
4328
|
# Stamp a version into the MASTER documents' `**Version**:` headers — the
|
|
3772
4329
|
# deterministic twin of cr-archive's active_version.json bump. close-delta calls
|
|
3773
4330
|
# this with the version cr-archive just computed, so all THREE carriers of the
|
|
@@ -3897,6 +4454,8 @@ switch ($sub) {
|
|
|
3897
4454
|
'cr-overlap' { Invoke-CrOverlap -Rest $rest }
|
|
3898
4455
|
'cr-activate' { Invoke-CrActivate -Rest $rest }
|
|
3899
4456
|
'cr-wave' { Invoke-CrWave -Rest $rest }
|
|
4457
|
+
'cr-wave-derive' { Invoke-CrWaveDerive -Rest $rest }
|
|
4458
|
+
'cr-rebase' { Invoke-CrRebase -Rest $rest }
|
|
3900
4459
|
'cr-archive' { Invoke-CrArchive -Rest $rest }
|
|
3901
4460
|
'cr-recall-check' { Invoke-CrRecallCheck -Rest $rest }
|
|
3902
4461
|
'cr-merge-check' { Invoke-CrMergeCheck -Rest $rest }
|
|
@@ -3905,8 +4464,10 @@ switch ($sub) {
|
|
|
3905
4464
|
'context-paths' { Invoke-ContextPaths -Rest $rest }
|
|
3906
4465
|
'model-scaffold' { Invoke-ModelScaffold -Rest $rest }
|
|
3907
4466
|
'version-stamp' { Invoke-VersionStamp -Rest $rest }
|
|
4467
|
+
'plan-gaps' { Invoke-PlanGaps -Rest $rest }
|
|
3908
4468
|
'plan-gate-stamp' { Invoke-PlanGateStamp -Rest $rest }
|
|
3909
4469
|
'plan-gate-check' { Invoke-PlanGateCheck -Rest $rest }
|
|
4470
|
+
'plan-coverage' { Invoke-PlanCoverage -Rest $rest }
|
|
3910
4471
|
'checklist-read' { Invoke-ChecklistRead -Rest $rest }
|
|
3911
4472
|
'checklist-apply' { Invoke-ChecklistApply -Rest $rest }
|
|
3912
4473
|
'detect-project' { Invoke-DetectProject -Rest $rest }
|