#!/usr/bin/env bash
# 관리자 RBAC M6c 게이트 본문 (보완 계획 admin-rbac-m6c). 계획서 Validation 이 부른다 (PL9 외부화, 200행 초과 회피).
# 실행 위치: 워크트리 루트. 검사 하나라도 실패하면 set -e 로 거기서 멈춘다. 건너뛰지 않는다.
# 외부 입력은 환경 변수로만 받는다 (커밋 산출물에 경로를 쓰지 않는다):
#   M6C_SCHEMA_DIR                                        스키마 전용 덤프 디렉터리 (02-schema.sql, 읽기만)
#   M6C_IT_MYSQL_PORT · M6C_IT_MYSQL_PASSWORD · M6C_IT_MYSQL_DATABASE   격리 MySQL (m6c-iso- 접두 컨테이너)
#   M6C_IT_MYSQL_CONTAINER                                 같은 컨테이너 이름 (시험군마다 DB 를 다시 적재한다)
# 실패 규약: 빈 값 · 누락 입력은 통과가 아니라 멈춤이다 (need_env). 조건 체인 (&& · ||) 은 쓰지 않는다 (앞 항이 실패해도 set -e 가 멈추지 않는다).
# 자기 참조 주의: 호스트 절대경로 문자열을 이 파일에 직접 쓰지 않는다 (검사 #14 가 이 파일도 센다).
set -euo pipefail
export LC_COLLATE=C
trap 'echo "게이트 실패: 줄 $LINENO: $BASH_COMMAND" >&2' ERR

WT=$(pwd)
GD=$(cd "$(git rev-parse --git-dir)" && pwd)
SRV=insuniverse-server
ADM=insuniverse-admin
SITE=insuniverse-site
SRCR=apps/api/src/modules
SRC=$SRV/$SRCR
ACR=$SRCR/auth/access-control
AC=$SRV/$ACR
META=.claude/meta/2026-10-21-admin-rbac-m6c
M6B=.claude/meta/2026-10-21-admin-rbac-m6b
PLAN=.claude/PRPs/plans/admin-rbac-m6c.plan.md
GATES=.claude/PRPs/plans/admin-rbac-m6c.gates.sh
TABLE=$META/scope-transition-table.md
BK=.claude/plans/codex-findings-backlog.md
ITCFG=$META/jest-itspec.config.cjs
PARENT_BASE=817962cf
SERVER_BASE=700b7d26
ADMIN_BASE=1301b664
SITE_BASE=fc8872a1
MERGED="b2c476a8 53a3e865 f9072f75 ccf1594b ea3c0359"
SM=$AC/scope-monitor.spec.ts
EN=$AC/scope-enforce.spec.ts
HS=$AC/scope-handlers.spec.ts
CONST=$AC/access-control.constants.ts
INTC=$AC/access-control.interceptor.ts
ORG_RACE=$SRC/agency/organization/organization.race.itspec.ts
ORG_RACE_R=$SRCR/agency/organization/organization.race.itspec.ts
FC_RACE_R=$SRCR/fc/agency/agency-fc.race.itspec.ts
MSCOPE_R=$SRCR/money/money-scope.itspec.ts
MQUERY_R=$SRCR/money/money-scope-query.itspec.ts
MBULK_R=$SRCR/money/money-bulk-scope.itspec.ts
MRACE_R=$SRCR/money/money-race.itspec.ts
FBULK_R=$SRCR/fc/agency/fc-bulk-scope.itspec.ts
M6C_SCHEMA_DIR="${M6C_SCHEMA_DIR:-}"
M6C_IT_MYSQL_PORT="${M6C_IT_MYSQL_PORT:-}"
M6C_IT_MYSQL_PASSWORD="${M6C_IT_MYSQL_PASSWORD:-}"
M6C_IT_MYSQL_DATABASE="${M6C_IT_MYSQL_DATABASE:-}"
M6C_IT_MYSQL_CONTAINER="${M6C_IT_MYSQL_CONTAINER:-}"

# 0건은 0 으로 센다 (grep 종료 1 은 0건, 2 는 오류로 실패). 입력은 인자의 파일이거나 표준입력이다.
count() {
  local rc o
  rc=0
  o=$(grep "$@") || rc=$?
  [ "$rc" -le 1 ] || { echo "count: grep 오류 rc $rc" >&2; return 2; }
  if [ -z "$o" ]; then echo 0; else printf '%s\n' "$o" | wc -l | tr -d ' '; fi
}

# 입력 변수가 비었으면 멈춘다 (건너뛰지 않는다).
need_env() {
  local v
  for v in "$@"; do
    [ -n "${!v:-}" ] || { echo "환경 변수 $v 가 필요하다 (건너뛰지 않는다)" >&2; exit 1; }
  done
}

# jest 를 서버 디렉터리에서 돌려 결과 JSON 을 만든다. 로그는 버리지 않는다. 인자: 결과 · 로그 · 설정 · 시험 경로 (서버 기준)
jrun() {
  local out log cfg
  out=$1
  log=$2
  cfg=$3
  shift 3
  rm -f "$out"
  ( cd "$WT/$SRV" && npx jest --json --outputFile="$out" --config "$cfg" "$@" ) >"$log" 2>&1 || true
  [ -s "$out" ] || { echo "jest 결과 없음 ($out) · 로그 끝:" >&2; tail -20 "$log" >&2; return 1; }
}

# 결과 판정: 실패 · 보류 · 할 일 · 런타임 오류 0, 통과 = 총수, 총수 ≥ 하한. 인자: 결과 · 하한
jgreen() {
  node -e 'const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); const min = Number(process.argv[2]); if (j.numFailedTests !== 0 || j.numFailedTestSuites !== 0 || j.numRuntimeErrorTestSuites !== 0 || j.numPendingTests !== 0 || j.numTodoTests !== 0 || j.numPassedTests !== j.numTotalTests || j.numTotalTests < min) { console.error("jgreen: " + JSON.stringify({ failed: j.numFailedTests, pending: j.numPendingTests, passed: j.numPassedTests, total: j.numTotalTests })); process.exit(1); }' "$1" "$2"
}

# 필수 시험 이름이 통과 집합에 있다 (부분 문자열). 인자: 결과 · 이름 여럿
jnames() {
  node -e 'const fs = require("fs"); const [jp, ...need] = process.argv.slice(1); const j = JSON.parse(fs.readFileSync(jp, "utf8")); const ok = []; for (const t of j.testResults) for (const a of t.assertionResults) if (a.status === "passed") ok.push(a.fullName); const miss = need.filter((n) => !ok.some((p) => p.includes(n))); if (miss.length) { console.error("통과 시험 이름 없음: " + miss.join(" | ")); process.exit(1); }' "$@"
}

# 이름이 든 통과 시험이 N 건 이상이다. 인자: 결과 · 이름 조각 · 하한
jcount() {
  node -e 'const fs = require("fs"); const [jp, needle, min] = process.argv.slice(1); const j = JSON.parse(fs.readFileSync(jp, "utf8")); let n = 0; for (const t of j.testResults) for (const a of t.assertionResults) if (a.status === "passed" && a.fullName.includes(needle)) n++; if (n < Number(min)) { console.error("통과 시험 " + needle + " " + n + " 건 < " + min); process.exit(1); }' "$1" "$2" "$3"
}

# 격리 MySQL 환경 (시험 본문이 IT_MYSQL_* 를 읽는다). 값이 없으면 멈춘다.
it_env() {
  need_env M6C_IT_MYSQL_PORT M6C_IT_MYSQL_PASSWORD M6C_IT_MYSQL_DATABASE
  export IT_MYSQL_PORT="$M6C_IT_MYSQL_PORT"
  export IT_MYSQL_PASSWORD="$M6C_IT_MYSQL_PASSWORD"
  export IT_MYSQL_DATABASE="$M6C_IT_MYSQL_DATABASE"
}

# 격리 DB 를 스키마 · 저장 함수 · 마이그레이션 두 개 · 합성 시드로 다시 적재한다. 앞 시험군의 쓰기 (담당 변경 · 적립 행 추가) 가
# 다음 시험군의 정확 개수 판정을 바꾸므로 시험군마다 호출한다 (관측: 경합 시험이 남긴 적립 행 때문에 기존 머니 목록 시험이 붉었다).
# 입력: M6C_IT_MYSQL_CONTAINER (격리 컨테이너 이름, m6c-iso- 접두) · M6C_SCHEMA_DIR. 컨테이너는 호출자가 연다 · 내린다.
iso_reset() {
  need_env M6C_IT_MYSQL_CONTAINER M6C_IT_MYSQL_PASSWORD M6C_SCHEMA_DIR
  local q=(docker exec -i -e MYSQL_PWD="$M6C_IT_MYSQL_PASSWORD" "$M6C_IT_MYSQL_CONTAINER" mysql -h127.0.0.1 -uroot --default-character-set=utf8mb4 -N -B)
  "${q[@]}" -e 'DROP DATABASE IF EXISTS insuniverse; CREATE DATABASE insuniverse;'
  "${q[@]}" insuniverse < "$M6C_SCHEMA_DIR/02-schema.sql"
  "${q[@]}" insuniverse < "$M6C_SCHEMA_DIR/01-functions.sql"
  "${q[@]}" insuniverse < "$WT/$SRV/drizzle/0031_admin_audit.sql"
  "${q[@]}" insuniverse < "$WT/$SRV/drizzle/0036_auth_permission.sql"
  "${q[@]}" insuniverse < "$WT/$META/synthetic-tenants.sql"
}

# 검사 #1: 착수 sha 가 각 저장소 HEAD 의 조상이고, 선행 병합 다섯 개가 서버 착수 sha 의 조상이다
# 기대: 서버 · 관리자 · 사이트 · 부모의 착수 sha 가 각 HEAD 의 조상이다 (rc 0). 선행 병합 다섯 개가 서버 착수 sha 의 조상이다 (rc 0)
# 음성: HEAD 가 HEAD~1 의 조상이 아니므로 `merge-base --is-ancestor HEAD HEAD~1` 가 rc 1 이다 (계획 시점 관측: 서버 저장소에서 확인)
git -C "$SRV" merge-base --is-ancestor "$SERVER_BASE" HEAD
git -C "$ADM" merge-base --is-ancestor "$ADMIN_BASE" HEAD
git -C "$SITE" merge-base --is-ancestor "$SITE_BASE" HEAD
git merge-base --is-ancestor "$PARENT_BASE" HEAD
for c in $MERGED; do git -C "$SRV" merge-base --is-ancestor "$c" "$SERVER_BASE"; done

# 검사 #2: 기준선 헤더가 착수 sha 와 맞고 실패 수 (본문 줄 수) 가 헤더 수와 같다 (jest · tsc)
# 기대: jest-baseline.txt 와 tsc-baseline.txt 의 첫 줄이 「# base-sha: <서버 착수 sha 전체>」 다. 「# failed-count: N」 · 「# errors: N」 의 N 이 본문의 「suite: 」 줄 수 · 「 error TS」 줄 수와 같다
# 음성: 헤더 sha 의 끝 글자를 바꾼 줄은 grep -qx 가 rc 1 이다 (계획 시점 관측: printf 로 확인)
SB=$(git -C "$SRV" rev-parse "$SERVER_BASE^{commit}")
head -1 "$META/jest-baseline.txt" | grep -qx "# base-sha: $SB"
head -1 "$META/tsc-baseline.txt" | grep -qx "# base-sha: $SB"
[ "$(count '^suite: ' "$META/jest-baseline.txt")" = "$(sed -n 's/^# failed-count: //p' "$META/jest-baseline.txt")" ]
[ "$(count ' error TS' "$META/tsc-baseline.txt")" = "$(sed -n 's/^# errors: //p' "$META/tsc-baseline.txt")" ]

# 검사 #3: 이행표 I 행의 id 가 I1–I9 아홉 개로 중복 없이 덮이고, 각 행의 파일 · 핸들러가 비-spec 소스에 실재한다
# 기대: I 행 id 의 서로 다른 수가 9 다. 각 행의 파일이 실재하고 핸들러 이름 뒤 「(」 이 그 파일에 있다
# 음성: 핸들러 이름 하나를 없는 이름으로 바꾼 사본 표에서 핸들러 grep 이 rc 1 이다 (구현 뒤 관측 기록)
[ "$(grep -oE '^\| I[1-9] ' "$TABLE" | tr -d '| ' | sort -u | wc -l | tr -d ' ')" = 9 ]
awk -F'|' '/^\| I[1-9] \|/ { f=$3; h=$4; gsub(/ /,"",f); gsub(/ /,"",h); print f, h }' "$TABLE" |
while read -r f h; do
  p=$(find "$SRC" -name "$f" -not -name '*.spec.ts' | head -1)
  [ -n "$p" ]
  grep -q "$h(" "$p"
done

# 검사 #4: 이행표 I 행의 조인 칸마다 tn_*.열 토큰이 스키마 덤프의 CREATE 블록 안에 있다 (I9 는 '-' 만). 「미검증」 표지 0
# 기대: 조인 칸의 tn_*.열 토큰 전부가 M6C_SCHEMA_DIR/02-schema.sql 의 해당 테이블 블록에 있다. I 행마다 토큰이 1 개 이상이다
# 음성: I1 조인 칸을 '-' 로 바꾼 사본에서 토큰 검사 rc 1 이다 (구현 뒤 관측 기록)
need_env M6C_SCHEMA_DIR
schema_has() {
  awk -v t="\`$1\`" -v c="\`$2\`" 'index($0,"CREATE TABLE " t){f=1} f && index($0,c){ok=1} f && /ENGINE=/{f=0} END{exit !ok}' "$M6C_SCHEMA_DIR/02-schema.sql"
}
grep -oE 'tn_[a-z_]+\.[a-z_]+' "$TABLE" | sort -u | while IFS=. read -r t c; do
  schema_has "$t" "$c"
done
awk -F'|' '/^\| I[1-9] \|/ { j=$7; gsub(/^ +| +$/,"",j); i=$2; gsub(/ /,"",i); print i "\t" j }' "$TABLE" |
while IFS=$'\t' read -r it j; do
  if [ "$it" = I9 ]; then
    [ "$j" = "-" ]
  else
    printf '%s\n' "$j" | grep -qE 'tn_[a-z_]+\.[a-z_]+'
  fi
done
[ "$(awk -F'|' '/^\| I[1-9] \|/ && $7 ~ /미검증/' "$TABLE" | wc -l | tr -d ' ')" = 0 ]

# 검사 #5: 측정 셀 규칙 — 셀 집합 = I 행 x 신원 6 (표 기반), 기대 상태 일치, 양성 대조, 구조적 0 공시 선언과 같음, 하네스 sha, 자가 시험 (변이 3종)
# 기대: scope-would-deny.json 의 셀 집합이 I 행 전부 x 신원 6종과 정확히 같다 (개수 · 중복 · 누락 · 표 밖 셀 모두 실패). 셀의 status 와 expected_status 가 표의 기대 상태와 같다. 양성: 기대 200 셀이 있는 행마다 소유 행 1 이상인 셀의 allow 가 1 이상이다. harness_sha256 이 scope-measure.mjs 와 같다
# 음성: 셀 전부의 allow 를 0 으로 바꾼 사본 · 셀 하나를 뺀 사본 · 셀 하나를 중복한 사본 셋 다 rc 1 이다 (아래 자가 시험에서 실행)
C5_JS='
const fs = require("fs"), crypto = require("crypto");
const [jp, tp, ip, hp] = process.argv.slice(1);
const fail = (m) => { console.error("검사 #5: " + m); process.exit(1); };
const ids = JSON.parse(fs.readFileSync(ip, "utf8")).identities;
if (ids.length !== 6) fail("신원 수 " + ids.length);
const rows = fs.readFileSync(tp, "utf8").split("\n").filter((l) => /^\| I[1-9] \|/.test(l)).map((l) => l.split("|").map((s) => s.trim()));
if (rows.length < 1) fail("이행표 I 행 없음");
const exp = new Map();
for (const r of rows) {
  const item = r[1], file = r[2], h = r[3], codes = (r[9] || "").split("/");
  if (codes.length !== 6 || !codes.every((c) => /^[1-4][0-9][0-9]$/.test(c))) fail("기대 상태 칸 " + item + "/" + h);
  codes.forEach((c, i) => exp.set([item, file, h, ids[i].id].join("|"), Number(c)));
}
if (exp.size !== rows.length * 6) fail("표 셀 수 " + exp.size + " 기대 " + rows.length * 6);
const j = JSON.parse(fs.readFileSync(jp, "utf8"));
const cells = j.cells || [];
if (cells.length !== exp.size) fail("셀 수 " + cells.length + " 기대 " + exp.size);
const seen = new Set();
let wd = 0;
for (const c of cells) {
  const k = [c.item, c.file, c.handler, c.identity].join("|");
  if (!exp.has(k)) fail("표에 없는 셀 " + k);
  if (seen.has(k)) fail("중복 셀 " + k);
  seen.add(k);
  const e = exp.get(k);
  if (c.status !== e) fail("상태 " + c.status + " 기대 " + e + " " + k);
  if (c.expected_status !== e) fail("JSON 기대 상태가 표와 다름 " + k);
  if (c.item === "I9" && c.rows !== 0) fail("I9 행 0 아님");
  wd += c.would_deny || 0;
}
const byRoute = new Map();
for (const c of cells) {
  const rk = [c.item, c.file, c.handler].join("|");
  if (!byRoute.has(rk)) byRoute.set(rk, []);
  byRoute.get(rk).push(c);
}
for (const [rk, cs] of byRoute) {
  const e200 = cs.filter((c) => exp.get([c.item, c.file, c.handler, c.identity].join("|")) === 200);
  if (e200.length === 0) continue;
  const pos = e200.filter((c) => c.owned_rows > 0);
  if (pos.length === 0) fail("양성 셀 없음 " + rk);
  for (const c of pos) if (!(c.allow >= 1)) fail("양성 대조 실패 " + rk + " " + c.identity);
}
if (wd < 1) fail("범위 밖 합 0");
const STRUCT = ["I1|agency-admin.controller.ts|getAllAgencyList|org-A1", "I1|agency-admin.controller.ts|getAllAgencyList|org-A2"];
const structSet = new Set(cells.filter((c) => /^org-/.test(c.identity) && c.status === 200 && (c.rows ?? 0) > 0 && (c.owned_rows ?? 0) === 0 && (c.allow ?? 0) === 0).map((c) => [c.item, c.file, c.handler, c.identity].join("|")));
if (structSet.size !== STRUCT.length || STRUCT.some((k) => !structSet.has(k))) fail("구조적 0 셀이 선언과 다르다: " + [...structSet].join(", "));
const h = crypto.createHash("sha256").update(fs.readFileSync(hp)).digest("hex");
if (j.harness_sha256 !== h) fail("하네스 sha256 불일치");
const t = j.tenants || {};
if (t.agency !== 2 || t.sales !== 2 || t.org !== 2) fail("테넌트 표지");
console.log("검사 #5 ok 셀 " + cells.length);
'
C5() { node -e "$C5_JS" "$1" "$TABLE" "$META/identities-scope.json" "$META/scope-measure.mjs"; }
C5_MUT_JS='
const fs = require("fs"); const [src, m, out] = process.argv.slice(1);
const j = JSON.parse(fs.readFileSync(src, "utf8"));
if (m === "zero") j.cells.forEach((c) => { if (c.owned_rows > 0) c.allow = 0; });
if (m === "drop") j.cells.pop();
if (m === "dup") j.cells.push({ ...j.cells[0] });
fs.writeFileSync(out, JSON.stringify(j));
'
C5_TMP=$GD/gatekit-m6c-c5.json
for mut in zero drop dup; do
  node -e "$C5_MUT_JS" "$META/scope-would-deny.json" "$mut" "$C5_TMP"
  if C5 "$C5_TMP" >/dev/null 2>&1; then echo "검사 #5 자가 시험 실패: 변이 $mut 가 통과했다" >&2; exit 1; fi
done
C5 "$META/scope-would-deny.json"

# 검사 #6: 단건 읽기 함수 네 개 (assertSingleOwnedAgc · assertSingleOrg · guardSingleOdd · guardSingleRow) 의 본문이 judgeSingleScope 를 쓰고 judgeRowScope · assertSales · guardSales 를 쓰지 않으며 상수 가드 줄이 있다. 단건 읽기 호출은 아홉 곳이다
# 기대: 네 함수 본문에 judgeSingleScope( 가 1 이상이고 judgeRowScope( · assertSales · guardSales 가 0 이고 SCOPE_ENFORCE_SALES_CORP 가 1 이상이다. 컨트롤러의 「await this.」 단건 읽기 호출 (assertSingleOwnedAgc · assertSingleOrg · guardSingleOdd · guardSingleRow) 이 9 다. spec 밖 judgeSingleScope( 호출처 (정의 줄 제외) 가 3 이상이다
# 음성: 현행 assertSalesOwnedAgc 본문은 judgeRowScope( 가 1 이상이라 같은 조건을 그 본문에 세우면 실패한다 (계획 시점 관측)
for fn in "assertSingleOrg:$SRC/agency/organization/organization-admin.controller.ts" "assertSingleOwnedAgc:$SRC/money/money-admin.controller.ts" "guardSingleOdd:$SRC/analyze/analyze-admin.controller.ts" "guardSingleRow:$SRC/analyze/analyze-admin.controller.ts"; do
  name=${fn%%:*}
  file=${fn#*:}
  body=$(awk -v n="$name(" 'index($0, n) && /(private|async|public)/ {f=1} f {print} f && /^  }/ {exit}' "$file")
  [ "$(printf '%s\n' "$body" | count 'judgeSingleScope(')" -ge 1 ]
  [ "$(printf '%s\n' "$body" | count 'judgeRowScope(')" = 0 ]
  [ "$(printf '%s\n' "$body" | count -E 'assertSales|guardSales')" = 0 ]
  [ "$(printf '%s\n' "$body" | count 'SCOPE_ENFORCE_SALES_CORP')" -ge 1 ]
done
[ "$(grep -rhoE 'await this\.(assertSingleOwnedAgc|assertSingleOrg|guardSingleOdd|guardSingleRow)\(' --include='*-admin.controller.ts' "$SRC" | wc -l | tr -d ' ')" = 9 ]
[ "$(grep -rn 'judgeSingleScope(' --include='*.ts' "$SRC" | grep -v '\.spec\.ts:' | grep -vc 'export function judgeSingleScope(' || true)" -ge 3 ]

# 검사 #7: 범위 판정 시험 (단건 3의미 · 신원별 키 · 조직 접두 경계 · 기업 행 조직관리자 거부 · 키 부재 deny · 호출처 의미 · memId 보존) 이 녹색이다
# 기대: scope-monitor.spec.ts · scope-enforce.spec.ts · scope-handlers.spec.ts 의 통과 시험 이름에 열 이름이 있고 실패 · 보류 · 할 일 · 런타임 오류가 0 이다
# 음성: judgeSingleScope 의 키 부재 분기를 예외 던짐으로 바꾼 사본에서 「키 부재 deny」 시험이 붉어 실패 수가 1 이상이다 (구현 뒤 관측 기록)
J7=$GD/gatekit-m6c-j7.json
jrun "$J7" "$GD/gatekit-m6c-j7.log" jest.config.js "$ACR/scope-monitor.spec.ts" "$ACR/scope-enforce.spec.ts" "$ACR/scope-handlers.spec.ts"
jgreen "$J7" 1
jnames "$J7" "단건 영업 sagId" "단건 기업 agcId" "단건 조직관리자 orgPath" "조직 접두 경계" "기업 행 조직관리자 거부" "키 부재 deny" "관리자 memId 거부" "3의미 동일 키" "호출처 의미 고정" "memId 경로 보존" "corpScope 미지값 deny" "빈 신원 orgPath deny" "상수 OFF 단건 통과" "관리자 래퍼 통과"

# 검사 #8: 일괄 · 변경 라우트 인벤토리, 전건 거부 시험, 담당 변경 경합 (격리 MySQL), 반사실 시험이 녹색이다
# 기대: 인벤토리 (bulk-inventory.md) 의 B 행 수가 grep 수 (머니 · 소속 · 조직 디렉터리의 주석 밖 변경 라우트 + board · popup · search 의 업로드 · 미리보기 라우트) 와 같고 1 이상이다. 「일괄」 「단건」 중 하나이고, 판정 칸은 「판정있음」 「판정추가」 「이연」 중 하나이며, 「이연」 행은 근거 칸이 비지 않는다. 격리 실행의 통과 시험 이름에 고정 이름 (경합 4 · 네 경로 각각의 반사실 4 · PG 3 · 전건 거부 8) 과 「시험」 칸의 이름 전부가 있고, 실패 · 보류 · 할 일 · 런타임 오류가 0 이다. 「일괄」 「판정있음」 「판정추가」 행의 「시험」 칸이 비었거나 「-」 인 행이 0 이다
# 음성: 인벤토리 행 하나를 지운 사본에서 행 수가 grep 수와 달라 rc 1 이다 (구현 뒤 관측 기록)
DOM="$SRC/money $SRC/fc/agency $SRC/agency/organization"
D8=$(grep -rhE '^\s*@(Post|Put|Patch|Delete)\(' --include='*-admin.controller.ts' $DOM | wc -l | tr -d ' ')
U8=$(grep -rhE '^\s*@Post\(.*(upload|preview)' --include='*-admin.controller.ts' "$SRC/board" "$SRC/popup" "$SRC/search" | wc -l | tr -d ' ')
ROUTES=$((D8 + U8))
[ "$ROUTES" -ge 1 ]
[ "$(count -E '^\| B[0-9]+ ' "$META/bulk-inventory.md")" = "$ROUTES" ]
[ "$(awk -F'|' '/^\| B[0-9]+ \|/ { j=$5; k=$6; gsub(/ /,"",j); gsub(/ /,"",k); if (j!="일괄" && j!="단건") bad++; if (k!="판정있음" && k!="판정추가" && k!="이연") bad++ } END { print bad+0 }' "$META/bulk-inventory.md")" = 0 ]
[ "$(awk -F'|' '/^\| B[0-9]+ \|/ { k=$6; r=$7; gsub(/ /,"",k); gsub(/^ +| +$/,"",r); if (k=="이연" && r=="") bad++ } END { print bad+0 }' "$META/bulk-inventory.md")" = 0 ]
it_env
J8=$GD/gatekit-m6c-j8.json
iso_reset
jrun "$J8" "$GD/gatekit-m6c-j8.log" "$WT/$ITCFG" "$MRACE_R" "$FC_RACE_R" "$MBULK_R" "$FBULK_R"
jgreen "$J8" 19
jnames "$J8" "결제 취소 담당 변경 경합 · 반사실: 잠금 제거 시 위반 재현" "지정 적립 담당 변경 경합 · 반사실: 잠금 제거 시 위반 재현" "환불 담당 변경 경합 · 반사실: 잠금 제거 시 위반 재현" "조직 권한 업로드 담당 변경 경합 · 반사실: 잠금 제거 시 위반 재현"
jnames "$J8" "결제 취소 담당 변경 경합" "지정 적립 담당 변경 경합" "환불 담당 변경 경합" "조직 권한 업로드 담당 변경 경합" "판정 거부 시 PG 미호출" "PG 호출 뒤 커밋 실패 조정 로그" "PG 응답 유실 뒤 조정 로그" "지정 적립 전건 거부" "지정 적립 엑셀 전건 거부" "지정 지급 엑셀 전건 거부" "소속 업로드 전건 거부" "조직 권한 업로드 전건 거부" "계정 수정 업로드 전건 거부" "구독 업로드 전건 거부" "환불 거부 시 행 변화 0"
[ "$(awk -F'|' '/^\| B[0-9]+ \|/ { j=$5; k=$6; t=$8; gsub(/ /,"",j); gsub(/ /,"",k); gsub(/^ +| +$/,"",t); if ((j=="일괄" || k=="판정있음" || k=="판정추가") && (t=="" || t=="-")) bad++ } END { print bad+0 }' "$META/bulk-inventory.md")" = 0 ]
NAMES8B=()
mapfile -t NAMES8B < <(awk -F'|' '/^\| B[0-9]+ \|/ { j=$5; k=$6; t=$8; gsub(/ /,"",j); gsub(/ /,"",k); gsub(/^ +| +$/,"",t); if (j=="일괄" || k=="판정있음" || k=="판정추가") print t }' "$META/bulk-inventory.md")
[ "${#NAMES8B[@]}" -ge 1 ]
jnames "$J8" "${NAMES8B[@]}"

# 검사 #9: 격리 질의의 타 기업 행 0 · 행 수 일치 · 행 수 하한 1 이 질의 종류 11 개 각각에 대해 통과하고, 분석 집계 합이 일치한다
# 기대: 두 격리 시험 파일이 녹색이다. 종류별 이름 「<종류> 타 기업 행 0」 「<종류> 행 수 일치」 「<종류> 행 수 하한 1」 과 「분석 집계 합 일치」 (34 이름) 가 모두 통과 집합에 있다. 실행 시험 수가 34 이상이다
# 음성: 생성기 절을 뺀 사본 질의에서 「지갑 목록 타 기업 행 0」 시험이 붉어 실패 수가 1 이상이다 (구현 뒤 관측 기록)
KINDS=("지갑 목록" "결제 목록" "기업 이력" "적립 목록" "적립 개수" "환불 목록" "환불 count" "분석 목록" "분석 집계" "분석 내보내기" "단건 상세")
NAMES9=()
for k in "${KINDS[@]}"; do NAMES9+=("$k 타 기업 행 0" "$k 행 수 일치" "$k 행 수 하한 1"); done
NAMES9+=("분석 집계 합 일치")
it_env
J9=$GD/gatekit-m6c-j9.json
iso_reset
jrun "$J9" "$GD/gatekit-m6c-j9.log" "$WT/$ITCFG" "$MSCOPE_R" "$MQUERY_R"
jgreen "$J9" 34
jnames "$J9" "${NAMES9[@]}"

echo "DIAG: #10 skipped (commit and probe pending)"
# 검사 #11: 강제 상수는 SCOPE_ENFORCE_SALES_CORP 한 줄이고 false 다. 비-시험 소스에 true 가 0 건이다
# 기대: 상수 파일의 SCOPE_ENFORCE_ export 가 1 개이고, 정확히 「export const SCOPE_ENFORCE_SALES_CORP = false as const;」 다. 비-시험 소스의 「SCOPE_ENFORCE_… = true」 계수가 0 이다
# 음성: 상수를 true 로 바꾼 사본에서 정확 일치 grep 이 rc 1 이다 (계획 시점 관측: sed 로 사본을 만들어 확인)
[ "$(count '^export const SCOPE_ENFORCE_' "$CONST")" = 1 ]
grep -qx 'export const SCOPE_ENFORCE_SALES_CORP = false as const;' "$CONST"
[ "$(count 'SCOPE_ENFORCE_[A-Z_]* = true' -r "$SRC" --include='*.ts' --exclude='*.spec.ts' --exclude='*.itspec.ts')" = 0 ]

# 검사 #12: 소속 쓰기 ↔ 조직 삭제 양방향 경합 (정방향 · 역방향) 과 조직 라우트 쓰기 잠금이 녹색이고, 구 순서 반사실 시험이 1213 을 재현한다
# 기대: organization.race.itspec.ts 의 통과 시험 이름에 「소속 쓰기 후 삭제」 「삭제 후 소속 쓰기」 「구 순서 반사실」 「조직 라우트 쓰기 잠금」 「삭제 경로 회원 선행 잠금」 이 있고 실패 · 보류 · 할 일 · 런타임 오류가 0 이다. 주석이 아니고 「.not.」 이 없는 줄에 1213 을 확인하는 긍정 expect 단언이 1 건 이상이다
# 음성: 잠금 순서를 구 순서로 되돌린 사본에서 정방향 시험이 1213 을 받아 실패 수가 1 이상이다. 1213 이 주석 한 줄뿐인 사본에서 단언 계수가 0 이다 (계획 시점 관측)
it_env
J12=$GD/gatekit-m6c-j12.json
iso_reset
jrun "$J12" "$GD/gatekit-m6c-j12.log" "$WT/$ITCFG" "$ORG_RACE_R"
jgreen "$J12" 5
jnames "$J12" "소속 쓰기 후 삭제" "삭제 후 소속 쓰기" "구 순서 반사실" "조직 라우트 쓰기 잠금" "삭제 경로 회원 선행 잠금"
[ "$(grep -v '^[[:space:]]*//' "$ORG_RACE" | grep -v '\.not\.' | count -E '(expect|toThrow|toMatch).*1213|1213.*(expect|toThrow|toMatch)')" -ge 1 ]

# 검사 #13: 백로그는 append 전용이고, 추가 줄은 2026-10-22 이후 날짜 형식이며, 이 계획의 RESOLVED-IN 줄이 1건 이상이다
# 기대: 계획 기준 커밋 (817962cf) 대비 제거된 줄이 0 이다. 추가된 줄은 모두 「2026-10-22 이후 날짜 | 」 로 시작한다. 「RESOLVED-IN | .claude/PRPs/plans/admin-rbac-m6c.plan.md」 줄이 1건 이상이다
# 음성: 날짜가 2026-10-21 인 추가 줄을 넣은 사본에서 날짜 식이 rc 1 이다 (계획 시점 관측: printf 로 확인)
[ "$(git diff --no-ext-diff --unified=0 "$PARENT_BASE" -- "$BK" | count '^-[^-]')" = 0 ]
[ "$(git diff --no-ext-diff --unified=0 "$PARENT_BASE" -- "$BK" | grep '^+[^+]' | count -v '^+2026-10-2[2-9] | ')" = 0 ]
[ "$(git diff --no-ext-diff --unified=0 "$PARENT_BASE" -- "$BK" | count '^+2026-10-2[2-9] | RESOLVED-IN | \.claude/PRPs/plans/admin-rbac-m6c\.plan\.md | ')" -ge 1 ]

# 검사 #14: 텍스트 위생 — 계획서 · 사이드카 · 메타 산출에 호스트 절대경로 0 · 동그라미 숫자 0 · 토큰 형태 0
# 기대: 세 대상의 호스트 절대경로 줄 수 0, 동그라미 숫자 (U+2460–U+24FF) 줄 수 0, 토큰 형태 줄 수 0. 메타 디렉터리가 있다
# 음성: 호스트 절대경로 한 줄을 넣은 사본에서 경로 줄 수가 1 이다 (계획 시점 관측: 사본 한 줄로 확인)
HP1=/hom
HP2=e/
[ -d "$META" ]
[ "$(grep -rIF "$HP1$HP2" "$PLAN" "$GATES" "$META" 2>/dev/null | wc -l | tr -d ' ')" = 0 ]
[ "$(perl -CSD -ne 'print if /[\x{2460}-\x{24FF}]/' "$PLAN" "$GATES" "$META"/* | wc -l | tr -d ' ')" = 0 ]
[ "$(grep -rIE 'gh[p]_|sk-[A-Za-z0-9]{20}|AKIA[0-9A-Z]{16}' "$PLAN" "$GATES" "$META" 2>/dev/null | wc -l | tr -d ' ')" = 0 ]

# 검사 #15: plan-kit lint 가 계획서에서 위반 0 건이다 (종료 코드와 출력 줄을 둘 다 본다. 경고 PL10 은 세지 않는다)
# 기대: lint-plan.mjs 가 rc 0 이고, 출력에서 PL10 이 아닌 「 PLn 」 줄이 0 개다
# 음성: 「Gate: #99」 를 넣은 사본 계획서에서 PL5 줄이 1 개 나와 rc 1 이다 (계획 시점 관측: 사본으로 확인)
LRC=0
LINT=$(node .claude/templates/plan-kit/lint-plan.mjs "$PLAN" 2>&1) || LRC=$?
[ "$LRC" = 0 ]
[ "$(printf '%s\n' "$LINT" | grep -v ' PL10 ' | count -E ' PL[0-9]+ ')" = 0 ]

# 검사 #16: 운영 검증 규칙 (2026-10-21) — 계획의 Tasks · Validation · Acceptance 절과 메타 산출에 장기 관측형 완료 조건 문구가 0 건이다
# 기대: 위 절과 메타 파일에서 「다음 릴리즈」 「배포 뒤」 「배포 후 」 「일 관측」 「메트릭으로 센」 「장기 관측」 계수가 0 이다
# 음성: 「배포 뒤 7일 관측」 한 줄을 넣은 사본 절에서 계수가 1 이다 (계획 시점 관측: printf 로 확인)
SECT=$(awk '/^## (Tasks|Validation|Acceptance)$/ {f=1; next} /^## / {f=0} f' "$PLAN")
[ "$(printf '%s\n' "$SECT" | count -E '다음 릴리즈|배포 뒤|배포 후 |일 관측|메트릭으로 센|장기 관측')" = 0 ]
[ "$(cat "$META"/*.md 2>/dev/null | count -E '다음 릴리즈|배포 뒤|배포 후 |일 관측|메트릭으로 센|장기 관측')" = 0 ]

# 검사 #17: 감시 인터셉터는 범위 함수를 부르지 않는다 (감시 OFF · 응답 불변 전제)
# 기대: access-control.interceptor.ts 에서 judgeRowScope · judgeSingleScope · scope-monitor 계수가 0 이다
# 음성: 인터셉터에 judgeRowScope 가 든 줄을 넣은 사본에서 계수가 1 이다 (계획 시점 관측: 현재 0)
[ "$(count -E 'judgeRowScope|judgeSingleScope|scope-monitor' "$INTC")" = 0 ]

# 검사 #18: 서버 CI 목록 (api.yml) 이 착수 대비 바뀌지 않았다
# 기대: 착수 sha 와 작업 트리 사이에 .github/workflows/api.yml 의 차이가 없다 (rc 0)
# 음성: api.yml 에 한 줄을 더한 사본 작업 트리에서 diff --quiet 가 rc 1 이다 (계획 시점 관측: 착수 sha 기준 rc 0)
git -C "$SRV" diff --no-ext-diff --quiet "$SERVER_BASE" -- .github/workflows/api.yml

# 검사 #19: 앞 마일스톤 권한 · 범위 spec 의 통과 집합이 기준선과 같다 (기준선의 「pass: 」 줄 전부가 이번 실행에서 통과)
# 기대: 기준선의 「pass: 」 줄 수가 1 이상이고, 실행 결과의 실패 · 보류가 0 이며, 각 기준선 이름이 통과 집합에 있다
# 음성: 기준선의 한 줄 이름을 바꾼 사본에서 이름 검사가 rc 1 이다 (구현 뒤 관측 기록)
J19=$GD/gatekit-m6c-j19.json
N19=$(count '^pass: ' "$META/prior-specs-baseline.txt")
[ "$N19" -ge 1 ]
NAMES19=()
mapfile -t NAMES19 < <(sed -n 's/^pass: //p' "$META/prior-specs-baseline.txt")
jrun "$J19" "$GD/gatekit-m6c-j19.log" jest.config.js "$SRCR/auth/"
jgreen "$J19" "$N19"
jnames "$J19" "${NAMES19[@]}"

echo "GATES_OK m6c"
