diff --git a/README.ko.md b/README.ko.md index b51eaf2..daeee72 100644 --- a/README.ko.md +++ b/README.ko.md @@ -93,7 +93,8 @@ $EDITOR .sdlc/config.md # build, test, lint, run, e2e, 프로젝트마다 다른 값은 `.sdlc/config.md` 한 곳에만 둡니다. `build`, `test`, `lint`, `run`, `e2e` 명령, 화면이나 API를 다룰 `qa` 도구, `human_gates`(기본값 `intent`, 모든 plan을 직접 승인하려면 `plan` 추가, 위험 플래그가 있는 plan은 -설정과 상관없이 사람이 승인), `knowledge` 폴더입니다. `.sdlc/`는 +설정과 상관없이 사람이 승인), `knowledge` 폴더, `repos`(리뷰가 묶을 저장소. +프로젝트 루트 아래 저장소가 많으면 나열하고, 비우면 루트 아래 전부)입니다. `.sdlc/`는 `.git/info/exclude`로 git에서 빠집니다. 팀이 배운 것을 공유하려면 `knowledge:`를 git이 추적하는 폴더로 지정하세요. diff --git a/README.md b/README.md index 2d88bce..27e7fcd 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,9 @@ Closed features move to `.sdlc/archive/`. Verified facts move to the `.sdlc/config.md` is the only project-specific file: the `build`, `test`, `lint`, `run` and `e2e` commands, the `qa` tool that drives screens or APIs, `human_gates` (default `intent`; add `plan` to approve every plan yourself — a -plan with a risk flag always asks), and the `knowledge` folder. `.sdlc/` stays +plan with a risk flag always asks), the `knowledge` folder, and `repos` (the +repositories a review binds — list them when the project root holds many; +empty means every one under it). `.sdlc/` stays out of git through `.git/info/exclude`; point `knowledge:` at a tracked folder to share what the team learns. diff --git a/SKILL.md b/SKILL.md index d031442..6fef882 100644 --- a/SKILL.md +++ b/SKILL.md @@ -55,7 +55,9 @@ finds `.git`. subagents, open a new session with only the role file and paths; if that is impossible too, write `no independent review: ` in the artifact. Every dispatch states the goal, input paths, what it may write, - the config.md commands, the output format, and when to stop. + the config.md commands, the output format, and when to stop. A role that + cannot write the feature folder writes to a temporary folder and reports + its path; you copy the files in. 5. **Proof, not claims.** Every PASS cites what was run or observed and its log. No environment to run it in is BLOCKED, never PASS; a unit test never stands in for the real interface. A bug fix needs a test that fails before @@ -74,7 +76,9 @@ finds `.git`. `.sdlc/config.md` holds every project value: `build`, `test`, `lint`, `run`, `e2e`, `qa` (the tool that drives screens or APIs), `human_gates` (default -`intent`) and `knowledge` (the durable knowledge folder). Records are working +`intent`), `knowledge` (the durable knowledge folder) and `repos` (the +repositories a review binds; empty means every one under the project root). +Records are working knowledge, not source: `init` keeps `.sdlc/` out of git through `.git/info/exclude`, changing no tracked file. Closed features move to `.sdlc/archive//`. Scouts read `knowledge` first; Evidence adds what diff --git a/VERSION b/VERSION index 7ec1d6d..ccbccc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.0 +2.2.0 diff --git a/log/changelog-v2.2.0.md b/log/changelog-v2.2.0.md new file mode 100644 index 0000000..55a0ffc --- /dev/null +++ b/log/changelog-v2.2.0.md @@ -0,0 +1,62 @@ +# v2.2.0 — friction from a large monorepo + +Six fixes from using the kit on a project root that holds hundreds of git +worktrees, where a human approves every commit. + +## Changes + +- **`repos:` in config.md.** A space-separated list of repository top + folders, relative to the project root (`.` for the repository holding the + root; no spaces inside a path), limits which repositories a review PASS + binds. Empty or absent binds every repository under the root, as before, + but `review` now also runs one `git status` per repository to find + uncommitted changes, so a root with many repositories is slower than in + v2.1.0: list them in `repos:` to bound it. On a root with hundreds of + worktrees the full scan took over 30 s per `review`. An entry that is not a + folder under the root (`../x`, or a symlink resolving outside it), not in a + repository, or a plain folder inside one is refused by name, and a Commit + outside the list says the list limited the review. +- **Review before commit.** `sdlc.sh review` on uncommitted changes binds the + PASS to each such repository's HEAD and the tree its working tree would + commit (untracked, unignored files included; the store and untracked + repositories nested inside it excluded, so a root full of worktrees is not + dirty and `review` prints no git warning per nested repository). `score` + then accepts, with no new round, a Commit that is or descends from that HEAD, + holds the reviewed tree inside its Commit path, and changes nothing outside + the path that the reviewed tree lacks. Anything else needs a new round: + reverting or stashing the change and scoring the reviewed HEAD itself, an + amended or rebased commit, or any other content. Only repositories named on + Commit lines are compared. Reviewing a commit works as before: uncommitted + changes outside every Commit path still do not block it. Renames count as + a deletion plus an addition, so an unreviewed rename outside the path is + refused too. The store stays out of the tree whether it is untracked, + ignored or partly tracked. If git cannot read the working tree (an + unreadable file, say), `review` prints one line saying why and binds HEAD + alone, as v2.1.0 did. The temporary index used for the tree is removed on + interrupt too. +- **Cell counts.** A Scenarios row in PLAN.md or a Results row in evidence.md + with more cells than its header is reported as `S1: row has 6 cells, header + has 5` instead of a misleading log error. A row with fewer cells (GFM allows + it, e.g. `| S1 | BLOCKED | | no env |`) reads as in v2.1.0. `\|` still + counts as text. +- **Repository paths.** A Commit or Delivered path that is not a folder under + the project root says so, quoted, with the line format + ` `, instead of `repository missing`. +- **QA sandbox.** QA that cannot write the feature folder writes evidence.md + and `logs/` to a temporary folder and reports its path; the coordinator + copies them in (roles/qa.md, SKILL.md rule 4). +- **Real-use evidence in Intent.** Before logs, error records, reports or + metrics count as evidence, Intent checks their source shows real use, not + tests, automation or synthetic traffic. + +Selftest adds eight regression groups (`repos`, `cells`, `paths`, +`uncommitted`, `nested`, `interrupt`, `scoped`, `fingerprint`); each fails +without its fix for the reported reason. `interrupt` and the unreadable-file +check in `fingerprint` skip on Git Bash, whose signals and permissions are +untested. + +## Compatibility + +Existing records stay valid. A review recorded by v2.1.0 binds HEADs as +before; a `.state` written by v2.2.0 for an uncommitted review stores +`+` for that repository, which v2.1.0 cannot read as a match. diff --git a/phases/1-intent.md b/phases/1-intent.md index 730816a..05e3748 100644 --- a/phases/1-intent.md +++ b/phases/1-intent.md @@ -14,7 +14,10 @@ risks in that understanding — confirmed before anyone explores deeply. success criteria `O1..On`, each observable so a scenario can check it. - **Risks in this intent** — ambiguity, a claim the code or data already contradicts (with the evidence), a policy the request would change and - who owns it, work that needs authority (rule 3). + who owns it, work that needs authority (rule 3). Before logs, error + records, reports or metrics count as evidence, check their source (the + user, agent or job behind them) shows real use, not tests, automation + or synthetic traffic. - **Blocking questions** — only those whose wrong answer changes what gets built or leaves the authorized scope. Ask them one at a time and record each answer in place as `— resolved: `. Other diff --git a/phases/3-build.md b/phases/3-build.md index c099be9..0b68df0 100644 --- a/phases/3-build.md +++ b/phases/3-build.md @@ -29,12 +29,22 @@ Changes: Commit the change locally (the project's rules decide the message), then dispatch the reviewer (`roles/reviewer.md`, diff mode) with intent.md, -PLAN.md, `refs/` and the commit. It appends a round to `review.md` ending in -`VERDICT: PASS` or `VERDICT: FAIL`. Record it with `sdlc.sh review -pass|fail ""`: the script reads the verdict of the newest round -(every recording needs a new one) and binds a PASS to the HEAD of every -repository under the project root. Code changed after a PASS needs a new -commit and a new round. +PLAN.md, `refs/` and the commit. Where every commit needs the human's +approval, review the uncommitted change instead. The reviewer appends a round +to `review.md` ending in `VERDICT: PASS` or `VERDICT: FAIL`. Record it with +`sdlc.sh review pass|fail ""`: the script reads the verdict +of the newest round (every recording needs a new one) and binds a PASS to the +HEAD of every repository under the project root (or in config `repos:`), and +to the working tree of any with uncommitted changes: tracked and untracked +files that are not ignored, leaving out the store and untracked repositories +nested inside it (if git cannot read that tree, the PASS binds HEAD alone +and says so). `score` and `close` compare only the repositories named on +evidence `Commit` lines. Each Commit must be the reviewed HEAD or descend +from it, hold the reviewed working tree inside its Commit path, and change +nothing outside that path that the reviewed tree does not hold; uncommitted +changes outside every Commit path do not count. Reverting or stashing the +change, amending or rebasing needs a new round, as does any code changed +after a PASS. FAIL → fix the blocking findings, commit, review again. The third FAIL stops the loop: show the human the trail; only `sdlc.sh approve plan --human` resumes it. Then phase 4. diff --git a/roles/qa.md b/roles/qa.md index 12c43f0..d18de0e 100644 --- a/roles/qa.md +++ b/roles/qa.md @@ -28,7 +28,10 @@ Inputs: intent.md, PLAN.md, `refs/`, config.md, and the commit(s) to verify. 7. Each FAIL gets a cause, with evidence: `plan` if the plan was wrong or missed something, `code` if the plan was right and the code is not. -Fill `evidence.md` from `templates/evidence.md` and stop. Signs you did not +Fill `evidence.md` from `templates/evidence.md` and stop. If the feature +folder is not writable from your sandbox (a symlink outside the allowed +roots, say), write `evidence.md` and `logs/` to a temporary folder and report +its path; the coordinator copies them in. Signs you did not really run it: a PASS without a log, every scenario through one caller, a write without read-back, a role checked only by being refused. diff --git a/roles/reviewer.md b/roles/reviewer.md index 20f5763..e62b6d8 100644 --- a/roles/reviewer.md +++ b/roles/reviewer.md @@ -13,7 +13,8 @@ Inputs: intent.md, PLAN.md, `refs/`. Blocking: ## Diff mode (phase 3) -Inputs: intent.md, PLAN.md, `refs/`, the commit. Read every changed file. +Inputs: intent.md, PLAN.md, `refs/`, the commit or uncommitted diff +(untracked files included). Read every changed file. Blocking: - it does not do what PLAN.md says, or does more; - a side effect PLAN.md names is left unhandled; input that callers send diff --git a/sdlc.sh b/sdlc.sh index 4d596a2..0e6ada5 100755 --- a/sdlc.sh +++ b/sdlc.sh @@ -47,6 +47,7 @@ config() { gates=$(printf '%s' "$gates" | awk '{gsub(/,/," ");for(i=1;i<=NF;i++){v=tolower($i);if(v!="intent"&&v!="plan"){bad=$i;break}if(!seen[v]++)out=out (out==""?"":" ") v}}END{if(bad!="")print "!" bad;else print out}') case "$gates" in '!'*) err "unknown human_gates value '${gates#!}' (write intent, plan, or leave it empty): the human approves intent and plan"; gates='intent plan';; esac knowledge=$(value "$STORE/config.md" knowledge) + bound=$(value "$STORE/config.md" repos) if [ -z "$knowledge" ]; then if [ -d "$STORE/memory" ]; then knowledge=.sdlc/memory; else knowledge=.sdlc/knowledge; fi fi @@ -98,12 +99,12 @@ parse() { function trim(s){sub(/^[ \t]+/,"",s);sub(/[ \t]+$/,"",s);return s} function problem(s){print "X\034" s} function question(){if(q!=""){l=tolower(trim(q));if(l!="none"&&l!="n/a"&&l!~/^resolved:/&&l!~/— resolved:/&&l!~/-- resolved:/)print "Q\034" q;q=""}} - function fields(s, c,j){for(j in a)delete a[j];nfields=1;for(j=1;j<=length(s);j++){c=substr(s,j,1);if(c=="\\"&&substr(s,j+1,1)=="|"){a[nfields]=a[nfields] "|";j++}else if(c=="|")nfields++;else a[nfields]=a[nfields] c}for(j=1;j<=nfields;j++)a[j]=trim(a[j]);return a[2]} + function fields(s, c,j){for(j in a)delete a[j];nfields=1;for(j=1;j<=length(s);j++){c=substr(s,j,1);if(c=="\\"&&substr(s,j+1,1)=="|"){a[nfields]=a[nfields] "|";j++}else if(c=="|")nfields++;else a[nfields]=a[nfields] c}for(j=1;j<=nfields;j++)a[j]=trim(a[j]);cells=nfields-1-(a[nfields]=="");return a[2]} { if(uncomment())next sub(/^[ \t]*\*[ \t]+/,"- ");gsub(/[`*]/,"");raw=$0;line=trim(raw);low=tolower(line) if(mode=="plan"&&low~/|/)problem("PLAN.md contains template placeholder") - if(low~/^## /){question();section=substr(low,4);next} + if(low~/^## /){question();section=substr(low,4);header=0;next} if(mode=="intent"){ if(section~/^understood as/&&line~/^- O[0-9]+[ \t]*:/){id=line;sub(/^[^-]*- /,"",id);sub(/[ \t]*:.*/,"",id);text=line;sub(/^[^:]*:[ \t]*/,"",text);if(text!=""&&text!~/^64){if(kind=="C")problem("invalid Commit: " hash)}else{print kind "\034" tolower(hash) "\034" repo;if(kind=="C")commits++}} if(line!~/^\|/)next - id=fields(line);if(tolower(id)=="id"||line~/^[| :\t-]+$/)next + id=fields(line);if(tolower(id)=="id"){header=cells;next}if(line~/^[| :\t-]+$/)next + if(header&&cells>header&&((mode=="plan"&§ion~/^scenarios/)||(mode=="evidence"&§ion~/^results/)))problem(id ": row has " cells " cells, header has " header) if(mode=="plan"&§ion~/^scenarios/){ count++;if(id!~/^S[0-9]+$/)problem("invalid scenario ID: " id);if(seen[id]++)problem("duplicate scenario: " id) must=tolower(a[6]);if(must=="yes"||must=="y"||must=="true"||must=="must")must=1;else if(must=="no"||must=="n"||must=="false"||must=="")must=0;else{problem("invalid Must: " a[6]);must=0} @@ -281,36 +283,120 @@ validate_evidence() { fi done } -# HEAD and toplevel of the project root's repository and of every repository inside the root, -# tab-separated pairs on one line: a review PASS binds them all. +# HEAD and toplevel of the project root's repository and of every repository inside the root (or only +# of config repos:), tab-separated pairs on one line: a review PASS binds them all. A repository with +# uncommitted changes records HEAD+tree, the tree its working tree would commit, so a commit-gated +# project can review first and commit that exact change after. review_heads() { - { git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || : - { find "$ROOT" \( -name node_modules -o -name .sdlc \) -prune -o -name .git -print -prune 2>/dev/null || :; } | while IFS= read -r g; do dirname "$g"; done - } | while IFS= read -r dir; do - top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) && head=$(git -C "$top" rev-parse -q --verify HEAD 2>/dev/null) && printf '%s\t%s\n' "$head" "$top" || : - done | sort -u | awk '{printf "%s%s", (NR>1 ? "\t" : ""), $0}' + root_repo + if [ -n "$bound" ]; then + read -r -a list <<< "$bound" + proot=$(cd "$ROOT" && pwd -P) + for path in "${list[@]}"; do + # "." names the repository holding the project root; any other entry is a repository's own top folder. + repo_path "$path"; preal=$(cd "$REPO" 2>/dev/null && pwd -P) || preal= + case "$preal/" in "$proot"/*) :;; *) die "repos: '$path' is not a directory under the project root";; esac + top=$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null) || die "repos: '$path' is not in a git repository" + [ "$preal" = "$proot" ] || [ "$preal" = "$(cd "$top" && pwd -P)" ] || die "repos: '$path' is not the top folder of a git repository (it is inside $top)" + done + fi + { if [ -n "$bound" ]; then for path in "${list[@]}"; do printf '%s\n' "$ROOT/$path"; done + else + git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || : + { find "$ROOT" \( -name node_modules -o -name .sdlc \) -prune -o -name .git -print -prune 2>/dev/null || :; } | while IFS= read -r g; do dirname "$g"; done + fi + } | { seen=$'\n'; while IFS= read -r dir; do + top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) && head=$(git -C "$top" rev-parse -q --verify HEAD 2>/dev/null) || continue + # The root's repository comes from rev-parse and from find: inspect it once. + case "$seen" in *$'\n'"$top"$'\n'*) continue;; esac; seen="$seen$top"$'\n' + changes "$top" "$top" + if [ -n "$dirty" ]; then + rc=0; tree=$(worktree_tree "$top") || rc=$? + case "$rc" in 0) head="$head+$tree";; 3) :;; *) exit 1;; esac + fi + printf '%s\t%s\n' "$head" "$top" + done; } | sort -u | awk '{printf "%s%s", (NR>1 ? "\t" : ""), $0}' +} +# The tree id that git add -A would commit in repository $1, store and untracked nested repositories +# excluded, built in a copy of its index. The store leaves the copy after add, so it stays out whether it +# is untracked, ignored or partly tracked (git add refuses an exclude naming an ignored path). When git add +# cannot read the working tree, exit 3 after one line saying why: the caller binds HEAD alone, as v2.1.0 +# did. Called in a command substitution, so its traps are its own; tmp is global for the EXIT trap, which +# runs after the function returns. +worktree_tree() { + local gitdir tree path cause specs=(.) + for path in "${nested[@]}"; do specs[${#specs[@]}]=":(top,exclude,literal)$path"; done + tmp=; trap 'rm -rf "$tmp"' EXIT; trap 'exit 130' INT TERM HUP + tmp=$(mktemp -d) || die 'cannot create a temporary folder' + gitdir=$(git -C "$1" rev-parse --absolute-git-dir) || die "not a git repository: $1" + if [ -f "$gitdir/index" ]; then cp -p "$gitdir/index" "$tmp/index"; else GIT_INDEX_FILE=$tmp/index git -C "$1" read-tree HEAD; fi || die "cannot copy the index: $1" + cause=$(GIT_INDEX_FILE=$tmp/index git -C "$1" add -A -- "${specs[@]}" 2>&1 >/dev/null) || + { err "cannot fingerprint the uncommitted changes in $1 (${cause%%$'\n'*}): the PASS binds its HEAD only"; exit 3; } + for path in "${store_paths[@]}"; do + GIT_INDEX_FILE=$tmp/index git -C "$1" rm -r -q -f --cached --ignore-unmatch -- ":(top,literal)$path" >/dev/null || die "cannot fingerprint the working tree: $1" + done + tree=$(GIT_INDEX_FILE=$tmp/index git -C "$1" write-tree) || die "cannot fingerprint the working tree: $1" + printf '%s' "$tree" +} +# Uncommitted changes in repository $1, as git status run in $2 lists them, into dirty. The store is left +# out, and so is every untracked nested repository (a worktree or clone inside it, listed by ls-files +# with a trailing slash): it is never part of a commit, and git add would stage it as a gitlink. Its +# path goes to nested and excludes. Tracked gitlinks are not untracked and stay in. +changes() { + local path + store_excludes "$1"; nested=() + dirty=$(git -C "$2" status --porcelain -- . "${excludes[@]}") || die "cannot inspect working tree: $2" + [ -n "$dirty" ] || return 0 + while IFS= read -r -d '' path; do + case "$path" in */) nested[${#nested[@]}]=${path%/}; excludes[${#excludes[@]}]=":(top,exclude,literal)${path%/}";; esac + done < <(git -C "$1" ls-files -o --exclude-standard -z) + [ "${#nested[@]}" -eq 0 ] || dirty=$(git -C "$2" status --porcelain -- . "${excludes[@]}") || die "cannot inspect working tree: $2" +} +root_repo() { + roottop=$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null) || roottop= + rootprefix=$(git -C "$ROOT" rev-parse --show-prefix 2>/dev/null) || rootprefix= +} +# Pathspecs that keep the store out of repository $1's status and fingerprint. +# store_paths holds the same stores relative to the toplevel. +store_excludes() { + local path + excludes=(':(exclude).sdlc'); store_paths=(.sdlc) + [ "$1" != "$roottop" ] || store_paths[${#store_paths[@]}]=${rootprefix}.sdlc + ptop=$(cd "$1" && pwd -P); physical_store=$(cd "$STORE" && pwd -P) + case "$physical_store" in "$ptop"/*) store_paths[${#store_paths[@]}]=${physical_store#"$ptop"/};; esac + for path in "${store_paths[@]:1}"; do excludes[${#excludes[@]}]=":(top,exclude,literal)$path"; done } repo_path() { case "$1" in /*|[A-Za-z]:*|\<*) die "repo path must be relative: $1";; esac; REPO=$ROOT/$1; } +# $2 names the evidence line (Commit or Delivered) whose repository path is $1. +repo_dir() { repo_path "$1"; [ -d "$REPO" ] || die "$2 path '$1' is not a directory under the project root: write '- $2: '"; } validate_commits() { # Repositories are compared by git's own toplevel path, so any path into a repository is bound to the review. # A Commit repository the last review did not see (outside the project root) is refused. - roottop=$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null) || roottop= - rootprefix=$(git -C "$ROOT" rev-parse --show-prefix 2>/dev/null) || rootprefix= + root_repo for ((i=0;i<${#commits[@]};i++)); do - repo_path "${repos[i]}" - [ -d "$REPO" ] || die "repository missing: ${repos[i]}" + repo_dir "${repos[i]}" Commit full=$(git -C "$REPO" rev-parse --verify -q "${commits[i]}^{commit}") || die "unknown commit: ${commits[i]}" head=$(git -C "$REPO" rev-parse HEAD) || die "not a git repository: ${repos[i]}" [ "$head" = "$full" ] || die "HEAD differs from Commit: ${repos[i]}" top=$(git -C "$REPO" rev-parse --show-toplevel) || die "not a git repository: ${repos[i]}" reviewed=$(printf '%s' "$heads" | awk -F'\t' -v top="$top" '{for(j=1;j' 2; feature "$1"; mutable; gate_plan || die "$greason" @@ -457,10 +543,10 @@ case "$command" in delivery_authority "$etarget" || die "$dreason" filled "$checked" && filled "$output" || die 'Delivery Checked with and Output required' for ((i=0;i<${#commits[@]};i++)); do - repo_path "${repos[i]}"; commit_repo=$REPO; matchsha= - source_repo=$(git -C "$commit_repo" rev-parse --show-toplevel) || die "repository missing: ${repos[i]}" + repo_dir "${repos[i]}" Commit; commit_repo=$REPO; matchsha= + source_repo=$(git -C "$commit_repo" rev-parse --show-toplevel) || die "not a git repository: ${repos[i]}" for ((j=0;j<${#delivered[@]};j++)); do - repo_path "${drepos[j]}" + repo_dir "${drepos[j]}" Delivered delivered_repo=$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null) || continue [ "$delivered_repo" != "$source_repo" ] || matchsha=${delivered[j]} done diff --git a/templates/config.md b/templates/config.md index 5d42d34..0afcd6b 100644 --- a/templates/config.md +++ b/templates/config.md @@ -14,3 +14,5 @@ qa: human_gates: intent # durable knowledge: scouts read it first, Evidence adds verified facts to it knowledge: .sdlc/knowledge +# repositories a review PASS binds: space-separated top folders of repositories under the project root (relative, no spaces in a path), "." for the repository holding the root; empty = every repository under the root +repos: diff --git a/tests/selftest.sh b/tests/selftest.sh index e90e5f5..e31611a 100755 --- a/tests/selftest.sh +++ b/tests/selftest.sh @@ -566,12 +566,234 @@ regression_ultra() { printf 'skip: git without SHA-256 repositories\n' fi } +nested_repo() { + mkdir -p "$1" + git -C "$1" -c init.defaultBranch=main init -q + git -C "$1" config user.name 'sdlc selftest'; git -C "$1" config user.email selftest@sdlc.invalid + printf '%s\n' "$1" > "$1/source"; git -C "$1" add source; git -C "$1" commit -qm base +} +regression_repos() { + repo regression-repos + printf 'services/\n' >> .git/info/exclude + for name in a b c; do nested_repo "services/$name"; done + printf 'human_gates: intent\nrepos: . services/a # repositories a review binds\n' > .sdlc/config.md + ready bound 1 + case "$(state heads)" in *'/services/a'*) :;; *) fail 'repos: did not bind services/a';; esac + case "$(state heads)" in *'/services/b'*|*'/services/c'*) fail 'repos: bound an unlisted repository';; esac + [ "${#OUTPUT}" -lt 200 ] || fail "review printed ${#OUTPUT} bytes" + evidence + replace '/## Results/ {print "- Commit: '"$(git -C services/a rev-parse HEAD)"' services/a"} {print}' "$E" + route deliver + replace '/## Results/ {print "- Commit: '"$(git -C services/b rev-parse HEAD)"' services/b"} {print}' "$E" + expect 1 score "$SLUG"; contains 'services/b was not under review'; contains '(config repos: . services/a)' + printf 'human_gates: intent\nrepos: services/a services/none\n' > .sdlc/config.md + printf 'VERDICT: PASS\n' >> "$F/review.md" + expect 1 review "$SLUG" pass 'listed path is missing'; contains "repos: 'services/none' is not a directory under the project root" + printf 'human_gates: intent\nrepos: services\n' > .sdlc/config.md + expect 1 review "$SLUG" pass 'listed path is a plain folder'; contains "repos: 'services' is not the top folder of a git repository" + nested_repo "$TMP/outside-repos" + printf 'human_gates: intent\nrepos: . ../outside-repos\n' > .sdlc/config.md + expect 1 review "$SLUG" pass 'listed path leaves the root'; contains "repos: '../outside-repos' is not a directory under the project root" + if ln -s "$TMP/outside-repos" services/out 2>/dev/null && [ -L services/out ]; then + printf 'human_gates: intent\nrepos: services/out\n' > .sdlc/config.md + expect 1 review "$SLUG" pass 'listed symlink leaves the root'; contains "repos: 'services/out' is not a directory under the project root" + else + skip 'symlink to a repository outside the root unavailable on this OS' + fi + ok 'config repos: limits the repositories a review binds, takes only repository top folders under the root, and names itself when a Commit is outside it' +} +regression_cells() { + repo regression-cells + ready cells 1 + evidence + replace '/\| S1 \|/ {$0="| S1 | PASS | | observed | extra cell | logs/S1.txt |"} {print}' "$E" + expect 1 score "$SLUG"; contains 'S1: row has 6 cells, header has 5' + case "$OUTPUT" in *'needs a non-empty log'*) fail 'cell mismatch reported as a log problem';; esac + replace '/\| S1 \|/ {$0="| S1 | PASS | | left \\| right | logs/S1.txt"} {print}' "$E" + route deliver + printf '| S2 | operator | run CLI | output | no | O1 | extra |\n' >> "$F/PLAN.md" + expect 1 approve plan "$SLUG" --agent 'long scenario row'; contains 'S2: row has 7 cells, header has 6' + feature short-rows + expect 0 approve intent "$SLUG" --human 'approved scope' + plan 1; printf '| S2 | operator | run CLI | output | no |\n' >> "$F/PLAN.md"; N=2 + expect 0 approve plan "$SLUG" --agent 'short scenario row as v2.1.0 read it' + reviewed "$SLUG" pass 'short rows' + evidence; replace '/\| S2 \|/ {$0="| S2 | BLOCKED | | no env |"} {print}' "$E" + expect 0 score "$SLUG"; contains 'ROUTE human' + case "$OUTPUT" in *'row has'*) fail 'a short row was refused';; esac + ok 'a Results or Scenarios row with more cells than its header is named, a shorter row reads as before; escaped pipes still count as text' +} +regression_paths() { + repo regression-paths + ready paths 1 + evidence + replace '/Commit:/ {$0=$0" (reviewed)"} {print}' "$E" + expect 1 score "$SLUG"; contains "Commit path '(reviewed)' is not a directory under the project root: write '- Commit: '" + evidence; route deliver; delivery local "$SHA nowhere" + expect 1 close "$SLUG" shipped 'delivered path is not a folder'; contains "Delivered path 'nowhere' is not a directory under the project root" + ok 'a Commit or Delivered path that is not a folder under the project root says so and shows the line format' +} +regression_uncommitted() { + repo regression-uncommitted + feature precommit + expect 0 approve intent "$SLUG" --human 'approved scope' + plan 1; expect 0 approve plan "$SLUG" --agent reviewed + printf 'reviewed change\n' >> README; printf 'new file\n' > added.txt + reviewed "$SLUG" pass 'reviewed before the human commits'; review_output=$OUTPUT + git add README added.txt; git commit -qm 'commit the reviewed change'; SHA=$(git rev-parse HEAD) + evidence; route deliver; delivery + expect 0 close "$SLUG" shipped 'reviewed before commit, committed unchanged' + OUTPUT=$review_output; contains 'Uncommitted changes are bound' + ok 'a review PASS on uncommitted changes accepts the commit of exactly that change' + feature precommit-changed + expect 0 approve intent "$SLUG" --human 'approved scope' + plan 1; expect 0 approve plan "$SLUG" --agent reviewed + printf 'reviewed two\n' >> README + reviewed "$SLUG" pass 'reviewed before commit' + reviewed_sha=$SHA + printf 'unreviewed extra\n' >> README; git commit -qam 'commit more than was reviewed'; SHA=$(git rev-parse HEAD) + evidence + expect 1 score "$SLUG"; contains "is not the reviewed uncommitted change"; contains "on top of the reviewed commit $reviewed_sha" + git reset -q --hard "$reviewed_sha"; printf 'reviewed two\n' >> README + git stash -q; git commit -q --amend --allow-empty -m 'rewritten base'; git stash pop -q + git commit -qam 'reviewed change on another base'; SHA=$(git rev-parse HEAD) + evidence + expect 1 score "$SLUG"; contains 'is not the reviewed uncommitted change' + reviewed "$SLUG" pass 'review the committed change' + route deliver + ok 'a commit that differs from the reviewed uncommitted change, or sits on another base, needs a new review' + feature precommit-dropped + expect 0 approve intent "$SLUG" --human 'approved scope' + plan 1; expect 0 approve plan "$SLUG" --agent reviewed + printf 'reviewed three\n' >> README + reviewed "$SLUG" pass 'reviewed before commit' + git checkout -q README; SHA=$(git rev-parse HEAD) + evidence + expect 1 score "$SLUG"; contains 'is not the reviewed uncommitted change' + printf 'reviewed three\n' >> README; git stash -q + expect 1 score "$SLUG"; contains 'is not the reviewed uncommitted change' + git stash pop -q; git commit -qam 'commit the reviewed change at last'; SHA=$(git rev-parse HEAD) + evidence; route deliver + ok 'the reviewed HEAD alone does not satisfy a review of uncommitted changes that were reverted or stashed' +} +regression_nested() { + repo regression-nested + for name in a b c; do nested_repo "worktrees/$name"; done + git worktree add -q --detach worktrees/linked + ready nested-clean 1 + case "$(state heads)" in *+*) fail 'untracked nested repositories bound as uncommitted changes';; esac + feature nested + expect 0 approve intent "$SLUG" --human 'approved scope' + plan 1; expect 0 approve plan "$SLUG" --agent reviewed + printf 'real change\n' >> README + reviewed "$SLUG" pass 'reviewed beside untracked nested repositories'; contains 'Uncommitted changes are bound' + case "$OUTPUT" in *'embedded git repository'*) fail 'review warned about nested repositories';; esac + [ "${#OUTPUT}" -lt 400 ] || fail "review printed ${#OUTPUT} bytes" + git commit -qam 'commit only the real change'; SHA=$(git rev-parse HEAD) + evidence; route deliver + git init -q worktrees/empty + printf 'more\n' >> README + reviewed "$SLUG" pass 'nested repository without a commit' + ok 'untracked nested repositories stay out of the bound change, its dirty check and the review output' +} +# Uncommitted changes outside every Commit path do not block a reviewed commit, as in v2.1.0. +regression_scoped() { + local parent=$TMP/regression-scoped + mkdir -p "$parent/proj" "$parent/other"; cd "$parent" + git -c init.defaultBranch=main init -q + git config user.name 'sdlc selftest'; git config user.email selftest@sdlc.invalid; git config core.autocrlf false + printf 'other\n' > other/o.txt; printf 'baseline\n' > proj/README; git add other proj; git commit -qm baseline + cd proj; expect 0 init + printf 'committed change\n' >> README; git commit -qam 'change in the project'; SHA=$(git rev-parse HEAD) + printf 'outside the project\n' >> ../other/o.txt + ready scoped-parent 1 + evidence; route deliver + ok 'a reviewed commit passes beside uncommitted changes in the parent repository outside the project root' + printf 'reviewed in the project\n' >> README + feature scoped-later + expect 0 approve intent "$SLUG" --human 'approved scope' + plan 1; expect 0 approve plan "$SLUG" --agent reviewed + reviewed "$SLUG" pass 'reviewed before commit beside outside changes' + printf 'unreviewed\n' > ../other/x.txt; git add README ../other/x.txt; git commit -qm 'reviewed change plus an unreviewed one'; SHA=$(git rev-parse HEAD) + evidence + expect 1 score "$SLUG"; contains 'is not the reviewed uncommitted change' + git reset -q HEAD~1; rm ../other/x.txt; git add README; git commit -qm 'only the reviewed change'; SHA=$(git rev-parse HEAD) + evidence; route deliver + ok 'a commit after an uncommitted review is compared inside its path, and refused for unreviewed changes outside it' + repo regression-scoped-pkg + mkdir pkg; printf 'package\n' > pkg/file; git add pkg; git commit -qm 'add package'; SHA=$(git rev-parse HEAD) + printf 'notes\n' > notes.txt + ready scoped-pkg 1 + evidence; replace '/^- Commit:/ {$0=$0" pkg"} {print}' "$E" + route deliver + ok 'an untracked file outside the Commit path does not block a reviewed commit' + mkdir out; seq 1 50 > out/secret.txt; git add out; git commit -qm 'add a file outside the package' + feature scoped-rename + expect 0 approve intent "$SLUG" --human 'approved scope' + plan 1; expect 0 approve plan "$SLUG" --agent reviewed + printf 'reviewed\n' >> pkg/file + reviewed "$SLUG" pass 'reviewed before commit' + git mv out/secret.txt other-secret.txt; printf 'EVIL\n' >> other-secret.txt + git add -A; git commit -qm 'reviewed change plus an unreviewed rename and edit'; SHA=$(git rev-parse HEAD) + evidence; replace '/^- Commit:/ {$0=$0" pkg"} {print}' "$E" + expect 1 score "$SLUG"; contains 'is not the reviewed uncommitted change' + ok 'an unreviewed rename outside the Commit path is refused like any other unreviewed change' +} +# Fingerprinting uncommitted changes never breaks a review that v2.1.0 accepted. +regression_fingerprint() { + repo regression-fingerprint + mkdir -p .sdlc/memory; printf 'shared\n' > .sdlc/memory/team.md; git add -f .sdlc/memory/team.md; git commit -qm 'share store knowledge' + mkdir pkg; printf 'package\n' > pkg/file; git add pkg; git commit -qm 'add package'; SHA=$(git rev-parse HEAD) + printf 'notes\n' > notes.txt + ready tracked-store 1 + evidence; replace '/^- Commit:/ {$0=$0" pkg"} {print}' "$E" + route deliver + ok 'a partly tracked, ignored store does not stop the working tree fingerprint' + repo 'regression-c++proj' + ready plus-path 1 + case "$OUTPUT" in *'Uncommitted changes are bound'*) fail 'a + in the repository path read as bound uncommitted changes';; esac + ok 'a + in a repository path is not a bound working tree' + case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) printf 'skip: unreadable files need POSIX permissions\n'; return;; esac + repo regression-unreadable + mkdir pkg; printf 'package\n' > pkg/file; git add pkg; git commit -qm 'add package'; SHA=$(git rev-parse HEAD) + mkdir build; printf 'locked\n' > build/locked.bin; chmod 000 build/locked.bin + if cat build/locked.bin > /dev/null 2>&1; then chmod 600 build/locked.bin; printf 'skip: unreadable files are readable for this user\n'; return; fi + ready unreadable 1; contains 'the PASS binds its HEAD only' + [ "$(printf '%s\n' "$OUTPUT" | grep -c 'cannot fingerprint')" -eq 1 ] || fail "fingerprint warning not printed once: $OUTPUT" + evidence; replace '/^- Commit:/ {$0=$0" pkg"} {print}' "$E" + route deliver + chmod 600 build/locked.bin + ok 'an unreadable untracked file binds HEAD alone with one warning, as v2.1.0 bound it' +} +# An interrupted review removes its temporary index folder. Needs job control and POSIX signals. +regression_interrupt() { + local real mark dir pid n + case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) printf 'skip: interrupt cleanup needs POSIX signals\n'; return;; esac + repo regression-interrupt + feature interrupt + expect 0 approve intent "$SLUG" --human 'approved scope' + plan 1; expect 0 approve plan "$SLUG" --agent reviewed + printf 'change\n' >> README; printf 'VERDICT: PASS\n' >> "$F/review.md" + real=$(command -v git); mark=$TMP/interrupt-mark; mkdir -p "$TMP/slowgit" + printf '#!/bin/sh\ncase " $* " in *" write-tree"*) printf %%s "$GIT_INDEX_FILE" > "%s"; sleep 5;; esac\nexec "%s" "$@"\n' "$mark" "$real" > "$TMP/slowgit/git" + chmod +x "$TMP/slowgit/git" + set -m; PATH="$TMP/slowgit:$PATH" bash "$SDLC" review "$SLUG" pass interrupted > /dev/null 2>&1 & pid=$!; set +m + n=0; while [ ! -s "$mark" ] && [ "$n" -lt 100 ]; do sleep 0.1; n=$((n+1)); done + [ -s "$mark" ] || fail 'review never fingerprinted the working tree' + dir=$(dirname "$(cat "$mark")"); kill -INT -- "-$pid"; wait "$pid" || : + n=0; while [ -e "$dir" ] && [ "$n" -lt 30 ]; do sleep 0.1; n=$((n+1)); done + [ ! -e "$dir" ] || { rm -rf "$dir"; fail 'interrupted review left its temporary index'; } + ok 'an interrupted review removes its temporary index' +} run_regressions() { case "$1" in stop) regression_stop;; scenarios) regression_scenarios;; delivery) regression_delivery;; review) regression_review;; other) regression_other;; init) regression_init;; ultra) regression_ultra;; round2-[abcde]) regression_round2 "${1#round2-}";; round3) regression_round3;; + repos) regression_repos;; cells) regression_cells;; paths) regression_paths;; uncommitted) regression_uncommitted;; + nested) regression_nested;; interrupt) regression_interrupt;; scoped) regression_scoped;; fingerprint) regression_fingerprint;; all) regression_stop; regression_scenarios; regression_delivery; regression_review; regression_other; regression_init; regression_ultra - for round2 in a b c d e; do regression_round2 "$round2"; done; regression_round3;; + for round2 in a b c d e; do regression_round2 "$round2"; done; regression_round3 + regression_repos; regression_cells; regression_paths; regression_uncommitted; regression_nested; regression_interrupt; regression_scoped; regression_fingerprint;; *) fail "unknown regression group: $1";; esac }