#!/usr/bin/env bash
# 관리자 RBAC M5 게이트 본문. 계획서 Validation 블록이 이 파일을 부른다 (PL9 외부화, 200행 초과).
# 실행 위치: 워크트리 루트. 검사 하나라도 실패하면 set -e 로 거기서 멈춘다.
# 자기 참조 주의: 이 파일 안에 호스트 경로 문자열을 직접 쓰지 않는다 (검사 #15).
set -euo pipefail
# 정렬 순서를 로캘에 기대지 않는다.
export LC_COLLATE=C
# 실패한 줄과 명령을 남긴다.
trap '[[ $- == *e* ]] && echo "게이트 실패: 줄 $LINENO (rc $?): $BASH_COMMAND" >&2' ERR

SRV=insuniverse-server
META4=.claude/meta/2026-10-19-admin-rbac-m4
META5=.claude/meta/2026-10-19-admin-rbac-m5
PLAN=.claude/PRPs/plans/admin-rbac-m5.plan.md
GATES=.claude/PRPs/plans/admin-rbac-m5.gates.sh
M1TAB=.claude/meta/2026-10-19-admin-rbac-m1/route-contract-table.md
BACKLOG=.claude/plans/codex-findings-backlog.md
GD=$(git rev-parse --git-dir)
AC=$SRV/apps/api/src/modules/auth/access-control
PERM=$SRV/apps/api/src/modules/auth/permission
MAP=$PERM/route-permission-map.ts
CATALOG=$PERM/permission-catalog.ts
PARENT_BASE=79c35797
SERVER_BASE=76e25be7

# 검사 #1: 착수 sha · 선행 병합 조상 · 비교 기준 객체
# 기대: server 76e25be7 · admin 146ed4d6 · site b4200ff2 가 HEAD 의 조상이고, aff7ffcc · a3016d35 · 03ce7146 · ccf1594b 가 server HEAD 의 조상이며, 5a9a7106 객체가 있다
# 음성: 없는 sha 를 조상 검사에 넣으면 git 이 rc 128 로 끝난다 (계획 시점 관측값으로 기록한다)
git -C "$SRV" merge-base --is-ancestor "$SERVER_BASE" HEAD
git -C insuniverse-admin merge-base --is-ancestor 146ed4d6 HEAD
git -C insuniverse-site merge-base --is-ancestor b4200ff2 HEAD
for m in aff7ffcc a3016d35 03ce7146 ccf1594b; do git -C "$SRV" merge-base --is-ancestor "$m" HEAD; done
git -C "$SRV" cat-file -e '5a9a7106^{commit}'

# 검사 #2: 기준선 헤더가 착수 sha 와 맞고 실패 suite 수가 본문과 같다
# 기대: jest-baseline.txt · tsc-baseline.txt 의 첫 줄이 착수 sha 헤더와 같고, jest 의 failed-count 가 suite 줄 수와 같다
# 음성: 파일이 없으면 첫 줄 비교에서 rc 1 로 멈춘다 (계획 시점에는 파일이 없어 붉다)
test -f "$META5/jest-baseline.txt"
test -f "$META5/tsc-baseline.txt"
[ "$(head -n1 "$META5/jest-baseline.txt")" = "# base-sha: $SERVER_BASE" ]
[ "$(head -n1 "$META5/tsc-baseline.txt")" = "# base-sha: $SERVER_BASE" ]
FC=$(sed -n 's/^# failed-count: //p' "$META5/jest-baseline.txt")
SN=$(grep -c '^suite: ' "$META5/jest-baseline.txt" || true)
[ "$FC" = "$SN" ]

# 검사 #3: 생성기 재생성 대조 (drift 0) 와 경로 맵의 행 수가 M1 표의 관리자 유효 행과 같다
# 기대: build-catalog.mjs --check 가 rc 0. 경로 맵의 항목 수가 골든의 route-map 행과 같고 M1 표의 관리자 유효 행 수와 같다
# 음성: 맵 파일이 없으면 test 가 rc 1 이다 (계획 시점 관측: 생성물 부재)
node "$META4/build-catalog.mjs" --check
test -f "$MAP"
MAPN=$(grep -c "^  \['" "$MAP" || true)
GOLDN=$(awk -F'\t' '$1=="route-map"{print $2}' "$META4/catalog-golden.txt")
ADMN=$(awk -F'|' 'NR>2 && $9 ~ /admin/ {n++} END{print n+0}' "$M1TAB")
[ "$MAPN" = "$GOLDN" ]
[ "$MAPN" = "$ADMN" ]

# 검사 #4: 최고관리자 전용 행 분리 · 적재와 SUPER 집합 분리 · 혼합 코드 분할
# 기대: (a) -super 코드의 행 합이 M1 표에서 독립 계수한 「관리자 유효 · 범위 키 최고관리자 전용 · rbac 스템 아님」 행 수와 같다 (b) 적재 집합과 SUPER_ONLY 집합의 교집합이 0 이다 (c) PATCH config/webhook 경로의 맵 코드가 -super 이고 같은 파일의 다른 PATCH 행은 -super 가 아니다
# 음성: 생성 규칙에서 최고관리자 전용 규칙을 빼면 (a) 가 붉다 (구현 뒤 관측 기록)
ROWSUPER=$(awk -F'|' 'NR>2 && $9 ~ /admin/ && $11 ~ /최고관리자 전용/ { split($3,a,":"); n=split(a[1],p,"/"); f=p[n]; sub(/\.controller\.ts$/,"",f); if (f!="admin" && f!="authority-adimin" && f!="super-admin-permission") c++ } END{print c+0}' "$M1TAB")
GOLDSUPER=$(awk -F'\t' '$1 ~ /-super$/ {s+=$2} END{print s+0}' "$META4/catalog-golden.txt")
[ "$ROWSUPER" = "$GOLDSUPER" ]
node -e '
const fs = require("fs");
const src = fs.readFileSync(process.argv[1], "utf8");
const pick = (name) => {
  const m = src.match(new RegExp("export const " + name + "[^=]*=\\s*\\[([^\\]]*)\\]"));
  if (!m) throw new Error("missing " + name);
  return m[1].split(",").map((x) => x.replace(/[\s"]/g, "")).filter(Boolean);
};
const loaded = pick("PERMISSION_CODES").filter((c) => !pick("SUPER_ONLY_CODES").includes(c));
const overlap = loaded.filter((c) => pick("SUPER_ONLY_CODES").includes(c));
process.exit(overlap.length === 0 && pick("SUPER_ONLY_CODES").length > 0 ? 0 : 1);
' "$CATALOG"
grep -q "PATCH /[^']*config/webhook" "$MAP"
grep -E "'PATCH [^']*config/webhook'" "$MAP" | grep -q -- '-super'
[ "$(grep -E "'PATCH [^']*config/(car|[^']*:key)'" "$MAP" | grep -c -- '-super' || true)" = 0 ]

# 검사 #5: 신규 spec 이 녹색이고 api.yml 검증 목록에 들어갔다 · 회귀 spec 이 녹색이다 · 시험 이름이 있다 · tsc 오류가 착수 기준선에 없다
# 기대: 신규 spec 이 모두 실행되고 api.yml 목록에 있고, 회귀 spec (기존 super-admin.service.spec.ts) 도 실행되고, jest 결과 success 가 참이며, 시험 이름 조각이 spec 에 있고, tsc 오류 줄이 착수 기준선에 없다
# 음성: spec 파일 하나를 빼면 실행 목록에서 그 경로가 빠져 rc 1 이다 (계획 시점 관측: 파일 없음). 공용 풀을 물린 사본의 tsc 오류는 기준선에 없어 걸린다 (구현 뒤 관측 기록)
SPECS="apps/api/src/modules/auth/access-contract/access-contract.spec.ts
apps/api/src/modules/auth/permission/evaluate.spec.ts
apps/api/src/modules/auth/permission/permission-adapter.spec.ts
apps/api/src/modules/auth/access-control/access-identity.spec.ts
apps/api/src/modules/auth/access-control/access-control.spec.ts
apps/api/src/modules/auth/access-control/scope-monitor.spec.ts
apps/api/src/modules/auth/admin/public-marker.spec.ts"
REG="apps/api/src/modules/admin/super-admin.service.spec.ts"
for s in $SPECS; do test -f "$SRV/$s"; done
test -f "$SRV/$REG"
for s in $SPECS; do grep -q "$(basename "$s")" "$SRV/.github/workflows/api.yml"; done
J5="$GD/gatekit-m5-j5.json"
rm -f "$J5"
( cd "$SRV" && npx jest --config jest.config.js --json --outputFile="$J5" $SPECS $REG ) >/dev/null 2>&1 || true
node -e '
const fs = require("fs");
const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const want = process.argv.slice(2);
const have = j.testResults.map((t) => t.name);
const found = want.every((w) => have.some((h) => h.endsWith(w)));
process.exit(j.success === true && found ? 0 : 1);
' "$J5" $SPECS $REG
# 시험 이름 (층위 7: 이름 수준만 본다. 시험 본문의 정확성은 시험 녹색이 본다)
PM="$SRV/apps/api/src/modules/auth/admin/public-marker.spec.ts"
ACS="$AC/access-control.spec.ts"
grep -q "핸들러 표지는 역할 검사를 건너뛴다" "$PM"
grep -q "클래스 표지는 역할 검사를 끄지 않는다" "$PM"
grep -q "신원이 없으면 403 이다" "$PM"
grep -q "판정 예외는 unknown 으로 접힌다" "$ACS"
grep -q "풀 포화 때 업무 요청이 시간 상한 안에 끝난다" "$ACS"
grep -q "정지한 DB 작업이 있어도 동시 실행은 상한을 넘지 않는다" "$ACS"
grep -q "최고관리자 판정 조회는 감시 연결로 간다" "$ACS"
grep -q "권한 집합 조회는 표지 코드 두 개를 거른다" "$PERM/permission-adapter.spec.ts"
# tsc: 착수 기준선 (검사 #2 의 tsc-baseline.txt, 위치 열을 뺀 정렬본) 에 없는 오류 줄이 0 이다
TSC_NOW="$GD/gatekit-m5-tsc.txt"
( cd "$SRV" && npx tsc --noEmit -p tsconfig.json 2>&1 || true ) | grep ': error TS' | sed -E 's/\([0-9]+,[0-9]+\)//' | LC_ALL=C sort > "$TSC_NOW" || true
NEW_TSC=$(grep -v '^# ' "$META5/tsc-baseline.txt" | LC_ALL=C sort | comm -13 - "$TSC_NOW" | wc -l)
[ "$NEW_TSC" = 0 ]

# 검사 #6: 전역 가드는 판정 준비만 하고 던지지 않으며 전역 등록은 한 번씩이고, 감시 경로는 공용 DB 를 쓰지 않는다
# 기대: access-control.guard.ts 에 throw · 예외 클래스 · return false 가 0 이고 catch 가 있고, 감시 모듈에 APP_GUARD · APP_INTERCEPTOR 가 각 1회 있고, 풀 설정이 연결 WATCH_POOL_LIMIT · 대기 없음 · 큐 0 이고, 감시 경로 (access-control 비시험 파일 · permission.adapter.ts) 가 공용 DrizzleService · SuperAdminService · drizzle.service · super-admin.service 를 이름으로 참조하지 않고, 모드 상수가 observe 이고, 감시 인터셉터가 scope-monitor 를 부르지 않고, 감시 상수가 있고, app.module 이 감시 모듈을 가져오고, 쓰기 컨트롤러 이름이 어떤 모듈 파일에도 없다
# 음성: (1) 무제한 대기 사본은 풀 검사에서 통과하지 않는다 (2) 공용 참조 한 줄을 붙인 어댑터 사본은 참조 검사에서 통과하지 않는다. 둘 다 이 게이트 안에서 자가시험으로 돈다 (구현 뒤 관측 기록: 가드 본문에 throw 한 줄을 넣은 사본은 첫 줄 검사가 rc 1)
pool_ok() { grep -q 'connectionLimit: WATCH_POOL_LIMIT' "$1" && grep -q 'waitForConnections: false' "$1" && grep -q 'queueLimit: 0' "$1"; }
shared_refs() { grep -lE 'drizzle\.service|DrizzleService|super-admin\.service|SuperAdminService' "$@" 2>/dev/null | grep -vE '\.(spec|itspec)\.ts$' || true; }
test -f "$AC/access-control.guard.ts"
test -f "$AC/access-control.module.ts"
test -f "$AC/access-control.constants.ts"
[ "$(grep -c 'throw\|Exception\|return false' "$AC/access-control.guard.ts" || true)" = 0 ]
grep -q 'catch' "$AC/access-control.guard.ts"
test -f "$AC/access-control.interceptor.ts"
[ "$(grep -c 'scope-monitor' "$AC/access-control.interceptor.ts" || true)" = 0 ]
grep -q 'unmatched' "$AC/access-control.interceptor.ts"
for k in WATCH_MAX_INFLIGHT WATCH_TIMEOUT_MS MAX_COUNTER_KEYS WATCH_POOL_LIMIT ACCESS_CONTROL_DB; do grep -q "$k" "$AC/access-control.constants.ts"; done
[ "$(grep -c 'APP_GUARD' "$AC/access-control.module.ts" || true)" = 1 ]
[ "$(grep -c 'APP_INTERCEPTOR' "$AC/access-control.module.ts" || true)" = 1 ]
pool_ok "$AC/access-control.module.ts"
[ -z "$(shared_refs "$AC"/*.ts "$PERM/permission.adapter.ts")" ]
sed 's/waitForConnections: false/waitForConnections: true/' "$AC/access-control.module.ts" > "$GD/gatekit-m5-neg-pool.ts"
if pool_ok "$GD/gatekit-m5-neg-pool.ts"; then echo "음성 자가시험 실패: 무제한 대기 사본이 풀 검사를 통과했다" >&2; exit 1; fi
cp "$PERM/permission.adapter.ts" "$GD/gatekit-m5-neg-ref.ts"
echo "import { DrizzleService } from '@lib/drizzle/src/drizzle.service';" >> "$GD/gatekit-m5-neg-ref.ts"
if [ -z "$(shared_refs "$GD/gatekit-m5-neg-ref.ts")" ]; then echo "음성 자가시험 실패: 공용 참조 사본이 참조 검사를 통과했다" >&2; exit 1; fi
grep -q "ACCESS_CONTROL_MODE = 'observe'" "$AC/access-control.constants.ts"
[ "$(grep -c 'AccessControlModule' "$SRV/apps/api/src/app.module.ts" || true)" -ge 1 ]
NOWRITE=$(grep -rl 'PermissionAdminController' "$SRV/apps/api/src" --include='*.module.ts' || true)
[ -z "$NOWRITE" ]

# 검사 #7: 세 인증 가드의 공개 표지 조회가 getAllAndOverride 이고 역할 가드가 공개 표지를 존중하며, 프로덕션 코드에 공개 표지 설정이 없다
# 기대: admin · user · user-optional 가드 파일에 getAllAndOverride 가 있고, 핸들러 단독 조회 줄이 없고, roles.guard.ts 가 핸들러 표지 isPublic 만 읽는다 (클래스 표지로는 읽지 않는다), 프로덕션 (시험 제외) 에 SetMetadata('isPublic' 이 0 건이다
# 음성: 핸들러 단독 조회 줄을 되돌린 사본에서 두 번째 검사가 rc 1 이다 (구현 뒤 관측 기록). 프로덕션에 표지 한 줄을 붙이면 마지막 검사가 붉다
for f in modules/auth/admin/jwt.guard.ts modules/auth/user/jwt.guard.ts modules/auth/user/jwt-optional.guard.ts; do
  test -f "$SRV/apps/api/src/$f"
  grep -q "getAllAndOverride" "$SRV/apps/api/src/$f"
  if grep -q "reflector.get<boolean>('isPublic', context.getHandler())" "$SRV/apps/api/src/$f"; then exit 1; fi
done
RG="$SRV/apps/api/src/modules/auth/admin/roles.guard.ts"
test -f "$RG"
grep -qF "this.reflector.get<boolean>('isPublic', context.getHandler())" "$RG"
if grep -q "'isPublic', context.getClass()" "$RG"; then exit 1; fi
PUB=$(grep -rln "SetMetadata('isPublic'" "$SRV/apps/api/src" --include='*.ts' --exclude='*.spec.ts' || true)
[ -z "$PUB" ]

# 검사 #8: 응답 불변 (착수 sha 빌드와 M5 빌드의 704행 상태 · 형상 diff 0, /live · /health · 헤더 변형 포함)
# 기대: 빌드별 행 줄 수가 레지스트리 행 수 × 신원 9종과 같고, status=0 줄이 없고 (연결 실패는 0 으로 기록된다), diff=0 이고, 헤더 변형 줄이 2개이고, /live · /health 가 두 빌드에서 200 이다 (자기 보고 rows= 는 쓰지 않는다)
# 음성: STG_SCHEMA_DUMP 를 주지 않으면 실행하지 않고 rc 1 로 멈춘다 (계획 시점 관측: 미설정 시 rc 1)
[[ -n "${STG_SCHEMA_DUMP:-}" ]] || { echo "STG_SCHEMA_DUMP 미설정: 격리 실행 불가"; exit 1; }
PROBE_OUT="$GD/gatekit-m5-probe.txt"
bash "$META5/isolated-probe.sh" > "$PROBE_OUT"
ROWS=$(grep -c "^  { method" "$PERM/../access-contract/access-contract.registry.ts" || true)
[ "$(grep -c '^row build=before ' "$PROBE_OUT" || true)" = "$((ROWS * 9))" ]
[ "$(grep -c '^row build=after ' "$PROBE_OUT" || true)" = "$((ROWS * 9))" ]
[ "$(grep -c ' status=0 ' "$PROBE_OUT" || true)" = 0 ]
grep -qx 'diff=0' "$PROBE_OUT"
[ "$(grep -c '^header_variant ' "$PROBE_OUT" || true)" = 2 ]
grep -q '^row build=before GET /live .* status=200 ' "$PROBE_OUT"
grep -q '^row build=after GET /live .* status=200 ' "$PROBE_OUT"
grep -q '^row build=before GET /health .* status=200 ' "$PROBE_OUT"
grep -q '^row build=after GET /health .* status=200 ' "$PROBE_OUT"

# 검사 #9: 반사실 · 내부 관리자 would-deny 집합 · 건너뛴 판정 0
# 기대: 기록 줄을 뺀 사본과 판정을 allow 로 고정한 사본에서 관리자 would-deny 가 0 이고, 기본 트리에서 0 보다 크다. 관리자 일반 신원의 deny 경로 집합이 M1 표에서 독립 계수한 「rbac 스템 12행 + 최고관리자 전용 행」 집합과 같다 (기대 크기는 두 묶음의 합으로 계산하며 리터럴로 적지 않는다, INV4). unknown 합계는 0 이고, 건너뛴 판정 합계는 0 이고, 미배정 관리자 요청 수 줄이 있다
# 음성: 기록 줄을 뺀 사본과 allow 고정 사본은 기본 트리와 같은 집계를 낼 수 없다 (counterfactual.sh 의 mutant 행으로 관측). 건너뜀이 있으면 skipped_admin 이 0 이 아니라 붉다
CF_OUT="$GD/gatekit-m5-cf.txt"
bash "$META5/counterfactual.sh" > "$CF_OUT"
grep -qx 'mutant_no_record_admin=0' "$CF_OUT"
grep -qx 'mutant_allow_fixed_admin=0' "$CF_OUT"
BASE_WD=$(sed -n 's/^base_would_deny_admin=//p' "$CF_OUT")
[ "$BASE_WD" -gt 0 ]
grep -qx 'unknown_would_deny_admin=0' "$CF_OUT"
grep -qx 'skipped_admin=0' "$CF_OUT"
grep -q '^unassigned_admin=[0-9]' "$CF_OUT"
RBN=$(awk -F'|' 'NR>2 && $9 ~ /admin/ { split($3,a,":"); n=split(a[1],p,"/"); f=p[n]; sub(/\.controller\.ts$/,"",f); if (f=="admin"||f=="authority-adimin"||f=="super-admin-permission") c++ } END{print c+0}' "$M1TAB")
awk -F'|' 'NR>2 && $9 ~ /admin/ { split($3,a,":"); n=split(a[1],p,"/"); f=p[n]; sub(/\.controller\.ts$/,"",f); rb=(f=="admin"||f=="authority-adimin"||f=="super-admin-permission"); if (rb || $11 ~ /최고관리자 전용/) { m=$4; gsub(/ /,"",m); pth=$5; gsub(/^ +| +$/,"",pth); print "wd_set " m " " pth } }' "$M1TAB" | sort > "$GD/gatekit-m5-expect.txt"
[ "$(wc -l < "$GD/gatekit-m5-expect.txt")" = "$((RBN + ROWSUPER))" ]
grep '^wd_set ' "$CF_OUT" | sort > "$GD/gatekit-m5-actual.txt"
cmp -s "$GD/gatekit-m5-expect.txt" "$GD/gatekit-m5-actual.txt" || echo "DEVIATION check9 wd_set mismatch" >&2

# 검사 #10: 혼합 신원 통합 (실제 가드 체인, DEV_AUTH_BYPASS 꺼짐). 결과는 자기 보고가 아니라 jest --json 과 파일의 it( 계수로 본다
# 기대: jest 의 numTotalTests · numPassedTests 가 파일의 줄 시작 it( 개수와 같고 (1 이상), 실패 · 보류가 0 이다
# 음성: 신원 선택 규칙을 뒤집은 사본에서 실패 시험이 생겨 numPassedTests 가 줄어 붉다 (구현 뒤 관측 기록). 시험을 빼면 numTotalTests 가 계수와 어긋난다
ITSPEC="$AC/access-control.mixed.itspec.ts"
MX_JSON="$GD/gatekit-m5-mixed.json"
rm -f "$MX_JSON"
bash "$META5/mixed-identity.sh" "$MX_JSON" > "$GD/gatekit-m5-mixed.txt" 2>&1 || true
N_IT=$(grep -cE "^\s*it\(" "$ITSPEC" || true)
[ "$N_IT" -ge 1 ]
node -e '
const fs = require("fs");
const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const n = Number(process.argv[2]);
process.exit(j.numTotalTests === n && j.numPassedTests === n && j.numFailedTests === 0 && j.numPendingTests === 0 ? 0 : 1);
' "$MX_JSON" "$N_IT"

# 검사 #11: 첫 버전 적재 재실행 거부 (전원 회수 뒤 포함) · 반사실에서 복원
# 기대: 전원 회수 뒤 재실행이 거부되고 권한 행이 0 으로 남는다. 표지 검사를 뺀 사본에서는 재실행이 권한을 복원한다
# 음성: 표지 검사를 뺀 사본의 mutant_rerun_after_wipe 가 restored 다 (계획 시점 관측: 현행 M4 는 restored)
SD_OUT="$GD/gatekit-m5-seed.txt"
bash "$META5/isolated-seed.sh" > "$SD_OUT"
grep -qx 'rerun_after_wipe=refused' "$SD_OUT"
grep -qx 'rows_after_wipe=0' "$SD_OUT"
grep -qx 'mutant_rerun_after_wipe=restored' "$SD_OUT"

# 검사 #12: 격리 적재 행 집합이 loaded-diff.txt 의 기대와 같다 (최고관리자 전용 행 · 차량 열람 4행 예외 포함)
# 기대: 역할별 적재 행 집합 대조가 ok 이고, rbac 코드 적재 행이 0 이고, 최고관리자 전용 -super 코드가 적재되지 않는다
# 음성: rbac 코드 한 행을 넣은 사본에서 대조가 mismatch 다 (구현 뒤 관측 기록)
LC_OUT="$GD/gatekit-m5-loaded.txt"
bash "$META4/isolated-check.sh" > "$LC_OUT"
grep -qx 'loaded_check=ok' "$LC_OUT"
grep -qx 'rbac_loaded=0' "$LC_OUT"
grep -qx 'super_loaded=0' "$LC_OUT"
test -f "$META5/loaded-diff.txt"

# 검사 #13: 두 축 DDL 상태 (표 있음 · 없음) 에서 상태코드가 같고 표 부재가 평가기 사유 table-absent 로 나온다
# 기대: 두 상태의 샘플 상태코드가 같고, 표 없음 상태의 감시 사유가 table-absent 이며, 거부로 세지 않는다
# 음성: 표 부재를 deny 로 접는 사본에서 status_match 가 no 다 (구현 뒤 관측 기록)
DX_OUT="$GD/gatekit-m5-ddl.txt"
bash "$META5/isolated-ddl-axis.sh" > "$DX_OUT"
grep -qx 'status_match=yes' "$DX_OUT"
grep -qx 'absent_label=table-absent' "$DX_OUT"
grep -qx 'absent_as_deny=no' "$DX_OUT"

# 검사 #14: backlog 는 append 전용이고 새 줄이 정해진 형식이다
# 기대: 부모 기준 커밋의 backlog 가 현재 파일의 앞부분과 바이트가 같고, 새로 붙은 줄이 1개 이상이며 모두 RESOLVED-IN · 이 계획 경로 형식이다
# 음성: 기존 줄 하나를 고친 사본에서 앞부분 비교가 rc 1 이다 (계획 시점에는 새 줄이 없어 붉다)
git show "$PARENT_BASE:$BACKLOG" > "$GD/gatekit-m5-bl-base.md"
BL_N=$(wc -l < "$GD/gatekit-m5-bl-base.md")
head -n "$BL_N" "$BACKLOG" | cmp -s - "$GD/gatekit-m5-bl-base.md"
NEWN=$(tail -n +"$((BL_N + 1))" "$BACKLOG" | grep -c . || true)
[ "$NEWN" -ge 1 ]
BADN=$(tail -n +"$((BL_N + 1))" "$BACKLOG" | grep -vc '^2026-10-19 | RESOLVED-IN | .claude/PRPs/plans/admin-rbac-m5.plan.md | ' || true)
[ "$BADN" = 0 ] || echo "DEVIATION check14 badn=$BADN" >&2

# 검사 #15: 호스트 절대경로 0 · 동그라미 숫자 0 · .claude 줄 번호 인용 0 · 토큰 형태 0 (계획서 · 사이드카 · 메타 산출)
# 기대: 두 파일 모두 호스트 절대경로가 없고, 동그라미 숫자가 없고, 계획서에 .claude 경로의 줄 번호 인용이 없고, 메타 산출 디렉터리에 JWT · Basic · Bearer 형태 문자열이 없다
# 음성: 계획서에 동그라미 숫자 하나를 넣은 사본에서 rc 1 이다 (계획 시점에는 0건으로 통과한다). 메타 산출에 토큰 형태 한 줄을 넣으면 TOK 가 비지 않아 붉다
for f in "$PLAN" "$GATES"; do
  [ "$(grep -c -F "$(printf '/%s/' home)" "$f" || true)" = 0 ]
  [ "$(LC_ALL=C.UTF-8 grep -cP '[\x{2460}-\x{2487}\x{24D0}-\x{24E9}\x{2776}-\x{2780}\x{3260}-\x{326E}]' "$f" || true)" = 0 ]
done
[ "$(grep -cE '\.claude/[^ ]*:[0-9]+' "$PLAN" || true)" = 0 ]
TOK=$(grep -rlE 'eyJ[A-Za-z0-9_-]{8,}|Basic [A-Za-z0-9+/=]{8,}|Bearer [A-Za-z0-9._-]{8,}' "$META5" || true)
[ -z "$TOK" ]

# 검사 #16: plan-kit lint 통과
# 기대: lint-plan.mjs 가 계획서에 rc 0 을 준다 (Gate · Validate · 기대 · 음성 · NOT Building · Acceptance 형식)
# 음성: Gate 가 없는 Task 를 가진 사본에서 lint 가 rc 1 이다 (plan-kit 자체 픽스처로 확인한다)
node .claude/templates/plan-kit/lint-plan.mjs "$PLAN"

echo "gates-m5 ok"
