axmap-cli 1.1.1 → 1.2.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.
@@ -212,7 +212,7 @@ node axmap/governance/gate.mjs --source <소스브랜치> --target <타깃브랜
212
212
 
213
213
  | | | 안 지키면 |
214
214
  |---|---|---|
215
- | **G1** | **자기 표는 세지 않는다** — MR 소스 브랜치 커밋의 author 인 사람의 표는 무효 | 혼자 쓰고 혼자 승인한다 |
215
+ | **G1** | **자기 표는 세지 않는다** — MR 소스 브랜치 커밋의 author 인 사람의 표는 무효 (예외 하나 — 바로 아래) | 혼자 쓰고 혼자 승인한다 |
216
216
  | **G2** | **정책은 언제나 타깃 브랜치**(**합쳐 받는 쪽 브랜치**)**에서 읽는다** | 정족수를 1 로 낮추는 MR 이 **자기 자신의 낮춘 규칙으로** 통과한다 |
217
217
  | **G3** | **표는 커밋(sha)에 묶인다** — 헤드가 바뀌면 효력을 잃는다 | 승인받은 뒤 내용을 갈아끼운다 |
218
218
  | **G4** | **한 사람은 한 표** — id 유일, email 유일 | 명단에 자기를 두 줄 적으면 두 표다 |
@@ -225,6 +225,58 @@ node axmap/governance/gate.mjs --source <소스브랜치> --target <타깃브랜
225
225
  **G2 만 순수 판정 밖에 있다.** 어느 브랜치에서 정책을 읽어오는가는 git 을 만지는
226
226
  일이라 게이트의 책임이다. `src/governance.mjs` 는 **읽어온 정책이 성립하는지**만 본다.
227
227
 
228
+ ### 🔴 G1 의 예외 하나 — 던질 수 있는 사람이 정족수보다 적을 때
229
+
230
+ 오래 쌓는 `<파트>/dev` 브랜치에서는 시간이 갈수록 커밋한 사람이 늘어
231
+ **던질 수 있는 사람이 0 으로 수렴한다.** 실측 (팀 저장소 S15P21E201, 2026-09-07):
232
+
233
+ | 브랜치 | 투표권자 | 그중 소스 브랜치의 author | 던질 수 있는 사람 | 정족수 |
234
+ |---|---|---|---|---|
235
+ | `front/main` ← `front/dev` | 4명 | **4명 전원** | **0명** | 2 |
236
+ | `back/main` ← `back/dev` | 6명 | **6명 전원** | **0명** | 2 |
237
+
238
+ 이건 **"아직 표가 모자라다"(미달)가 아니다.** 표를 더 모아도 안 풀린다 — 던질 수
239
+ 있는 사람 자체가 없기 때문이다. 그 상태에 미달(종료 코드 2)을 내면 부르는 쪽에게
240
+ *"사람에게 부탁하면 된다"* 고 거짓말을 하는 것이고, 사람은 시스템 밖에서
241
+ 탈출한다 (`hotfix/` 를 코드 안에 둔 것과 같은 이유다).
242
+
243
+ 그래서 **그 판정 하나에 한해** G1 을 푼다. 조건 셋을 **모두** 만족할 때만이다.
244
+
245
+ | | |
246
+ |---|---|
247
+ | 1 | 이번 판정의 **정족수를 숫자로 알고 있다** (`judge` 가 `countVotes` 에 넘긴다) |
248
+ | 2 | **G1 이 실제로 명단에서 누군가를 뺐다** — 안 뺐으면 G1 이 만든 교착이 아니다 |
249
+ | 3 | 그러고 **남은 사람이 정족수보다 적다** |
250
+
251
+ 2번이 없으면 *"명단 자체가 정족수보다 작다"*(승계로도 못 채운 **막힌 저장소**)까지
252
+ 이 문으로 샌다. 그건 G1 을 풀어도 아무도 못 던지므로, 판정문에 거짓 사유만
253
+ 한 줄 남는다.
254
+
255
+ **바뀌지 않는 것 — 이게 절반이다.**
256
+
257
+ - **정족수 숫자.** `majorityThreshold` 는 그대로다. 문턱을 낮추면 *"최소 두 사람이
258
+ 본다"* 는 팀의 뜻이 조용히 사라진다. 여기서 푸는 것은 **누가 던질 수 있는가** 하나다
259
+ - **G3 · G4 · G5**, 브랜치 대조, committer(**표를 기록한 git 신원**) 대조,
260
+ `vote_note`, 미래 시각 — 전부 그대로 돈다
261
+ - **fail-closed.** 소스 브랜치의 author 목록을 못 받으면 여전히 판정 불가다
262
+ (`authors-missing`). **"저자를 모를 때" 는 해제 조건이 아니다** — 거기로 새면
263
+ G1 은 켜고 끄는 스위치가 되고, 그건 성질이 아니라 설정이다
264
+
265
+ 🔴 **해제는 조용히 발동하지 않는다.** 승계와 같은 규칙이다 — 판정문이 찍는다.
266
+
267
+ ```
268
+ 🔴 G1(자기 표 배제)이 해제된 판정입니다 — 유효 투표권자 0명 < 정족수 2.
269
+ 저자 본인의 표를 셌습니다.
270
+ ```
271
+
272
+ 해제 덕분에 세어진 사람의 이름까지 남는다. 기계가 읽을 표식은 `tally` 에 있다 —
273
+ `g1Waived` · `eligibleCount` · `threshold` · `selfVoted`. `gate --json` 이 그대로 낸다.
274
+
275
+ **`self_vote` 정책(아래)과는 다른 층이다.** `self_vote` 는 *"누구까지를 자기로
276
+ 보는가"* 를 **사람이 미리 정하는 것**이고, 이 예외는 *"그렇게 정했더니 아무도 못
277
+ 던지게 됐을 때"* **코드가 그 판정에서만 푸는 것**이다. `self_vote: "off"` 면
278
+ 배제할 사람이 없으므로 이 예외는 아예 안 걸린다.
279
+
228
280
  ---
229
281
 
230
282
  ## 종료 코드
@@ -496,7 +548,9 @@ git log --format=%B -1 <커밋> | git interpret-trailers --parse
496
548
  살아 있는 투표권자가 필요 표보다 적으면, 최근 90일 안에 커밋한 author 상위 3명이
497
549
  **임시 투표권**을 갖는다.
498
550
 
499
- - 승계로 들어온 사람도 **자기 표는 못 센다** (G1 예외가 없다)
551
+ - 승계로 들어온 사람도 **자기 표는 못 센다** (G1). 예외는 하나 — 던질 수 있는
552
+ 사람이 정족수보다 적어 G1 이 해제된 판정에서는 승계로 들어온 사람의 자기 표도
553
+ 센다 (위 "G1 의 예외 하나")
500
554
  - 정책에 적힌 사람은 **살아 있지 않아도 투표권을 잃지 않는다.** 생사는 *승계를 켤지*만
501
555
  정한다. 두 달 자리를 비운 사람이 돌아와 던진 표가 안 세지면 그건 fail-closed 가
502
556
  아니라 그냥 틀린 판정이다
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//name": "🔴 npm 의 'axmap' 은 2022년부터 남이 쓰고 있는 다른 꾸러미다(leozin_ 의 Map 라이브러리, 1.1.2). 그래서 `npx axmap` 은 우리 것이 아니고, 누구에게도 그렇게 안내하면 안 된다. 2026-08-28 에 axmap-cli 로 정했다 — 스코프(@사용자/이름)를 쓰지 않는 이유는 스코프가 npm 계정 이름에 묶여서 나중에 팀이나 조직으로 옮길 때 이름이 통째로 바뀌기 때문이다. 사람이 치는 **명령** 이름은 그대로 axmap 이다 (아래 bin) — 꾸러미 이름과 명령 이름은 달라도 된다.",
3
3
  "name": "axmap-cli",
4
- "version": "1.1.1",
4
+ "version": "1.2.0",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
@@ -929,9 +929,15 @@ const FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000
929
929
  * @param {string|null} [args.branch] 소스 브랜치 이름. 주면 표의 branch 와 대조한다
930
930
  * @param {string|null} [args.tipAuthorEmail] 맨 위 커밋의 author email.
931
931
  * `self_vote: "tip"` 일 때만 쓰이고, 그때는 **없으면 판정 불가**다
932
- * @returns {{approvals:number, counted:Array, rejections:Array, invalid:Array}}
932
+ * @param {number|null} [args.threshold] 이번 판정의 정족수. **주면** 자기 표 배제가
933
+ * 판정을 불가능하게 만드는지 볼 수 있다 (아래 waiver). 안 주면 해제는
934
+ * 영영 안 걸린다 — 모르면 안 푸는 쪽이 fail-closed 다. `judge` 가 넣어 준다
935
+ * @returns {{approvals:number, counted:Array, rejections:Array, invalid:Array,
936
+ * g1Waived:boolean, eligibleCount:number, threshold:number|null, selfVoted:Array}}
933
937
  */
934
- export function countVotes({ policy, votes, authorEmails, sha, now, voters = null, branch = null, tipAuthorEmail = null }) {
938
+ export function countVotes({
939
+ policy, votes, authorEmails, sha, now, voters = null, branch = null, tipAuthorEmail = null, threshold = null,
940
+ }) {
935
941
  const roster = voters ?? policy?.voters ?? null
936
942
  if (!Array.isArray(roster) || roster.length === 0) {
937
943
  throw new GovernanceError('투표권자 명단이 없습니다.', { exit: EXIT.POLICY_BROKEN, code: 'voters-missing' })
@@ -966,9 +972,41 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
966
972
  const authors = excludedAuthors(selfVote, authorEmails, tipAuthorEmail)
967
973
  const byEmail = new Map(roster.map((v) => [fold(v.email), v]))
968
974
 
975
+ // ── G1 해제(waiver) — 자기 표 배제가 **판정 자체를 불가능하게 만들 때만** ────
976
+ //
977
+ // 🔴 왜 필요한가 — 실측. 오래 쌓는 `<파트>/dev` 브랜치에서는 시간이 갈수록
978
+ // 커밋한 사람이 늘어 "던질 수 있는 사람" 이 0 으로 수렴한다. 팀 저장소의
979
+ // `front/main` 은 투표권자 4명이 **전원 저자**였고(정족수 2), `back/dev` 는
980
+ // 명단 6명 전원이 저자였다. 이건 "아직 표가 모자라다"(미달, exit 2)가 아니라
981
+ // **표를 더 모아도 영원히 안 풀리는 교착**이다. 재시도가 답이 아닌 상태에
982
+ // 미달 코드를 내는 것은 부르는 쪽에게 거짓말을 하는 것이다.
983
+ //
984
+ // 🔴 해제 조건을 좁게 잡는다. 셋을 **모두** 만족해야 한다.
985
+ // 1. 이번 판정의 정족수를 숫자로 알고 있다 (`threshold` 를 받았다)
986
+ // 2. G1 이 실제로 명단에서 누군가를 뺐다 (`excludedFromRoster > 0`)
987
+ // 3. 그러고 남은 사람이 정족수보다 적다 (`eligibleCount < threshold`)
988
+ // 2번이 없으면 "명단 자체가 정족수보다 작다"(승계로도 못 채운 막힌 저장소)
989
+ // 까지 이 문으로 새어 나온다. 그건 G1 이 만든 교착이 아니므로 G1 을 풀어도
990
+ // 아무도 못 던진다 — 판정문에 거짓 사유만 한 줄 남는다.
991
+ //
992
+ // 🔴 **정족수 숫자는 건드리지 않는다.** 문턱을 낮추면 "두 사람이 본다" 는 팀의
993
+ // 뜻이 조용히 사라진다. 여기서 바꾸는 것은 **누가 던질 수 있는가** 하나다.
994
+ //
995
+ // 🔴 fail-closed 는 그대로다. 저자 목록을 못 받으면 위에서 이미 던졌다
996
+ // (`authors-missing`). "저자를 모를 때" 는 해제 조건이 **아니다** —
997
+ // 모르는 것과 셀 수 없는 것을 통과로 바꾸면 이 층이 없는 것과 같다.
998
+ const eligible = roster.filter((v) => !authors.has(fold(v?.email)))
999
+ const eligibleCount = eligible.length
1000
+ const excludedFromRoster = roster.length - eligibleCount
1001
+ const need = isInt(threshold) && threshold >= 1 ? threshold : null
1002
+ const g1Waived = need !== null && excludedFromRoster > 0 && eligibleCount < need
1003
+
969
1004
  const counted = []
970
1005
  const rejections = []
971
1006
  const invalid = []
1007
+ // 해제 덕분에 세어진 표. **판정문에 이름까지 남긴다** — 해제가 조용히 발동하면
1008
+ // 그건 예외가 아니라 우회로다 (승계를 출력에 찍는 것과 같은 이유).
1009
+ const selfVoted = []
972
1010
  const seen = new Map() // fold(email) -> 이미 센 표
973
1011
 
974
1012
  for (const raw of votes) {
@@ -993,7 +1031,8 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
993
1031
  if (!isStr(v.committerEmail)) { bad('committer-unknown'); continue }
994
1032
  if (fold(v.committerEmail) !== email) { bad('committer-mismatch', v.committerEmail); continue }
995
1033
 
996
- if (authors.has(email)) { bad('self-vote'); continue } // G1
1034
+ // G1 · 자기 표는 센다. 단 해제된 판정에서는 센다 (위 waiver 주석).
1035
+ if (authors.has(email) && !g1Waived) { bad('self-vote'); continue }
997
1036
  if (toMs(v.at, 'votes[].at') > nowMs + FUTURE_TOLERANCE_MS) { bad('future-dated', v.at); continue }
998
1037
  // 🔴 이유 없는 표는 **찬성이든 반대든** 안 센다. 반대야말로 이유가 필요하다 —
999
1038
  // 이유 없는 반대는 판정문에 "누군가 막고 있다" 만 남기고 무엇을 고쳐야
@@ -1004,13 +1043,28 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
1004
1043
  if (seen.has(email)) { bad('duplicate', `이미 센 표: ${seen.get(email).voter}`); continue } // G4
1005
1044
 
1006
1045
  seen.set(email, v)
1046
+ // 해제가 없었으면 여기 못 왔을 표. 판정문이 이름을 찍는다.
1047
+ if (g1Waived && authors.has(email)) selfVoted.push({ voter: v.voter, email: v.email, vote: v.vote })
1007
1048
  if (v.vote === 'approve') counted.push(v)
1008
1049
  // 반대는 정족수 계산에 들어가지 않는다. 이 층이 답하는 질문은 "찬성이 몇인가"
1009
1050
  // 하나뿐이고, 거부권은 다른 제도다 — 만들려면 SPEC 부터 고쳐야 한다.
1010
1051
  else rejections.push(v)
1011
1052
  }
1012
1053
 
1013
- return { approvals: counted.length, counted, rejections, invalid }
1054
+ return {
1055
+ approvals: counted.length,
1056
+ counted,
1057
+ rejections,
1058
+ invalid,
1059
+ // ── 기계가 읽는 해제 표식 ────────────────────────────────────────────
1060
+ // `gate.mjs --json` 이 이 셋을 그대로 내보낸다. 판정문의 한 줄이 지워져도
1061
+ // 이 표식은 남는다 — 나중에 "이 판정이 예외였나" 를 사람이 아니라 코드가
1062
+ // 물어볼 수 있어야 한다.
1063
+ g1Waived,
1064
+ eligibleCount,
1065
+ threshold: need,
1066
+ selfVoted,
1067
+ }
1014
1068
  }
1015
1069
 
1016
1070
  /**
@@ -1084,7 +1138,14 @@ export function judge({
1084
1138
  : null
1085
1139
  const need = rulesFor(changed, policy, { majority })
1086
1140
  const roster = deriveVoters({ policy, contributors, now, threshold: need.threshold })
1087
- const tally = countVotes({ policy, votes, authorEmails, sha, now, voters: roster.voters, branch, tipAuthorEmail })
1141
+ // 🔴 정족수를 **분자 쪽에도** 넘긴다. `countVotes` 숫자가 있어야
1142
+ // "자기 표를 빼고 나면 던질 수 있는 사람이 정족수보다 적은가" 를 볼 수
1143
+ // 있다. 문턱은 여기서 정해지므로(`rulesFor`) 그 값을 그대로 준다 —
1144
+ // 분자가 다른 문턱을 보고 해제를 판단하면 판정만 조용히 틀린다.
1145
+ const tally = countVotes({
1146
+ policy, votes, authorEmails, sha, now, voters: roster.voters, branch, tipAuthorEmail,
1147
+ threshold: need.threshold,
1148
+ })
1088
1149
  const ok = tally.approvals >= need.threshold
1089
1150
  return {
1090
1151
  exit: ok ? EXIT.OK : EXIT.SHORT,
@@ -1141,6 +1202,22 @@ export function formatVerdict(verdict) {
1141
1202
  }
1142
1203
  L.push('')
1143
1204
 
1205
+ // 🔴 자기 표 배제 해제는 **조용히 발동하지 않는다.** 이 줄을 지우면 해제가
1206
+ // 예외가 아니라 우회로가 된다 — 승계 줄을 지울 수 없는 것과 같은 이유다.
1207
+ // 나중에 이력을 보는 사람이 "이 판정은 예외였다" 를 여기서 알아야 한다.
1208
+ if (tally?.g1Waived) {
1209
+ L.push(` 🔴 G1(자기 표 배제)이 해제된 판정입니다 — 유효 투표권자 ${tally.eligibleCount}명 < 정족수 ${threshold}. 저자 본인의 표를 셌습니다.`)
1210
+ L.push(' 오래 쌓인 브랜치는 투표권자가 전부 저자가 되어 던질 수 있는 사람이 0 이 됩니다.')
1211
+ L.push(' 그건 표가 모자란 것이 아니라 표를 더 모아도 안 풀리는 교착이라, 이 판정에 한해서만 풉니다.')
1212
+ L.push(' 🔴 정족수 숫자는 그대로입니다. G3(표는 커밋에 묶인다)·G4(같은 사람의 두 번째 표)·')
1213
+ L.push(' 브랜치 대조·committer(표를 기록한 git 신원) 대조도 전부 그대로 돕니다.')
1214
+ if (tally.selfVoted?.length) {
1215
+ L.push(' 해제 덕분에 센 표')
1216
+ for (const s of tally.selfVoted) L.push(` ! ${s.voter} <${s.email}> ${s.vote}`)
1217
+ }
1218
+ L.push('')
1219
+ }
1220
+
1144
1221
  if (tally.counted.length) {
1145
1222
  L.push(' 센 표')
1146
1223
  for (const c of tally.counted) L.push(` o ${c.voter} <${c.email}> ${c.at}`)
@@ -1173,7 +1250,10 @@ export function formatVerdict(verdict) {
1173
1250
  } else {
1174
1251
  L.push(' 그런데 창 안에 기여자가 없습니다.')
1175
1252
  }
1176
- L.push(' 승계로 들어온 사람도 자기 표는 세지 않습니다 (G1).')
1253
+ // 해제된 판정에서는 줄이 거짓이 된다. 틀린 안내는 안 하는 것만 못하다.
1254
+ L.push(tally?.g1Waived
1255
+ ? ' 이 판정은 자기 표 배제가 해제되어 있어 승계로 들어온 사람의 자기 표도 셉니다 (위 🔴).'
1256
+ : ' 승계로 들어온 사람도 자기 표는 세지 않습니다 (G1).')
1177
1257
  L.push('')
1178
1258
  }
1179
1259
  if (roster.short > 0) {