@cratis/pi 7.85.0 → 7.87.0
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/package/corpus/harnesses/copilot/instructions/pull-requests.instructions.md +2 -0
- package/package/corpus/rules/pull-requests.md +2 -0
- package/package/corpus/skills/cratis-engineering-consolidation-guard/LICENSE +2 -0
- package/package/corpus/skills/cratis-engineering-consolidation-guard/SKILL.md +197 -0
- package/package/corpus/skills/cratis-engineering-consolidation-guard/references/ci-required-check.md +131 -0
- package/package/corpus/skills/cratis-engineering-consolidation-guard/scripts/verify-consolidation.sh +365 -0
- package/package/corpus/skills/cratis-screenplay-captures-and-reactions/references/record-capture-specifications.md +5 -0
- package/package/corpus/skills/cratis-screenplay-command-surface/references/constraints-produces-concurrency.md +3 -2
- package/package/corpus/skills/cratis-screenplay-command-surface/references/validate-rules.md +1 -0
- package/package/corpus/skills/cratis-screenplay-projections/references/joins.md +2 -1
- package/package/corpus/skills/cratis-screenplay-specifications/references/generated-fixtures.md +4 -0
- package/package/corpus/skills/cratis-screenplay-specifications/references/reactions-and-cascades.md +7 -0
- package/package/corpus/skills/cratis-screenplay-toolchain/references/language-admission.md +4 -0
- package/package/corpus/skills/cratis-screenplay-ui-composition/references/application-shell.md +19 -0
- package/package/profile-catalog.json +1 -0
- package/package.json +1 -1
|
@@ -158,6 +158,8 @@ Do not open a pull request per task when the tasks belong to the same body of wo
|
|
|
158
158
|
|
|
159
159
|
**Before consolidating open PRs, review each PR’s release intent and workflow effects.** Integration may trigger completion/publication behavior on an absorbed PR. Use supported non-release intent where appropriate. A direct request to consolidate the named pull requests authorizes the necessary relabeling and merge; it does not authorize unrelated notifications.
|
|
160
160
|
|
|
161
|
+
Before a consolidation or a re-land of reverted work merges, prove it dropped nothing with `cratis-engineering-consolidation-guard`.
|
|
162
|
+
|
|
161
163
|
Split into separate pull requests when the changes are genuinely unrelated, when one is urgent and the others are not, or when one is risky enough to want its own revert.
|
|
162
164
|
|
|
163
165
|
## Quality Gates
|
|
@@ -157,6 +157,8 @@ Do not open a pull request per task when the tasks belong to the same body of wo
|
|
|
157
157
|
|
|
158
158
|
**Before consolidating open PRs, review each PR’s release intent and workflow effects.** Integration may trigger completion/publication behavior on an absorbed PR. Use supported non-release intent where appropriate. A direct request to consolidate the named pull requests authorizes the necessary relabeling and merge; it does not authorize unrelated notifications.
|
|
159
159
|
|
|
160
|
+
Before a consolidation or a re-land of reverted work merges, prove it dropped nothing with `cratis-engineering-consolidation-guard`.
|
|
161
|
+
|
|
160
162
|
Split into separate pull requests when the changes are genuinely unrelated, when one is urgent and the others are not, or when one is risky enough to want its own revert.
|
|
161
163
|
|
|
162
164
|
## Quality Gates
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cratis-engineering-consolidation-guard
|
|
3
|
+
description: Prove that a consolidation or re-land dropped nothing - compare the result with every original branch or commit, detect lost files that commit pairing cannot see, treat a merge resolution that reverts a file as a finding, ratchet the specification count, and enforce it as a required CI check. Use when folding several pull requests into one branch, re-landing reverted work, restoring commits by cherry-pick, or reviewing such a branch before it merges. Not for an ordinary merge of the base into a feature branch, for pull request descriptions or labels (follow the pull-requests rule), or for general code review (use cratis-code-review).
|
|
4
|
+
license: LICENSE
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Consolidations and re-lands prove nothing was dropped
|
|
8
|
+
|
|
9
|
+
A **consolidation** is any branch whose job is to carry changes authored somewhere
|
|
10
|
+
else: several pull requests folded into one, reverted work brought back, commits
|
|
11
|
+
restored from an abandoned branch. Its **sources** are those original branches
|
|
12
|
+
and commits.
|
|
13
|
+
|
|
14
|
+
The combined tree is built and specified before it merges, and that is exactly
|
|
15
|
+
why loss goes unnoticed. A change deleted *by the merge itself* leaves nothing
|
|
16
|
+
behind to fail: a deleted specification cannot go red, and a file a conflict
|
|
17
|
+
resolution put back to its old content compiles fine. The guard compares the
|
|
18
|
+
result with every source, path by path, and turns each loss into a finding.
|
|
19
|
+
|
|
20
|
+
**A re-land is a consolidation.** Work that landed, was reverted and is landed
|
|
21
|
+
again has one source, the original change, and loses files the same way. Run
|
|
22
|
+
the same guard against that original change, not against the revert.
|
|
23
|
+
|
|
24
|
+
## When the guard applies
|
|
25
|
+
|
|
26
|
+
- Folding two or more pull requests onto one branch, as
|
|
27
|
+
[pull-requests.md](../../rules/pull-requests.md) asks for small related work.
|
|
28
|
+
- Re-landing reverted work, whether by reverting the revert, cherry-picking the
|
|
29
|
+
original commits, or merging the original branch again.
|
|
30
|
+
- Restoring commits by cherry-pick: from a deleted branch, a closed pull
|
|
31
|
+
request, or a backport.
|
|
32
|
+
- Reviewing any of the above before it merges.
|
|
33
|
+
|
|
34
|
+
It does not apply to merging the base branch into a feature branch (the base is
|
|
35
|
+
not a source), or to a fresh implementation that shares no history with the
|
|
36
|
+
work it replaces; there, specify the behavior instead.
|
|
37
|
+
|
|
38
|
+
## Consolidate without rewriting history
|
|
39
|
+
|
|
40
|
+
[git-commits.md](../../rules/git-commits.md) forbids rebase, squash and force.
|
|
41
|
+
Each permitted way to consolidate also records its source in history, which is
|
|
42
|
+
what lets CI find the sources without being told:
|
|
43
|
+
|
|
44
|
+
| Situation | Default | Recorded as |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| Fold a pull request | `git merge --no-ff pr631` | a merge whose second parent is the folded tip |
|
|
47
|
+
| Restore commits | `git cherry-pick -x <sha>` | a `(cherry picked from commit <sha>)` line |
|
|
48
|
+
| Re-land reverted work | `git revert <revert-sha>` | `This reverts commit <revert-sha>.` |
|
|
49
|
+
|
|
50
|
+
Choose cherry-pick over reverting the revert when only some of the original
|
|
51
|
+
commits come back; every change left out is then a finding that needs a
|
|
52
|
+
disposition.
|
|
53
|
+
|
|
54
|
+
## Name the sources
|
|
55
|
+
|
|
56
|
+
A source is either a ref or a range `<from>..<ref>`. A bare ref means the change
|
|
57
|
+
since `merge-base(<base>, <ref>)`; the range means the change since
|
|
58
|
+
`merge-base(<from>, <ref>)`.
|
|
59
|
+
|
|
60
|
+
| Source | Argument |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| A folded pull request | `git fetch origin "refs/pull/631/head:pr631"`, then `pr631` |
|
|
63
|
+
| A merged pull request being re-landed, merge commit `M` | `M^1..M^2` |
|
|
64
|
+
| A single commit `C`, or a pull request that landed as one commit | `C^..C` |
|
|
65
|
+
| A branch commits were cherry-picked from | its head ref, or `C^..C` per commit |
|
|
66
|
+
|
|
67
|
+
Fetch the pull request's own head ref rather than its branch: `refs/pull/<n>/head`
|
|
68
|
+
survives the branch being deleted.
|
|
69
|
+
|
|
70
|
+
Work that already landed needs the range form. Its commits are ancestors of the
|
|
71
|
+
base, so `merge-base(<base>, <ref>)` is the tip itself and the comparison would
|
|
72
|
+
be empty; the script refuses that with exit `2` rather than reporting a pass.
|
|
73
|
+
|
|
74
|
+
Confirm a folded branch has not moved since it was fetched. When
|
|
75
|
+
`gh pr view 631 --json headRefOid --jq .headRefOid` differs from
|
|
76
|
+
`git rev-parse pr631`, fetch again and fold the new commits before verifying.
|
|
77
|
+
|
|
78
|
+
## Run the guard
|
|
79
|
+
|
|
80
|
+
Execute [scripts/verify-consolidation.sh](scripts/verify-consolidation.sh) inside
|
|
81
|
+
the repository, with the consolidation ref first and every source after it.
|
|
82
|
+
|
|
83
|
+
Requires git 2.28 or later (`git --version`); with an older git, run the three
|
|
84
|
+
checks below by hand.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
guard=<skill-directory>/scripts/verify-consolidation.sh
|
|
88
|
+
git fetch origin
|
|
89
|
+
for n in 631 640 652; do git fetch origin "refs/pull/$n/head:pr$n"; done
|
|
90
|
+
"$guard" consolidate/backlog-filters pr631 pr640 pr652
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A re-land of a merged pull request, given the original merge commit:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
landed=<original-merge-sha>
|
|
97
|
+
"$guard" reland/dashboard-counters "$landed^1..$landed^2"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Options: `--base <ref>` (default `origin/main`), `--spec-pattern <regex>` for a
|
|
101
|
+
repository whose specifications are not `for_*`/`when_*` folders,
|
|
102
|
+
`--allow-rewritten <sha>` and `--allow-spec-delta <n>` for dispositions, and
|
|
103
|
+
`--creation-factor <n>` for `git range-diff` pairing.
|
|
104
|
+
|
|
105
|
+
Before choosing sources by hand, list the ones history records and compare:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
"$guard" --discover consolidate/backlog-filters
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
A source on one list and not the other is a finding of its own: either a fold
|
|
112
|
+
that was not intended, or an intended one that never happened.
|
|
113
|
+
|
|
114
|
+
**Pass:** exit `0` with `0 finding(s)` and a nonzero path count. Exit `1` lists
|
|
115
|
+
findings. Exit `2` means it could not run (an unresolvable ref, or a source
|
|
116
|
+
with nothing to compare); that is never a pass.
|
|
117
|
+
After changing the script, run `"$guard" --self-test`;
|
|
118
|
+
it plants each kind of loss in a scratch repository and passes only when every
|
|
119
|
+
one is reported.
|
|
120
|
+
|
|
121
|
+
## Why commit pairing is not enough
|
|
122
|
+
|
|
123
|
+
`git range-diff` compares commits, not the resulting tree.
|
|
124
|
+
|
|
125
|
+
- **After a true merge, every folded commit is an ancestor of the
|
|
126
|
+
consolidation** and pairs with itself, however much the merge resolution
|
|
127
|
+
threw away. A re-land is the same: the original commits are already in the
|
|
128
|
+
base's history.
|
|
129
|
+
- **What matters is per path.** A file the source added must exist; a file it
|
|
130
|
+
modified must not still hold its pre-change content; a file it deleted must
|
|
131
|
+
not hold its pre-change content again. That content check is the one that sees
|
|
132
|
+
a resolution reverting a file.
|
|
133
|
+
- **Pairing still matters when commits are copied.** After cherry-picks, a
|
|
134
|
+
source commit with no identical patch in the consolidation was dropped or
|
|
135
|
+
rewritten. The script matches by `git patch-id` first, because the `-x` line
|
|
136
|
+
alone makes `range-diff` report every cherry-pick as changed.
|
|
137
|
+
- **Byte equality catches whole-file loss, not partial loss.** The script lists
|
|
138
|
+
every path that differs from the source tip as `review:`; read
|
|
139
|
+
`git diff <source-tip> <consolidation> -- <path>` for each one another source
|
|
140
|
+
or the base does not explain.
|
|
141
|
+
|
|
142
|
+
The specification ratchet covers what the other checks share a blind spot for:
|
|
143
|
+
the consolidation must carry at least as many specification files as the base
|
|
144
|
+
plus each source's net addition. It is a count, so read the content findings
|
|
145
|
+
first; they name the paths.
|
|
146
|
+
|
|
147
|
+
## Disposition of every finding
|
|
148
|
+
|
|
149
|
+
Each finding gets exactly one disposition before the merge:
|
|
150
|
+
|
|
151
|
+
- **Restore it** in a new commit, for example
|
|
152
|
+
`git checkout pr631 -- src/Filters/ByOwner.cs`, then commit. Re-run the guard.
|
|
153
|
+
- **Accept it in history** when a rewritten commit differs only by conflict
|
|
154
|
+
resolution (after reading its range-diff), or when two sources added the same
|
|
155
|
+
specification path. Commit the disposition as a trailer, so CI honors it and
|
|
156
|
+
the reviewer sees it; the guard prints each one as `disposition:`.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
git commit --allow-empty -m "Accept the conflict-resolved owner filter restoration" \
|
|
160
|
+
-m "Consolidation-Allow-Rewritten: <sha>"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`Consolidation-Allow-Spec-Delta: <n>` works the same way. The
|
|
164
|
+
`--allow-rewritten` and `--allow-spec-delta` options are the local-only
|
|
165
|
+
equivalents.
|
|
166
|
+
- **Remove it in its own pull request** when dropping part of a source is the
|
|
167
|
+
point. A consolidation carries its sources whole; the removal, made after it
|
|
168
|
+
merges, is then the reviewed change rather than a side effect of a merge.
|
|
169
|
+
- **Stop the consolidation** when the loss cannot be explained.
|
|
170
|
+
|
|
171
|
+
A merge resolution that reverts a file is a finding even when it was
|
|
172
|
+
intentional, because nothing else records the intent. Never clear a finding by
|
|
173
|
+
removing its source from the argument list. Explain each disposition in a pull
|
|
174
|
+
request comment; the description is the release note and carries no
|
|
175
|
+
verification notes.
|
|
176
|
+
|
|
177
|
+
## Make it a required check
|
|
178
|
+
|
|
179
|
+
A guard an agent can skip is a suggestion. Run it in CI on every pull request,
|
|
180
|
+
discover the sources from history, fail on exit `1` and `2`, and add the job to
|
|
181
|
+
the branch's required status checks with no bypass for the identity that
|
|
182
|
+
merges. Read [references/ci-required-check.md](references/ci-required-check.md)
|
|
183
|
+
when adding or reviewing that workflow.
|
|
184
|
+
|
|
185
|
+
## Pitfalls
|
|
186
|
+
|
|
187
|
+
- **Verifying against the revert.** A re-land's source is the original change;
|
|
188
|
+
the revert's own diff only proves the revert was undone.
|
|
189
|
+
- **Passing the landed tip as a bare ref.** It is already in the base, so the
|
|
190
|
+
script exits `2` with `already contained`; use `M^1..M^2` or `C^..C`.
|
|
191
|
+
- **Trusting a green build.** The specifications that would have failed are the
|
|
192
|
+
ones the merge deleted.
|
|
193
|
+
- **Fixing a finding by force-pushing or squashing the branch.** That removes
|
|
194
|
+
the evidence and breaks [git-commits.md](../../rules/git-commits.md); restore
|
|
195
|
+
in a new commit.
|
|
196
|
+
- **Folding from a branch name.** A deleted branch cannot be fetched again;
|
|
197
|
+
`refs/pull/<n>/head` can.
|
package/package/corpus/skills/cratis-engineering-consolidation-guard/references/ci-required-check.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Consolidation guard as a required CI check
|
|
2
|
+
|
|
3
|
+
## Contents
|
|
4
|
+
|
|
5
|
+
- What makes it unskippable
|
|
6
|
+
- Workflow
|
|
7
|
+
- Make the check required
|
|
8
|
+
- What discovery cannot see
|
|
9
|
+
|
|
10
|
+
## What makes it unskippable
|
|
11
|
+
|
|
12
|
+
An agent asked to "run the guard before merging" can forget, run it against the
|
|
13
|
+
wrong sources, or read exit `2` as a pass. A required check removes all three:
|
|
14
|
+
|
|
15
|
+
- **It runs on every pull request.** The job decides whether the branch is a
|
|
16
|
+
consolidation, so no one has to label it. A workflow-level `paths` filter
|
|
17
|
+
would leave the required check pending on unrelated pull requests; decide
|
|
18
|
+
inside the job instead.
|
|
19
|
+
- **It discovers the sources from history.** `--discover` reads merge parents
|
|
20
|
+
the base does not contain, `cherry-pick -x` lines and reverts of reverts. The
|
|
21
|
+
author does not have to declare anything, and cannot leave a source out.
|
|
22
|
+
- **It fails on `1` and on `2`.** A source that cannot be resolved or compared
|
|
23
|
+
fails the check; unknown is not pass. A `cherry-pick -x` of a commit that was
|
|
24
|
+
never pushed, or exists only on a fork, stays at `2`: push the source commit
|
|
25
|
+
so the job can fetch it. No trailer clears an unresolvable source.
|
|
26
|
+
- **Dispositions live in history.** An accepted rewrite or specification delta
|
|
27
|
+
is a `Consolidation-Allow-Rewritten:` or `Consolidation-Allow-Spec-Delta:`
|
|
28
|
+
commit trailer, which the job honors and prints as `disposition:`. Accepting a
|
|
29
|
+
loss is therefore a reviewable commit, never a bypass. The job reads trailers
|
|
30
|
+
from every commit the consolidation adds, folded source commits included, so
|
|
31
|
+
a reviewer reads each `disposition:` line, not only the consolidation's own
|
|
32
|
+
commits.
|
|
33
|
+
- **Merging requires it.** A red or missing required check blocks
|
|
34
|
+
`gh pr merge`. With no bypass actor, `gh pr merge --admin` cannot get past it
|
|
35
|
+
either.
|
|
36
|
+
|
|
37
|
+
## Workflow
|
|
38
|
+
|
|
39
|
+
Copy `scripts/verify-consolidation.sh` from this skill into the repository's
|
|
40
|
+
own `scripts/` folder and commit it. A CI gate must not change underneath the
|
|
41
|
+
repository when the shared corpus is updated, and a copy the repository owns
|
|
42
|
+
changes only through its own pull requests. Run the copy's `--self-test` in the
|
|
43
|
+
same job so a broken edit to it cannot pass silently.
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
name: Consolidation guard
|
|
47
|
+
|
|
48
|
+
on:
|
|
49
|
+
pull_request:
|
|
50
|
+
types: [opened, synchronize, reopened]
|
|
51
|
+
|
|
52
|
+
concurrency:
|
|
53
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
54
|
+
cancel-in-progress: true
|
|
55
|
+
|
|
56
|
+
permissions:
|
|
57
|
+
contents: read
|
|
58
|
+
|
|
59
|
+
jobs:
|
|
60
|
+
consolidation-guard:
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
timeout-minutes: 15
|
|
63
|
+
steps:
|
|
64
|
+
- uses: actions/checkout@v4
|
|
65
|
+
with:
|
|
66
|
+
ref: ${{ github.event.pull_request.head.sha }}
|
|
67
|
+
fetch-depth: 0
|
|
68
|
+
|
|
69
|
+
- name: Fetch pull request heads
|
|
70
|
+
# Cherry-pick and revert sources can live only on deleted branches; their pull request refs remain.
|
|
71
|
+
run: git fetch --no-tags origin '+refs/pull/*/head:refs/remotes/origin/pull/*'
|
|
72
|
+
|
|
73
|
+
- name: Self-test the guard
|
|
74
|
+
run: scripts/verify-consolidation.sh --self-test
|
|
75
|
+
|
|
76
|
+
- name: Verify every folded and re-landed source survives
|
|
77
|
+
env:
|
|
78
|
+
BASE: origin/${{ github.base_ref }}
|
|
79
|
+
run: |
|
|
80
|
+
set -euo pipefail
|
|
81
|
+
sources="$(scripts/verify-consolidation.sh --discover --base "$BASE" HEAD)"
|
|
82
|
+
if [ -z "$sources" ]; then
|
|
83
|
+
echo "No folded, cherry-picked or re-landed sources between $BASE and HEAD; checked $(git rev-list --count "$BASE..HEAD") commits."
|
|
84
|
+
exit 0
|
|
85
|
+
fi
|
|
86
|
+
printf 'Sources:\n%s\n' "$sources"
|
|
87
|
+
# Word splitting is intended: one source per line.
|
|
88
|
+
# shellcheck disable=SC2086
|
|
89
|
+
scripts/verify-consolidation.sh --base "$BASE" HEAD $sources
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Notes on the choices:
|
|
93
|
+
|
|
94
|
+
- `ref: ${{ github.event.pull_request.head.sha }}` checks out the branch as
|
|
95
|
+
pushed. The default pull request checkout is GitHub's synthetic merge with the
|
|
96
|
+
base, whose own merge commit would be discovered as a fold.
|
|
97
|
+
- `fetch-depth: 0` brings the full history and the base branch as
|
|
98
|
+
`origin/<base>`; discovery and merge-base need both.
|
|
99
|
+
- Fetching every pull request head costs time in a large repository. Drop that
|
|
100
|
+
step only if the repository never restores from deleted branches; an
|
|
101
|
+
unresolvable source then fails the check with exit `2`, which is the safe
|
|
102
|
+
direction.
|
|
103
|
+
- Keep this a `pull_request` job on a hosted runner in a public repository; it
|
|
104
|
+
needs only read access.
|
|
105
|
+
- Repositories with specifications outside `for_*`/`when_*` folders pass
|
|
106
|
+
`--spec-pattern` to the verifying invocation.
|
|
107
|
+
|
|
108
|
+
## Make the check required
|
|
109
|
+
|
|
110
|
+
Add the job's check name, `consolidation-guard`, to the default branch's
|
|
111
|
+
required status checks: a repository ruleset with **Require status checks to
|
|
112
|
+
pass**, or classic branch protection with **Require status checks to pass
|
|
113
|
+
before merging** and **Do not allow bypassing the above settings**. Leave the
|
|
114
|
+
ruleset's bypass list empty, or at least without the identity agents merge as.
|
|
115
|
+
|
|
116
|
+
Renaming the job changes the check name. The old required check then waits for
|
|
117
|
+
a status that never arrives, which blocks every pull request; update the
|
|
118
|
+
required check in the same change.
|
|
119
|
+
|
|
120
|
+
## What discovery cannot see
|
|
121
|
+
|
|
122
|
+
Discovery reads only what history records. Work restored by copying files,
|
|
123
|
+
re-implemented by hand, or folded with a squash leaves no trace, which is one
|
|
124
|
+
more reason [git-commits.md](../../../rules/git-commits.md) forbids squashing.
|
|
125
|
+
For such a branch, run the guard locally with explicit sources and post the
|
|
126
|
+
command and its result as a pull request comment, so the reviewer can repeat it.
|
|
127
|
+
|
|
128
|
+
A file a source added and the consolidation deliberately removes has no
|
|
129
|
+
trailer: the check stays red until the file is restored. Carry the source whole
|
|
130
|
+
and remove the file in its own pull request after the consolidation merges, so
|
|
131
|
+
the removal is reviewed as a change of its own.
|
package/package/corpus/skills/cratis-engineering-consolidation-guard/scripts/verify-consolidation.sh
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Copyright (c) Cratis. All rights reserved.
|
|
3
|
+
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
4
|
+
#
|
|
5
|
+
# Prove that a consolidation or re-land branch still carries every change it claims to carry.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# verify-consolidation.sh [options] <consolidation-ref> <source>...
|
|
9
|
+
# verify-consolidation.sh --discover [--base <ref>] <consolidation-ref>
|
|
10
|
+
# verify-consolidation.sh --self-test
|
|
11
|
+
#
|
|
12
|
+
# A <source> is one folded or re-landed change:
|
|
13
|
+
# <ref> a folded branch or pull-request head; its change is everything since merge-base(<base>, <ref>)
|
|
14
|
+
# <from>..<ref> the change <ref> made since merge-base(<from>, <ref>). Required for work that already
|
|
15
|
+
# landed once: <merge>^1..<merge>^2 for a merged pull request, <commit>^..<commit> for one commit.
|
|
16
|
+
#
|
|
17
|
+
# Checks, each reported per source:
|
|
18
|
+
# A commits source commits with no identical patch (git patch-id) in the consolidation's own commits;
|
|
19
|
+
# git range-diff then classifies each as dropped or rewritten. Skipped when the source tip is an
|
|
20
|
+
# ancestor of the consolidation, because every commit then pairs with itself whatever the
|
|
21
|
+
# merge resolution threw away.
|
|
22
|
+
# B content every path the source touched: an added file must exist, a modified file must not still hold
|
|
23
|
+
# the pre-change blob, a deleted file must not still hold the pre-change blob. This is the check
|
|
24
|
+
# that sees a merge resolution reverting a file.
|
|
25
|
+
# C specs the consolidation carries at least as many specification files as the base plus every
|
|
26
|
+
# source's net addition.
|
|
27
|
+
#
|
|
28
|
+
# Options:
|
|
29
|
+
# --base <ref> mainline the consolidation targets (default: origin/main)
|
|
30
|
+
# --creation-factor <n> git range-diff pairing aggressiveness, 0-100 (default: 90)
|
|
31
|
+
# --allow-rewritten <sha> accept one commit reported as rewritten after reading its range-diff. Repeatable.
|
|
32
|
+
# --allow-spec-delta <n> accept n fewer specification files than the ratchet expects (default: 0)
|
|
33
|
+
# --spec-pattern <regex> extended regex selecting specification files
|
|
34
|
+
# (default: (^|/)(for_|when_)[^/]*/.*\.(cs|ts|tsx)$)
|
|
35
|
+
# --discover print the sources recorded in the consolidation's history, one per line
|
|
36
|
+
# --self-test plant known defects in a scratch repository and require every one to be found
|
|
37
|
+
#
|
|
38
|
+
# Dispositions recorded in the consolidation's own commits count like the options, so CI honors them:
|
|
39
|
+
# Consolidation-Allow-Rewritten: <sha> same as --allow-rewritten <sha>
|
|
40
|
+
# Consolidation-Allow-Spec-Delta: <n> added to --allow-spec-delta
|
|
41
|
+
#
|
|
42
|
+
# Exit codes: 0 every source survives, 1 findings, 2 could not run (bad arguments, an unresolvable ref, or a
|
|
43
|
+
# source with nothing to compare).
|
|
44
|
+
set -euo pipefail
|
|
45
|
+
|
|
46
|
+
BASE="origin/main"
|
|
47
|
+
CREATION_FACTOR=90
|
|
48
|
+
ALLOW_SPEC_DELTA=0
|
|
49
|
+
ALLOWED_REWRITTEN=""
|
|
50
|
+
SPEC_PATTERN='(^|/)(for_|when_)[^/]*/.*\.(cs|ts|tsx)$'
|
|
51
|
+
MODE="verify"
|
|
52
|
+
|
|
53
|
+
usage() { awk 'NR < 5 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0"; }
|
|
54
|
+
die() { printf 'ERROR %s\n' "$*" >&2; exit 2; }
|
|
55
|
+
# A whole number in base 10 (so '08' is eight, not an octal error), or exit 2.
|
|
56
|
+
whole_number() { case "$2" in ''|*[!0-9]*) die "$1 must be a whole number, found '$2'." ;; esac; printf '%s' "$((10#$2))"; }
|
|
57
|
+
|
|
58
|
+
while [ $# -gt 0 ]; do
|
|
59
|
+
case "$1" in
|
|
60
|
+
--base) [ $# -ge 2 ] || die "--base needs a value"; BASE="$2"; shift 2 ;;
|
|
61
|
+
--creation-factor) [ $# -ge 2 ] || die "--creation-factor needs a value"; CREATION_FACTOR="$(whole_number --creation-factor "$2")"; shift 2 ;;
|
|
62
|
+
--allow-rewritten) [ $# -ge 2 ] || die "--allow-rewritten needs a value"; ALLOWED_REWRITTEN="$ALLOWED_REWRITTEN $2"; shift 2 ;;
|
|
63
|
+
--allow-spec-delta) [ $# -ge 2 ] || die "--allow-spec-delta needs a value"; ALLOW_SPEC_DELTA="$(whole_number --allow-spec-delta "$2")"; shift 2 ;;
|
|
64
|
+
--spec-pattern) [ $# -ge 2 ] || die "--spec-pattern needs a value"; SPEC_PATTERN="$2"; shift 2 ;;
|
|
65
|
+
--discover) MODE="discover"; shift ;;
|
|
66
|
+
--self-test) MODE="self-test"; shift ;;
|
|
67
|
+
-h|--help) usage; exit 0 ;;
|
|
68
|
+
--) shift; break ;;
|
|
69
|
+
-*) die "unknown option: $1" ;;
|
|
70
|
+
*) break ;;
|
|
71
|
+
esac
|
|
72
|
+
done
|
|
73
|
+
|
|
74
|
+
commit_of() { git rev-parse --quiet --verify "$1^{commit}" 2>/dev/null || die "cannot resolve '$1' to a commit; fetch it first (a pull request: git fetch origin refs/pull/<n>/head:pr<n>)."; }
|
|
75
|
+
blob_of() { git rev-parse --quiet --verify "$1:$2" 2>/dev/null || true; }
|
|
76
|
+
is_ancestor() { git merge-base --is-ancestor "$1" "$2"; }
|
|
77
|
+
# True when an allowed entry (at least 7 hex characters) is a prefix of the full commit SHA.
|
|
78
|
+
is_allowed_rewrite() {
|
|
79
|
+
local allowed
|
|
80
|
+
for allowed in $ALLOWED_REWRITTEN; do
|
|
81
|
+
[ "${#allowed}" -ge 7 ] || continue
|
|
82
|
+
case "$1" in "$allowed"*) return 0 ;; esac
|
|
83
|
+
done
|
|
84
|
+
return 1
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
spec_count() {
|
|
88
|
+
local count
|
|
89
|
+
count="$(git ls-tree -r --name-only "$1" | grep -cE "$SPEC_PATTERN" || true)"
|
|
90
|
+
printf '%s' "${count:-0}"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
patch_ids() {
|
|
94
|
+
# One line per non-merge commit with a diff: "<patch-id> <commit>".
|
|
95
|
+
git log --no-merges -p --no-color --format='commit %H' "$1" | git patch-id --stable
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------------------------------------
|
|
99
|
+
discover() {
|
|
100
|
+
[ $# -eq 1 ] || die "--discover takes exactly one consolidation ref"
|
|
101
|
+
local consolidation base_commit cons_base merge parent candidates="" reverted original parents
|
|
102
|
+
consolidation="$(commit_of "$1")"
|
|
103
|
+
base_commit="$(commit_of "$BASE")"
|
|
104
|
+
cons_base="$(git merge-base "$base_commit" "$consolidation")"
|
|
105
|
+
|
|
106
|
+
# Folded branches: every non-first parent of a merge that the base does not already contain.
|
|
107
|
+
for merge in $(git rev-list --merges "$cons_base..$consolidation"); do
|
|
108
|
+
for parent in $(git rev-list --parents -n 1 "$merge" | cut -d' ' -f3-); do
|
|
109
|
+
is_ancestor "$parent" "$base_commit" || candidates="$candidates $parent"
|
|
110
|
+
done
|
|
111
|
+
done
|
|
112
|
+
# A branch folded twice contributes each tip; an older tip is checked too, which only costs time.
|
|
113
|
+
for parent in $candidates; do printf '%s\n' "$parent"; done | awk '!seen[$0]++'
|
|
114
|
+
|
|
115
|
+
# Restorations recorded by git cherry-pick -x.
|
|
116
|
+
git log --format=%B "$cons_base..$consolidation" \
|
|
117
|
+
| sed -n 's/.*(cherry picked from commit \([0-9a-f]\{7,40\}\)).*/\1/p' \
|
|
118
|
+
| sort -u | while read -r original; do printf '%s^..%s\n' "$original" "$original"; done
|
|
119
|
+
|
|
120
|
+
# Re-lands recorded by git revert of a revert: the original change is what the reverted revert undid.
|
|
121
|
+
git log --format=%B "$cons_base..$consolidation" \
|
|
122
|
+
| sed -n 's/^This reverts commit \([0-9a-f]\{7,40\}\).*/\1/p' | sort -u \
|
|
123
|
+
| while read -r reverted; do
|
|
124
|
+
git rev-parse --quiet --verify "$reverted^{commit}" >/dev/null || die "a revert names $reverted, which is not in this clone; fetch it before discovering."
|
|
125
|
+
original="$(git log -1 --format=%B "$reverted" | sed -n 's/^This reverts commit \([0-9a-f]\{7,40\}\).*/\1/p' | head -n 1)"
|
|
126
|
+
[ -n "$original" ] || continue
|
|
127
|
+
parents="$(git rev-list --parents -n 1 "$original" | wc -w | tr -d ' ')"
|
|
128
|
+
if [ "$parents" -gt 2 ]; then printf '%s^1..%s^2\n' "$original" "$original"; else printf '%s^..%s\n' "$original" "$original"; fi
|
|
129
|
+
done
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------------------------------------
|
|
133
|
+
verify() {
|
|
134
|
+
[ $# -ge 2 ] || { usage >&2; exit 2; }
|
|
135
|
+
local consolidation base_commit cons_base source from to fork
|
|
136
|
+
consolidation="$(commit_of "$1")"; shift
|
|
137
|
+
base_commit="$(commit_of "$BASE")"
|
|
138
|
+
cons_base="$(git merge-base "$base_commit" "$consolidation")"
|
|
139
|
+
|
|
140
|
+
# Dispositions recorded as commit trailers in the consolidation's own history.
|
|
141
|
+
local trailer
|
|
142
|
+
for trailer in $(git log --format='%(trailers:key=Consolidation-Allow-Rewritten,valueonly)' "$cons_base..$consolidation"); do
|
|
143
|
+
printf 'disposition: Consolidation-Allow-Rewritten %s\n' "$trailer"
|
|
144
|
+
ALLOWED_REWRITTEN="$ALLOWED_REWRITTEN $trailer"
|
|
145
|
+
done
|
|
146
|
+
for trailer in $(git log --format='%(trailers:key=Consolidation-Allow-Spec-Delta,valueonly)' "$cons_base..$consolidation"); do
|
|
147
|
+
trailer="$(whole_number Consolidation-Allow-Spec-Delta "$trailer")"
|
|
148
|
+
printf 'disposition: Consolidation-Allow-Spec-Delta %s\n' "$trailer"
|
|
149
|
+
ALLOW_SPEC_DELTA=$((ALLOW_SPEC_DELTA + trailer))
|
|
150
|
+
done
|
|
151
|
+
|
|
152
|
+
# Resolve every source before checking any, so a bad argument cannot produce a partial verdict.
|
|
153
|
+
local resolved="" changed
|
|
154
|
+
for source in "$@"; do
|
|
155
|
+
case "$source" in
|
|
156
|
+
*..*) from="${source%%..*}"; to="${source#*..}" ;;
|
|
157
|
+
*) from="$BASE"; to="$source" ;;
|
|
158
|
+
esac
|
|
159
|
+
from="$(commit_of "$from")"; to="$(commit_of "$to")"
|
|
160
|
+
fork="$(git merge-base "$from" "$to")" || die "'$source' shares no history with its base."
|
|
161
|
+
if [ "$fork" = "$to" ]; then
|
|
162
|
+
die "'$source' is already contained in its base, so there is nothing to compare. A change that landed once needs the <from>..<ref> form, for example <merge>^1..<merge>^2."
|
|
163
|
+
fi
|
|
164
|
+
changed="$(git diff --name-only "$fork" "$to" | wc -l | tr -d ' ')"
|
|
165
|
+
[ "$changed" -gt 0 ] || die "'$source' changes no files relative to ${fork:0:12}; there is nothing to verify."
|
|
166
|
+
resolved="$resolved $source|$fork|$to"
|
|
167
|
+
done
|
|
168
|
+
|
|
169
|
+
local failures=0 paths=0 commits=0 entry left status subject cons_ids="" unmatched range_line abbrev
|
|
170
|
+
fail() { printf 'FAIL %s\n' "$*"; failures=$((failures + 1)); }
|
|
171
|
+
note() { printf ' %s\n' "$*"; }
|
|
172
|
+
|
|
173
|
+
for entry in $resolved; do
|
|
174
|
+
source="${entry%%|*}"; entry="${entry#*|}"; fork="${entry%%|*}"; to="${entry#*|}"
|
|
175
|
+
printf '== %s (change since %s, tip %s)\n' "$source" "${fork:0:12}" "${to:0:12}"
|
|
176
|
+
|
|
177
|
+
# --- Check A: commits ----------------------------------------------------------------------------
|
|
178
|
+
if is_ancestor "$to" "$consolidation"; then
|
|
179
|
+
note "commits: tip is an ancestor of the consolidation, so commit pairing proves nothing; the content check decides."
|
|
180
|
+
else
|
|
181
|
+
[ -n "$cons_ids" ] || cons_ids="$(patch_ids "$cons_base..$consolidation" | cut -d' ' -f1)"
|
|
182
|
+
unmatched=""
|
|
183
|
+
while read -r patch_id left; do
|
|
184
|
+
[ -n "$patch_id" ] || continue
|
|
185
|
+
commits=$((commits + 1))
|
|
186
|
+
printf '%s\n' "$cons_ids" | grep -qxF "$patch_id" || unmatched="$unmatched $left"
|
|
187
|
+
done < <(patch_ids "$fork..$to")
|
|
188
|
+
for left in $unmatched; do
|
|
189
|
+
subject="$(git log -1 --format=%s "$left")"
|
|
190
|
+
status=""
|
|
191
|
+
while read -r range_line; do
|
|
192
|
+
abbrev="$(printf '%s' "$range_line" | sed -E 's/^ *([0-9]+|-+): +([0-9a-f]+|-+) .*/\2/')"
|
|
193
|
+
case "$left" in "$abbrev"*) status="$(printf '%s' "$range_line" | sed -E 's/^ *([0-9]+|-+): +([0-9a-f]+|-+) +([<>=!]) .*/\3/')"; break ;; esac
|
|
194
|
+
done < <(git range-diff --no-color --creation-factor="$CREATION_FACTOR" "$fork..$to" "$cons_base..$consolidation" | grep -E '^ *([0-9]+|-+): ')
|
|
195
|
+
if [ "$status" = "!" ]; then
|
|
196
|
+
if is_allowed_rewrite "$left"; then
|
|
197
|
+
note "allowed rewrite: ${left:0:12} ($subject)"
|
|
198
|
+
else
|
|
199
|
+
fail "$source: commit ${left:0:12} ($subject) survives with a different patch. Read 'git range-diff $fork..$to $cons_base..$consolidation'; if the difference is only conflict resolution, re-run with --allow-rewritten ${left:0:12}."
|
|
200
|
+
fi
|
|
201
|
+
elif [ -z "$status" ] || [ "$status" = "<" ]; then
|
|
202
|
+
fail "$source: commit ${left:0:12} ($subject) has no counterpart in the consolidation; it was dropped."
|
|
203
|
+
else
|
|
204
|
+
fail "$source: commit ${left:0:12} ($subject) has no patch-identical counterpart, but range-diff pairs it ('$status'). Read 'git range-diff $fork..$to $cons_base..$consolidation' and decide."
|
|
205
|
+
fi
|
|
206
|
+
done
|
|
207
|
+
fi
|
|
208
|
+
|
|
209
|
+
# --- Check B: content ---------------------------------------------------------------------------
|
|
210
|
+
local kind path dest fork_blob tip_blob cons_blob
|
|
211
|
+
while IFS="$(printf '\t')" read -r kind path dest; do
|
|
212
|
+
paths=$((paths + 1))
|
|
213
|
+
case "$kind" in R*|C*) ;; *) dest="$path" ;; esac
|
|
214
|
+
fork_blob="$(blob_of "$fork" "$path")"
|
|
215
|
+
tip_blob="$(blob_of "$to" "$dest")"
|
|
216
|
+
cons_blob="$(blob_of "$consolidation" "$dest")"
|
|
217
|
+
case "$kind" in
|
|
218
|
+
A*|R*|C*)
|
|
219
|
+
if [ -z "$cons_blob" ]; then fail "$source: added '$dest', which does not exist in the consolidation."
|
|
220
|
+
elif [ "$cons_blob" != "$tip_blob" ]; then note "review: '$dest' differs from the source tip (git diff $to $consolidation -- '$dest')"; fi
|
|
221
|
+
case "$kind" in R*) [ -z "$(blob_of "$consolidation" "$path")" ] || note "review: '$path' was renamed to '$dest' but still exists in the consolidation" ;; esac
|
|
222
|
+
;;
|
|
223
|
+
M*)
|
|
224
|
+
if [ -z "$cons_blob" ]; then fail "$source: modified '$path', which does not exist in the consolidation."
|
|
225
|
+
elif [ "$cons_blob" = "$fork_blob" ]; then fail "$source: modified '$path', but the consolidation still holds the pre-change version byte for byte; the change was lost."
|
|
226
|
+
elif [ "$cons_blob" != "$tip_blob" ]; then note "review: '$path' differs from the source tip (git diff $to $consolidation -- '$path')"; fi
|
|
227
|
+
;;
|
|
228
|
+
D*)
|
|
229
|
+
if [ -n "$cons_blob" ] && [ "$cons_blob" = "$fork_blob" ]; then fail "$source: deleted '$path', but the consolidation still holds the pre-change version; the deletion was lost."
|
|
230
|
+
elif [ -n "$cons_blob" ]; then note "review: '$path' was deleted by the source but exists, changed, in the consolidation"; fi
|
|
231
|
+
;;
|
|
232
|
+
*) note "unclassified change '$kind' to '$path'" ;;
|
|
233
|
+
esac
|
|
234
|
+
done < <(git diff --name-status --find-renames "$fork" "$to")
|
|
235
|
+
done
|
|
236
|
+
|
|
237
|
+
# --- Check C: specification ratchet -----------------------------------------------------------------
|
|
238
|
+
local expected actual
|
|
239
|
+
# Count from where the consolidation left the base, not the base tip: specification files the base gained
|
|
240
|
+
# or lost since then are not the consolidation's to account for.
|
|
241
|
+
expected="$(spec_count "$cons_base")"
|
|
242
|
+
for entry in $resolved; do
|
|
243
|
+
entry="${entry#*|}"; fork="${entry%%|*}"; to="${entry#*|}"
|
|
244
|
+
expected=$(( expected + $(spec_count "$to") - $(spec_count "$fork") ))
|
|
245
|
+
done
|
|
246
|
+
actual="$(spec_count "$consolidation")"
|
|
247
|
+
printf '== specification files: consolidation has %s, base plus sources account for %s\n' "$actual" "$expected"
|
|
248
|
+
if [ "$actual" -lt $(( expected - ALLOW_SPEC_DELTA )) ]; then
|
|
249
|
+
fail "specification ratchet: the consolidation carries $actual specification files where $expected are accounted for. Find the deleted ones; if two sources added the same path, re-run with --allow-spec-delta $(( expected - actual ))."
|
|
250
|
+
fi
|
|
251
|
+
|
|
252
|
+
printf '\nChecked %s source(s), %s path(s), %s commit(s) compared; %s finding(s).\n' "$#" "$paths" "$commits" "$failures"
|
|
253
|
+
if [ "$failures" -ne 0 ]; then
|
|
254
|
+
printf 'Every finding needs a disposition before this merges.\n'
|
|
255
|
+
exit 1
|
|
256
|
+
fi
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------------------------------------
|
|
260
|
+
self_test() {
|
|
261
|
+
local self result=0 output status
|
|
262
|
+
self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
|
|
263
|
+
SCRATCH="$(mktemp -d)"
|
|
264
|
+
trap 'rm -rf "$SCRATCH"' EXIT
|
|
265
|
+
local scratch="$SCRATCH"
|
|
266
|
+
export HOME="$scratch" GIT_CONFIG_NOSYSTEM=1
|
|
267
|
+
export GIT_AUTHOR_NAME=self-test GIT_AUTHOR_EMAIL=self-test@example.com
|
|
268
|
+
export GIT_COMMITTER_NAME=self-test GIT_COMMITTER_EMAIL=self-test@example.com
|
|
269
|
+
cd "$scratch"
|
|
270
|
+
git init -q -b main
|
|
271
|
+
commit() { git add -A && git commit -q -m "$1"; }
|
|
272
|
+
mkdir -p src for_shared/when_used
|
|
273
|
+
printf 'one\n' > src/shared.txt; printf 'spec\n' > for_shared/when_used/spec.cs; printf 'old\n' > src/obsolete.txt
|
|
274
|
+
commit "Add base"
|
|
275
|
+
|
|
276
|
+
git checkout -q -b first main
|
|
277
|
+
printf 'first\n' > src/first.txt; mkdir -p for_first/when_added; printf 'spec\n' > for_first/when_added/spec.cs
|
|
278
|
+
commit "Add first feature"
|
|
279
|
+
printf 'one\nfirst\n' > src/shared.txt; git rm -q src/obsolete.txt
|
|
280
|
+
commit "Extend shared and remove obsolete"
|
|
281
|
+
git checkout -q -b second main
|
|
282
|
+
printf 'second\n' > src/second.txt
|
|
283
|
+
commit "Add second feature"
|
|
284
|
+
|
|
285
|
+
expect() {
|
|
286
|
+
local name="$1" code="$2" needle="$3"; shift 3
|
|
287
|
+
set +e; output="$(cd "$scratch" && bash "$self" --base main "$@" 2>&1)"; status=$?; set -e
|
|
288
|
+
if [ "$status" -ne "$code" ]; then
|
|
289
|
+
printf 'SELF-TEST FAIL %s: exit %s, expected %s\n%s\n' "$name" "$status" "$code" "$output"; result=1
|
|
290
|
+
elif [ -n "$needle" ] && ! printf '%s' "$output" | grep -qF -- "$needle"; then
|
|
291
|
+
printf 'SELF-TEST FAIL %s: output lacks "%s"\n%s\n' "$name" "$needle" "$output"; result=1
|
|
292
|
+
else
|
|
293
|
+
printf 'self-test pass: %s\n' "$name"
|
|
294
|
+
fi
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
# A clean consolidation by true merges.
|
|
298
|
+
git checkout -q -b clean main
|
|
299
|
+
git merge -q --no-ff -m "Fold first" first; git merge -q --no-ff -m "Fold second" second
|
|
300
|
+
expect "clean consolidation passes" 0 "0 finding(s)" clean first second
|
|
301
|
+
|
|
302
|
+
# The base moves on after the consolidation branched: its new specification files are not the consolidation's.
|
|
303
|
+
git checkout -q main
|
|
304
|
+
mkdir -p for_later/when_added; printf 'spec\n' > for_later/when_added/spec.cs
|
|
305
|
+
commit "Add a later spec on main"
|
|
306
|
+
expect "base gaining a spec after the branch point passes" 0 "0 finding(s)" clean first second
|
|
307
|
+
expect "non-numeric spec delta cannot run" 2 "whole number" --allow-spec-delta abc clean first second
|
|
308
|
+
expect "leading-zero spec delta is decimal" 0 "0 finding(s)" --allow-spec-delta 08 clean first second
|
|
309
|
+
|
|
310
|
+
# A merge resolution that throws away a file, a modification, a deletion and a spec.
|
|
311
|
+
git checkout -q -b lossy main
|
|
312
|
+
git merge -q --no-ff -m "Fold second" second
|
|
313
|
+
git merge -q --no-ff --no-commit first >/dev/null 2>&1
|
|
314
|
+
git checkout -q main -- src/shared.txt src/obsolete.txt
|
|
315
|
+
git rm -q --cached for_first/when_added/spec.cs; rm -f for_first/when_added/spec.cs
|
|
316
|
+
git commit -q -m "Fold first"
|
|
317
|
+
expect "lost added spec found" 1 "added 'for_first/when_added/spec.cs'" lossy first second
|
|
318
|
+
expect "reverted modification found" 1 "modified 'src/shared.txt'" lossy first second
|
|
319
|
+
expect "lost deletion found" 1 "deleted 'src/obsolete.txt'" lossy first second
|
|
320
|
+
expect "spec ratchet trips" 1 "specification ratchet" lossy first second
|
|
321
|
+
|
|
322
|
+
# A cherry-picked consolidation that leaves one commit behind.
|
|
323
|
+
git checkout -q -b picked main
|
|
324
|
+
git cherry-pick -x first~1 >/dev/null
|
|
325
|
+
expect "dropped commit found" 1 "has no counterpart" picked first
|
|
326
|
+
|
|
327
|
+
# A re-land: landed, reverted, restored by reverting the revert.
|
|
328
|
+
# A restoration whose cherry-pick needed a different patch, then accepted through a recorded disposition.
|
|
329
|
+
local first_commit; first_commit="$(git rev-parse first~1)"
|
|
330
|
+
git checkout -q -b rewritten main
|
|
331
|
+
git cherry-pick -n "$first_commit" >/dev/null
|
|
332
|
+
printf 'first, resolved\n' > src/first.txt
|
|
333
|
+
commit "Restore first feature"
|
|
334
|
+
expect "rewritten commit found" 1 "different patch" rewritten "$first_commit^..$first_commit"
|
|
335
|
+
git commit -q --allow-empty -m "Accept the resolved first feature" -m "Consolidation-Allow-Rewritten: ${first_commit:0:12}"
|
|
336
|
+
expect "recorded disposition accepts the rewrite" 0 "allowed rewrite" rewritten "$first_commit^..$first_commit"
|
|
337
|
+
|
|
338
|
+
git checkout -q main
|
|
339
|
+
git merge -q --no-ff -m "Land first" first
|
|
340
|
+
local landed; landed="$(git rev-parse HEAD)"
|
|
341
|
+
git revert -m 1 --no-edit "$landed" >/dev/null
|
|
342
|
+
local revert; revert="$(git rev-parse HEAD)"
|
|
343
|
+
git checkout -q -b reland main
|
|
344
|
+
git revert --no-edit "$revert" >/dev/null
|
|
345
|
+
expect "clean re-land passes" 0 "0 finding(s)" reland "$landed^1..$landed^2"
|
|
346
|
+
expect "landed source without a range cannot run" 2 "already contained" reland first
|
|
347
|
+
expect "discover finds the re-land" 0 "$landed^1..$landed^2" --discover reland
|
|
348
|
+
git checkout -q -b lossy-reland main
|
|
349
|
+
git revert --no-edit --no-commit "$revert"
|
|
350
|
+
git rm -q -r --cached for_first; rm -rf for_first
|
|
351
|
+
git commit -q -m "Re-land first"
|
|
352
|
+
expect "re-land that drops a spec found" 1 "added 'for_first/when_added/spec.cs'" lossy-reland "$landed^1..$landed^2"
|
|
353
|
+
|
|
354
|
+
expect "discover finds folded tips" 0 "$(git rev-parse second)" --discover clean
|
|
355
|
+
expect "unknown ref cannot run" 2 "cannot resolve" clean no-such-ref
|
|
356
|
+
|
|
357
|
+
if [ "$result" -ne 0 ]; then printf 'Self-test failed: a planted defect was not reported as expected.\n'; exit 1; fi
|
|
358
|
+
printf 'Self-test passed: every planted defect was found.\n'
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
case "$MODE" in
|
|
362
|
+
discover) discover "$@" ;;
|
|
363
|
+
self-test) self_test ;;
|
|
364
|
+
*) verify "$@" ;;
|
|
365
|
+
esac
|
|
@@ -9,6 +9,11 @@ For `source api`, webhook, poll and other record sources, a first record of a ty
|
|
|
9
9
|
|
|
10
10
|
This applies independently to each child identity in its parent's previous collection and each nested record: a missing/null previous nested value is new, and every child of a new parent is new. Expression guards still evaluate the mapped current record on first sight; maps and unguarded appends keep their meaning. These record fixtures are not valid for `source events`: use typed events instead (automations-and-translations [public event captures](../../cratis-screenplay-automations-and-translations/references/public-events-and-direction.md)).
|
|
11
11
|
|
|
12
|
+
A capture **over events** carries a public event's list across the boundary two ways (decision 0098, no new ESM version): `lines = $.lines`
|
|
13
|
+
copies the whole list into a list property of the same shape (matched by property name), and `children lines identified by sku` with an
|
|
14
|
+
`append ... when added` makes one private event per element, so a repeated delivery appends nothing and one with an extra line appends only
|
|
15
|
+
that line. `nested <object>` scopes into an object the same way.
|
|
16
|
+
|
|
12
17
|
The complete model separates a new observation, an update and an unchanged repeat:
|
|
13
18
|
|
|
14
19
|
```screenplay
|
|
@@ -68,8 +68,9 @@ to the routed scope. This does not make command `reads` protected.
|
|
|
68
68
|
computed expressions parse but block binding (`PLAY0268`).
|
|
69
69
|
- **`for <identifier>`** on an indented line names the event source the event is
|
|
70
70
|
appended to. At most one per `produces`. To bind, it must name the command's
|
|
71
|
-
`identifier` property (`PLAY0273` otherwise; a `generated identifier` command, ESM v7, may name another required scalar property)
|
|
72
|
-
|
|
71
|
+
`identifier` property (`PLAY0273` otherwise; a `generated identifier` command, ESM v7, may name another required scalar property). From ESM v30 (decision 0098)
|
|
72
|
+
a command may append to several event sources: each `produces` may name any required scalar command property, and the events of one accepted command are appended
|
|
73
|
+
together. Optional and collection properties still cannot be destinations. A model using it selects v30; state each destination with `for` on the matching `then` event.
|
|
73
74
|
- **`tag`** lines apply literal tags to this append. Tags also exist on the
|
|
74
75
|
`event` declaration, where they apply to every append of that type.
|
|
75
76
|
- **Several unconditional `produces` blocks** are allowed — that is co-production,
|
package/package/corpus/skills/cratis-screenplay-command-surface/references/validate-rules.md
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
| `matches "<regex>"` | Portable linear dialect; any substring unless anchored `^…$`; invalid syntax PLAY0367, refused constructs PLAY0699 |
|
|
14
14
|
| `contains "<text>"` / `not contains "<text>"` | Ordinal substring presence/absence on scalar non-enum text |
|
|
15
15
|
| `starts with "<text>"` / `ends with "<text>"` | Ordinal prefix/suffix; quoted literal operands, not conditions |
|
|
16
|
+
| `decimals <= <n>` | `price decimals <= 2`; a `Decimal` property or concept only, `<n>` a whole number 0 to 28, trailing zeros do not count; ESM v29 (decision 0098) |
|
|
16
17
|
| `all > <v>` / `all >= <v>` | `lines.quantity all > 0` |
|
|
17
18
|
| `rule <Name>` | `orgNumber rule BeAValidOrganizationNumber` |
|
|
18
19
|
| `checksum luhn|mod11|mod97` | String concept `validate checksum luhn`; validates supplied/stored values too |
|
|
@@ -35,7 +35,8 @@ A join cannot declare its own key or trigger removal — that is
|
|
|
35
35
|
| `increment <prop>` | +1, expressing *modifying state* |
|
|
36
36
|
| `decrement <prop>` | −1 |
|
|
37
37
|
| `add <prop> by <expr>` | += expression |
|
|
38
|
-
| `
|
|
38
|
+
| `add <prop> by <a> * <b>` | += the exact product of two values (ESM v29, decision 0098); only after `by`, one `*` |
|
|
39
|
+
| `subtract <prop> by <expr>` | −= expression (also `by <a> * <b>`) |
|
|
39
40
|
|
|
40
41
|
`count` and `increment` behave identically; the difference is intent. The target
|
|
41
42
|
must be numeric.
|
package/package/corpus/skills/cratis-screenplay-specifications/references/generated-fixtures.md
CHANGED
|
@@ -66,6 +66,10 @@ specification RegistersWithPinnedValues
|
|
|
66
66
|
differences in response field order. An absent optional source yields `Null`; arrays compare in order.
|
|
67
67
|
- `then returns` counts as a success outcome but does not relax event comparison: a command specification with
|
|
68
68
|
no expected events still asserts that no facts were produced.
|
|
69
|
+
- A command that a **reaction** invokes takes its generated values from `given generated <Command>.<property> = <value>` lines
|
|
70
|
+
before the `when` (ESM v30, decision 0098). Each invocation takes the next value pinned for that
|
|
71
|
+
command and property in authored order; two invocations need two lines. The property must be `generated`; the value is a
|
|
72
|
+
concrete literal.
|
|
69
73
|
- If execution reaches generation without every generated value supplied, the run is `Unsupported(IdentityAllocation)` and
|
|
70
74
|
the specification never passes; nothing is invented. A denial or validation failure happens before generation and
|
|
71
75
|
keeps its own outcome.
|
package/package/corpus/skills/cratis-screenplay-specifications/references/reactions-and-cascades.md
CHANGED
|
@@ -59,6 +59,13 @@ module Collections
|
|
|
59
59
|
or mixed effects (`-037`). Single-hop reaction append specs render; deeper appends and
|
|
60
60
|
command specs cascading into reactions remain `-011`. Keep refused scope as gap-fill against this contract: `cratis-screenplay-render-and-gap-fill`.
|
|
61
61
|
|
|
62
|
+
### A command specification states the public event its slice publishes
|
|
63
|
+
|
|
64
|
+
When an outbound Translate slice publishes an event from the private events a command produces, the command's specification
|
|
65
|
+
lists the public event after the private ones (`then ProductPriceChanged` after `then ProductPriceSet`). `PLAY0285` follows the
|
|
66
|
+
outbound projection (decision 0098); the accepted path is proven where the command is. An exact fact count
|
|
67
|
+
fails without it.
|
|
68
|
+
|
|
62
69
|
### Version skew: cascades and the false `PLAY0285`
|
|
63
70
|
|
|
64
71
|
`PLAY0285` (a specification's expected event contradicts every producer of its
|
|
@@ -16,6 +16,10 @@ exact language/semantic pairs; binding, reference execution and rendering are se
|
|
|
16
16
|
| v26 | Chronological command Date/DateTime comparisons | Same-primitive `==`, `!=`, `>`, `>=`, `<`, `<=`; DateTime UTC ticks and pre-rule offset validation | v26 admitted; Host/executor run it; renderer supports admitted shapes with remaining STAGE-ESM-046 refusals; legacy engines refuse -046 |
|
|
17
17
|
| v27 | A read model with two or more `key` marks | Untyped projection `key` blocks, equality-filter lists, single-instance `by` blocks, per-part fixtures | v27 admitted; Host runs it, renderer refuses STAGE-ESM-048/-047, executor refuses specs over it |
|
|
18
18
|
| v28 | `==`/`!=` between Uuid values in command `require` and `produces when` | Uuid properties, Uuid concepts and identities, canonical literals; protected read paths in `require` only | v28 refused STAGE-ESM-016 |
|
|
19
|
+
| v29 | `decimals <= N`; `add`/`subtract` by `a * b`; a concept mapped to another concept over the same primitive (decision 0098) | Decimal-places rule on a Decimal concept or property; the exact decimal product; the conversion is admitted only when the target declares no rule or compliance the source does not, never for enums, composites or optional into required | v29 refused until Stage admits it |
|
|
20
|
+
| v30 | A command `produces ... for <required scalar property>` (several event sources); a specification `given generated <Command>.<property> = <value>` (decision 0098) | Several sources in one accepted command; pinned generated values for reaction-invoked commands, taken in authored order | v30 refused until Stage admits it |
|
|
21
|
+
|
|
22
|
+
Constructs of decision 0098 with no executable-model content need no version: application-level toolbar buttons in the layout's `navigation topbar` region (`button <id> title "..." slice <Slice>` with an optional indented `authorize <Policy>`), a command specification that states the public event its outbound Translate slice publishes (no more false `PLAY0285`), and a capture over events that copies a list (`lines = $.lines`) or fans out with `children lines identified by sku`. Check `screenplay --version` against the release that admitted v29 and v30 before relying on them.
|
|
19
23
|
|
|
20
24
|
Requires Screenplay 4.181.0 or later (`screenplay --version`) for atomic batches; older compilers cannot admit the preamble.
|
|
21
25
|
|
package/package/corpus/skills/cratis-screenplay-ui-composition/references/application-shell.md
CHANGED
|
@@ -166,6 +166,25 @@ own.
|
|
|
166
166
|
- `item` entries (`action`, `navigate to`, `dialog`) stay valid beside buttons. Use `button` to start a slice, and
|
|
167
167
|
`item ... navigate to` to go to a screen.
|
|
168
168
|
|
|
169
|
+
### Application-level buttons
|
|
170
|
+
|
|
171
|
+
A layout's `navigation topbar` region takes the same `button` line once for the whole application, so an action stays at hand
|
|
172
|
+
whichever page the user is on. An indented `authorize <Policy>` line under a button limits who sees it. A module's
|
|
173
|
+
`navigation topbar extends|overrides|inherits` governs the buttons as it does the items. A button in `navigation sidebar` is
|
|
174
|
+
`PLAY0741`. The slice rules above (`PLAY0761`-`PLAY0765`) apply unchanged (decision 0098).
|
|
175
|
+
|
|
176
|
+
```text
|
|
177
|
+
layout AppShell
|
|
178
|
+
category application
|
|
179
|
+
topbar
|
|
180
|
+
navigation contributes Navigation
|
|
181
|
+
content
|
|
182
|
+
navigation topbar
|
|
183
|
+
item shop title "Shop" target Shop
|
|
184
|
+
button newProduct title "New product" slice Merchandising.Products.DraftProduct emphasis primary icon plus
|
|
185
|
+
authorize CanCurateCatalog
|
|
186
|
+
```
|
|
187
|
+
|
|
169
188
|
⚠️ When a screen uses a `template`, put the `toolbar` inside a slot body (`body` above). Stage refuses a screen that
|
|
170
189
|
mixes a top-level `template` directive with other top-level directives.
|
|
171
190
|
|
|
@@ -982,6 +982,7 @@
|
|
|
982
982
|
"id": "cratis/engineering/core",
|
|
983
983
|
"availableTargets": [
|
|
984
984
|
"cratis-documentation-writing",
|
|
985
|
+
"cratis-engineering-consolidation-guard",
|
|
985
986
|
"cratis-engineering-decision-record",
|
|
986
987
|
"cratis-engineering-docs-authoring",
|
|
987
988
|
"cratis-engineering-effect-boundaries",
|