axmap-cli 0.0.1 → 0.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.
@@ -24,6 +24,7 @@
24
24
  * ── 이 층이 지키는 성질 (G1~G5) ──────────────────────────────────────────
25
25
  *
26
26
  * G1 자기 표는 세지 않는다 — 소스 브랜치 커밋의 author 인 사람의 표는 무효
27
+ * (누구까지를 "author" 로 볼지는 정책의 `self_vote` 가 정한다. 기본은 전부)
27
28
  * G2 정책은 언제나 **타깃 브랜치**에서 읽는다 (이 파일의 밖 — 게이트의 책임)
28
29
  * G3 표는 커밋(sha)에 묶인다 — 헤드가 바뀌면 효력을 잃는다. 사라지지는 않는다
29
30
  * G4 한 사람은 한 표 — email 이 유일 키다
@@ -103,6 +104,220 @@ const fold = (v) => String(v ?? '').trim().toLowerCase()
103
104
  const isInt = (v) => Number.isInteger(v)
104
105
  const isStr = (v) => typeof v === 'string' && v.trim() !== ''
105
106
 
107
+ // ---------------------------------------------------------------------------
108
+ // 자기 표 배제(G1)의 **범위** — 정책의 `self_vote`
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * `self_vote` 에 적을 수 있는 값. **G1 을 끄는 스위치가 아니라 범위를 정하는 값**이다.
113
+ *
114
+ * `"authors"` 소스 브랜치의 **모든** 커밋 author 를 배제한다 — 지금까지의 동작
115
+ * `"tip"` 소스 브랜치 **맨 위 커밋(tip)의 author 한 명만** 배제한다.
116
+ * 맨 위 커밋은 표가 묶이는 sha 자신이다 (G3), 즉 "이번에 머지될
117
+ * 커밋을 만든 사람" 이다
118
+ * `"off"` 아무도 배제하지 않는다
119
+ *
120
+ * 🔴 **필드가 없으면 `"authors"` 다.** 이 옵션이 없던 시절에 쓰던 저장소의 판정이
121
+ * 조용히 바뀌면 안 된다. 거버넌스에서 조용한 변화는 우회로와 구분되지 않는다.
122
+ *
123
+ * 🔴 **왜 필요했나 — 실측.** 오래 쌓는 `<파트>/dev` 브랜치에서는 시간이 갈수록
124
+ * 커밋한 사람이 늘어 던질 수 있는 사람이 0 으로 수렴한다. 팀 저장소의
125
+ * `common/dev` 는 투표권자 4명 중 3명이 커밋해서 **던질 수 있는 사람 1명 <
126
+ * 필요 2표** 였다. 이건 "아직 표가 모자라다"(미달)가 아니라 **영구히 잠긴 것**
127
+ * 이고, 표를 더 모아도 풀리지 않는다.
128
+ *
129
+ * 🔴 그래도 `"off"` 를 기본으로 두지 않는 이유: G1 이 실제로 막는 것은 **"혼자
130
+ * 올리고 혼자 통과"** 하나다. `"tip"` 은 그것을 여전히 막는다 — 머지될 커밋을
131
+ * 만든 사람은 못 던진다. `"off"` 는 그 마지막 한 겹까지 없앤다.
132
+ */
133
+ export const SELF_VOTE_MODES = ['authors', 'tip', 'off']
134
+
135
+ /** `self_vote` 를 안 적어 두었을 때의 값. **바꾸면 기존 저장소의 판정이 바뀐다.** */
136
+ export const DEFAULT_SELF_VOTE = 'authors'
137
+
138
+ const selfVoteList = () => SELF_VOTE_MODES.map((m) => `"${m}"`).join(' · ')
139
+
140
+ /** 값 하나가 `self_vote` 로 성립하는가. 없는 것(`undefined`/`null`)은 성립으로 친다. */
141
+ const isSelfVoteValue = (v) => v === undefined || v === null || (typeof v === 'string' && SELF_VOTE_MODES.includes(v))
142
+
143
+ /**
144
+ * 정책에서 자기 표 배제 범위를 읽는다. **없으면 기본값, 이상하면 던진다.**
145
+ *
146
+ * 🔴 이상한 값을 조용히 기본값으로 떨어뜨리지 않는다. 오타 하나가 배제 범위를
147
+ * 바꾸면 그건 "표가 모자란다" 가 아니라 **틀린 답을 자신 있게 낸 것**이다.
148
+ * 정상 경로에서는 `validatePolicy` 가 먼저 잡으므로 여기까지 오지 않는다 —
149
+ * 이 함수의 throw 는 정책 검증을 건너뛰고 부르는 쪽을 위한 마지막 문이다.
150
+ */
151
+ export function selfVoteMode(policy) {
152
+ const raw = policy?.self_vote
153
+ if (raw === undefined || raw === null) return DEFAULT_SELF_VOTE
154
+ if (!isSelfVoteValue(raw)) {
155
+ throw new GovernanceError(
156
+ `\`self_vote\` 는 ${selfVoteList()} 중 하나여야 합니다: ${JSON.stringify(raw)}`,
157
+ { code: 'self-vote-invalid' },
158
+ )
159
+ }
160
+ return raw
161
+ }
162
+
163
+ /**
164
+ * 이번 판정에서 **표를 못 던지는 사람들**의 email 집합 (비교용으로 접은 값).
165
+ *
166
+ * 🔴 **`majorityThreshold`(분모)와 `countVotes`(분자)가 이 함수 하나만 쓴다.**
167
+ * 둘이 다른 기준으로 세면 "필요한 표" 와 "센 표" 의 분모가 어긋나고, 그때는
168
+ * 아무 에러 없이 판정만 틀린다 — 이 층에서 제일 나쁜 실패 모양이다.
169
+ *
170
+ * @param {string} mode `selfVoteMode()` 가 돌려준 값
171
+ * @param {string[]} authorEmails 소스 브랜치 커밋들의 author email
172
+ * @param {string|null} tipAuthorEmail 맨 위 커밋의 author email (`"tip"` 에서만 쓴다)
173
+ */
174
+ function excludedAuthors(mode, authorEmails, tipAuthorEmail) {
175
+ if (mode === 'off') return new Set()
176
+ if (mode === 'tip') {
177
+ if (!isStr(tipAuthorEmail)) {
178
+ // 맨 위 커밋의 author 를 모르면 **누구를 빼야 하는지 자체를 모른다.**
179
+ // 미달이 아니라 판정 불가다 (G5 — 못 세면 통과가 아니다).
180
+ throw new GovernanceError(
181
+ 'self_vote 가 "tip" 인데 소스 브랜치 맨 위 커밋의 author email 을 받지 못했습니다.\n'
182
+ + ' 누구를 빼야 하는지 모르면 세지 않습니다 (G1).',
183
+ { code: 'tip-author-missing' },
184
+ )
185
+ }
186
+ return new Set([fold(tipAuthorEmail)])
187
+ }
188
+ return new Set(authorEmails.map(fold))
189
+ }
190
+
191
+ /**
192
+ * `authorEmails` 가 배제에 쓸 만한 모양인가. **`"off"` 일 때만 없어도 된다.**
193
+ *
194
+ * 🔴 이 가드를 `"tip"` 에서도 유지하는 이유: `"tip"` 은 배제 범위를 좁힐 뿐
195
+ * G1 을 끄지 않는다. 소스에 새 커밋이 하나도 없는(= author 를 못 모은) 상태는
196
+ * `"tip"` 에서도 여전히 "셀 수 없는" 상태다 — 그때 무엇을 머지하는지 자체가
197
+ * 불분명하다.
198
+ */
199
+ const authorsUsable = (authorEmails) =>
200
+ Array.isArray(authorEmails) && authorEmails.length > 0 && authorEmails.every(isStr)
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // 표에 이유를 요구한다 — 정책의 `vote_note` (선택 필드)
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /**
207
+ * `vote_note` — **"이유 없는 표는 안 센다"** 를 정책이 켜는 자리.
208
+ *
209
+ * ```json
210
+ * "vote_note": { "min_chars": 20, "since": "2026-09-01" }
211
+ * ```
212
+ *
213
+ * | 칸 | |
214
+ * |---|---|
215
+ * | `min_chars` | `note` 를 몇 글자 이상 적어야 하는가. 안 적으면 `1`(= 뭐든 한 글자) |
216
+ * | `since` | **시행일.** 이 시각 **이후**에 던져진 표(`at` 기준)에만 요구한다 |
217
+ *
218
+ * 🔴 **필드가 없으면 아무것도 요구하지 않는다.** `self_vote` 와 같은 불변식이다 —
219
+ * 이 옵션이 없던 시절의 저장소가 판정이 조용히 바뀌는 것을 겪으면 안 된다.
220
+ * 거버넌스에서 조용한 변화는 우회로와 구분되지 않는다.
221
+ *
222
+ * 🔴 **`since` 가 있는 이유는 소급 금지 하나다.** 이미 던져진 표는 그때의 규칙으로
223
+ * 유효해야 한다. 규칙을 바꾸는 순간 지난 표가 무효가 되면, 그건 규칙을 고치는
224
+ * 사람이 **남의 표를 지울 수 있다**는 뜻이 된다. `since` 를 안 적으면 시행일
225
+ * 제한이 없다(= 모든 표에 요구) — 새 저장소가 처음부터 켜는 경우다.
226
+ *
227
+ * 🔴 **왜 세는 쪽에도 두는가.** 쓰는 쪽(`governance/vote.mjs`)만 막으면
228
+ * `git pull` 을 안 한 사람의 옛 `vote.mjs` 가 이 규칙을 모른 채 이유 없는 표를
229
+ * 그냥 쓴다. 쓰는 쪽의 거부는 **친절함**이고, 진짜 문은 CI 에서 도는 이 층이다.
230
+ */
231
+ export const DEFAULT_VOTE_NOTE_MIN_CHARS = 1
232
+
233
+ /**
234
+ * `vote_note` 를 읽어 규칙 하나로 만든다. **문제는 모아서 돌려준다** —
235
+ * `validatePolicy` 가 한 번에 다 보여줄 수 있게 (그 함수의 관례 그대로).
236
+ *
237
+ * @returns {{rule: {minChars:number, sinceMs:number|null, since:string|null}|null,
238
+ * problems: Array<{code:string,message:string}>}}
239
+ */
240
+ function parseVoteNote(raw) {
241
+ const problems = []
242
+ const bad = (code, message) => problems.push({ code, message })
243
+
244
+ if (raw === undefined || raw === null) return { rule: null, problems }
245
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
246
+ bad('vote-note-malformed', `\`vote_note\` 는 객체여야 합니다 (안 적으면 이유를 요구하지 않습니다): ${JSON.stringify(raw)}`)
247
+ return { rule: null, problems }
248
+ }
249
+
250
+ let minChars = DEFAULT_VOTE_NOTE_MIN_CHARS
251
+ if (raw.min_chars !== undefined && raw.min_chars !== null) {
252
+ if (!isInt(raw.min_chars) || raw.min_chars < 1) {
253
+ // 0 은 "길이 제한 없음" 이 아니라 **규칙이 꺼진 것**이다. 끄고 싶으면
254
+ // `vote_note` 자체를 지우는 것이 정직하고, 그건 정책 diff 에 남는다.
255
+ bad('vote-note-min-chars', `\`vote_note.min_chars\` 는 1 이상의 정수여야 합니다 (안 적으면 ${DEFAULT_VOTE_NOTE_MIN_CHARS}): ${JSON.stringify(raw.min_chars)}`)
256
+ } else {
257
+ minChars = raw.min_chars
258
+ }
259
+ }
260
+
261
+ let sinceMs = null
262
+ if (raw.since !== undefined && raw.since !== null) {
263
+ if (!isStr(raw.since)) {
264
+ bad('vote-note-since', `\`vote_note.since\` 는 시각 문자열이어야 합니다: ${JSON.stringify(raw.since)}`)
265
+ } else {
266
+ const t = Date.parse(raw.since)
267
+ if (Number.isNaN(t)) {
268
+ // 못 읽는 시행일을 "제한 없음" 으로 떨어뜨리지 않는다. 오타 하나가
269
+ // 지난 표 전부를 무효로 만들 수 있는 자리다 (G5 와 같은 이유).
270
+ bad('vote-note-since', `\`vote_note.since\` 를 시각으로 읽을 수 없습니다: ${JSON.stringify(raw.since)}`)
271
+ } else {
272
+ sinceMs = t
273
+ }
274
+ }
275
+ }
276
+
277
+ if (problems.length) return { rule: null, problems }
278
+ return { rule: { minChars, sinceMs, since: raw.since ?? null }, problems }
279
+ }
280
+
281
+ /**
282
+ * 정책에서 이유 규칙을 읽는다. **없으면 `null`(요구 안 함), 이상하면 던진다.**
283
+ *
284
+ * `selfVoteMode` 와 같은 모양이다 — 정상 경로에서는 `validatePolicy` 가 먼저
285
+ * 잡으므로 여기까지 오지 않는다. 이 throw 는 정책 검증을 건너뛰고 부르는 쪽을
286
+ * 위한 마지막 문이다.
287
+ */
288
+ export function voteNoteRule(policy) {
289
+ const { rule, problems } = parseVoteNote(policy?.vote_note)
290
+ if (problems.length) {
291
+ throw new GovernanceError(problems.map((p) => p.message).join('\n'), { code: problems[0].code })
292
+ }
293
+ return rule
294
+ }
295
+
296
+ /**
297
+ * 표 한 장이 이유 규칙을 어겼는가. **안 어겼으면 `null`.**
298
+ *
299
+ * 🔴 쓰는 쪽(`governance/vote.mjs`)과 세는 쪽(`countVotes`)이 **이 함수 하나**를
300
+ * 쓴다. 두 곳이 길이를 다르게 세면, 던질 때는 통과하고 셀 때는 무효가 되는
301
+ * 표가 생긴다 — 던진 사람은 던졌다고 믿고 아무도 안 센다.
302
+ *
303
+ * 길이는 **앞뒤 공백을 뗀 뒤의 코드 포인트 수**다. 공백으로 길이를 채우는 것을
304
+ * 이유로 치지 않기 위해서다. 이모지 하나는 한 글자로 센다.
305
+ *
306
+ * @param {{note:*, at:*, rule:object|null}} args `at` 은 표에 적힌 시각(소급 금지용)
307
+ * @returns {{reason:'note-missing'|'note-short', detail:string|null}|null}
308
+ */
309
+ export function voteNoteProblem({ note, at, rule }) {
310
+ if (!rule) return null
311
+ // 소급 금지 — 시행일 **이전**에 던져진 표는 그때의 규칙으로 유효하다.
312
+ if (rule.sinceMs !== null && toMs(at, 'votes[].at') < rule.sinceMs) return null
313
+
314
+ const text = typeof note === 'string' ? note.trim() : ''
315
+ if (text === '') return { reason: 'note-missing', detail: null }
316
+ const n = [...text].length
317
+ if (n < rule.minChars) return { reason: 'note-short', detail: `${n}자 — 최소 ${rule.minChars}자` }
318
+ return null
319
+ }
320
+
106
321
  // ---------------------------------------------------------------------------
107
322
  // 과반(majority) 문턱
108
323
  // ---------------------------------------------------------------------------
@@ -137,21 +352,35 @@ const thresholdRank = (v) => (v === MAJORITY ? Infinity : v)
137
352
  /**
138
353
  * 과반 문턱을 실제 숫자로 만든다.
139
354
  *
355
+ * 🔴 세 번째 인자는 **기본값이 있다.** 두 인자로 부르던 기존 호출은 `"authors"` 로
356
+ * 돌아가므로 판정이 바뀌지 않는다. 새 옵션을 넣는 것이 옛 저장소의 답을 바꾸면
357
+ * 안 된다는 것이 이 변경의 첫 번째 불변식이다.
358
+ *
140
359
  * @param {Array} voters 정책 명단. 승계로 들어온 사람은 넣지 않는다 (위 주석)
141
360
  * @param {string[]} authorEmails 소스 브랜치 커밋의 author email
361
+ * @param {{selfVote?: string, tipAuthorEmail?: string|null}} [opts]
362
+ * `selfVote` 는 `selfVoteMode(policy)` 가 돌려준 값을 그대로 넣는다 —
363
+ * `countVotes` 와 **같은 값**이어야 분자와 분모가 어긋나지 않는다
142
364
  */
143
- export function majorityThreshold(voters, authorEmails) {
365
+ export function majorityThreshold(voters, authorEmails, { selfVote = DEFAULT_SELF_VOTE, tipAuthorEmail = null } = {}) {
144
366
  if (!Array.isArray(voters) || voters.length === 0) {
145
367
  throw new GovernanceError('정책에 투표권자가 없어 과반을 셀 수 없습니다.', { exit: EXIT.POLICY_BROKEN, code: 'voters-missing' })
146
368
  }
147
- if (!Array.isArray(authorEmails) || authorEmails.length === 0 || !authorEmails.every(isStr)) {
369
+ if (!isSelfVoteValue(selfVote) || selfVote === undefined || selfVote === null) {
370
+ // 부르는 쪽이 이상한 값을 줬다. 안전한 값으로 치환하면 분모가 조용히 틀린다.
371
+ throw new GovernanceError(
372
+ `\`self_vote\` 는 ${selfVoteList()} 중 하나여야 합니다: ${JSON.stringify(selfVote)}`,
373
+ { code: 'self-vote-invalid' },
374
+ )
375
+ }
376
+ if (selfVote !== 'off' && !authorsUsable(authorEmails)) {
148
377
  // 작성자를 모르면 분모를 정할 수 없다. 미달이 아니라 판정 불가다 (G5).
149
378
  throw new GovernanceError(
150
379
  '과반을 세려면 소스 브랜치 커밋의 author email 이 필요합니다. 작성자를 못 빼면 문턱이 틀립니다.',
151
380
  { code: 'authors-missing' },
152
381
  )
153
382
  }
154
- const authors = new Set(authorEmails.map(fold))
383
+ const authors = excludedAuthors(selfVote, authorEmails, tipAuthorEmail)
155
384
  const eligible = voters.filter((v) => !authors.has(fold(v?.email)))
156
385
  // 명단이 전부 작성자라도 **0 으로 내려가지 않는다.** 문턱 0 은 문턱이 아니라
157
386
  // 이 층이 없는 것이다. 그때는 승계로 들어온 남이 한 표를 줘야 통과한다.
@@ -225,11 +454,19 @@ export function validatePolicy(policy, { policyPath = DEFAULT_POLICY_PATH } = {}
225
454
  }
226
455
 
227
456
  // ── 투표권자 ──────────────────────────────────────────────────────────
457
+ //
458
+ // 🔴 **명단은 없어도 된다.** 비어 있는 것은 "투표권자가 없다" 가 아니라
459
+ // **"저장소에 커밋한 사람이 곧 명단이다"** 라는 뜻이다 (`deriveVoters`).
460
+ // 손으로 적는 명단은 이메일 한 글자만 틀려도 그 사람이 영영 표를 못 던지는데,
461
+ // 틀렸다는 사실은 표가 모자랄 때까지 안 보인다. 적을 것이 없으면 틀릴 것도 없다.
462
+ //
463
+ // 적어 두는 길은 남긴다 — 저장소에 커밋하지 않는 사람에게 표를 주려면
464
+ // 커밋 이력으로는 표현할 수 없기 때문이다.
228
465
  const voters = policy.voters
229
466
  let voterCount = null
230
- if (!Array.isArray(voters) || voters.length === 0) {
231
- add('voters-missing', '`voters`비어 있습니다. 투표권자가 없으면 셀 수 있는 것이 없습니다.')
232
- } else {
467
+ if (voters != null && !Array.isArray(voters)) {
468
+ add('voters-malformed', `\`voters\`배열이 아닙니다: ${JSON.stringify(voters)}`)
469
+ } else if (Array.isArray(voters) && voters.length > 0) {
233
470
  voterCount = voters.length
234
471
  const seenId = new Map()
235
472
  const seenEmail = new Map()
@@ -260,7 +497,16 @@ export function validatePolicy(policy, { policyPath = DEFAULT_POLICY_PATH } = {}
260
497
  }
261
498
  const t = holder.threshold
262
499
  // `"majority"` 는 명단에서 계산되는 값이라 여기서 상한을 따질 것이 없다.
263
- if (t === MAJORITY) return t
500
+ if (t === MAJORITY) {
501
+ // 🔴 분모가 없으면 과반은 셀 수 없다. 명단을 안 둔 저장소에서는 투표권자가
502
+ // 커밋과 함께 늘어나므로, 같은 MR 이 어제와 오늘 다른 문턱을 갖는다.
503
+ // 그건 문턱이 아니다.
504
+ if (voterCount === null) {
505
+ add('majority-without-roster', `\`${label}.threshold\` 가 "majority" 인데 \`voters\` 명단이 없습니다. 과반은 고정된 분모가 있어야 셉니다 — 숫자로 적거나 명단을 두세요.`)
506
+ return null
507
+ }
508
+ return t
509
+ }
264
510
  if (!isInt(t) || t < 1) {
265
511
  // 정족수 0 은 정족수가 아니라 **이 층이 없는 것**이다. 그렇게 하고 싶으면
266
512
  // CI 잡을 지우는 것이 정직하고, 그건 이력에 남는다. 조용히 0 으로 두면
@@ -348,6 +594,31 @@ export function validatePolicy(policy, { policyPath = DEFAULT_POLICY_PATH } = {}
348
594
  })
349
595
  }
350
596
 
597
+ // ── 자기 표 배제 범위 (선택 필드) ─────────────────────────────────────
598
+ //
599
+ // 🔴 **판정 불가(1)로 올린다. 깨진 정책(4)이 아니다.**
600
+ // threshold 가 이상한 것은 "정책이 틀리게 적혀 있다" 지만, `self_vote` 가
601
+ // 이상한 것은 **누구를 빼야 하는지 자체를 모른다** 는 뜻이다. 그 상태에서
602
+ // 센 숫자는 미달인지 충족인지조차 알 수 없다 — G5(못 세면 통과가 아니다)가
603
+ // 말하는 바로 그 자리다. 조용히 기본값으로 떨어뜨리는 것이 최악이다.
604
+ if (!isSelfVoteValue(policy.self_vote)) {
605
+ add(
606
+ 'self-vote-invalid',
607
+ `\`self_vote\` 는 ${selfVoteList()} 중 하나여야 합니다 (안 적으면 "${DEFAULT_SELF_VOTE}"): ${JSON.stringify(policy.self_vote)}`,
608
+ EXIT.UNDECIDABLE,
609
+ )
610
+ }
611
+
612
+ // ── 표에 이유를 요구하는 규칙 (선택 필드) ──────────────────────────────
613
+ //
614
+ // 🔴 `self_vote` 와 **같은 자리·같은 등급**이다. 판정 불가(1)로 올리고 깨진
615
+ // 정책(4)으로 올리지 않는 이유도 같다: 이 값이 이상하면 **어떤 표가 유효한지
616
+ // 자체를 모른다.** 그 상태에서 센 숫자는 미달인지 충족인지조차 알 수 없다
617
+ // (G5 — 못 세면 통과가 아니다).
618
+ for (const p of parseVoteNote(policy.vote_note).problems) {
619
+ add(p.code, p.message, EXIT.UNDECIDABLE)
620
+ }
621
+
351
622
  // ── 승계 설정 ────────────────────────────────────────────────────────
352
623
  const s = policy.succession
353
624
  if (s !== undefined && s !== null) {
@@ -482,8 +753,14 @@ export function rulesFor(paths, policy, { majority = null } = {}) {
482
753
  // ---------------------------------------------------------------------------
483
754
 
484
755
  /**
485
- * 유효 투표권자를 정한다. 살아 있는 사람이 필요 표보다 적으면 **승계**가 돈다.
756
+ * 유효 투표권자를 정한다. 길이 둘이다.
486
757
  *
758
+ * 🔴 **명단(`policy.voters`)이 비어 있으면 저장소에 커밋한 사람이 곧 명단이다.**
759
+ * 최근 `window_days` 안에 커밋한 사람 **전원**이 투표권자가 된다. 상위 몇 명으로
760
+ * 자르지 않는다 — 자르는 것은 승계(빈자리를 임시로 메우는 것)의 일이고, 여기서는
761
+ * 그 사람들이 명단 자체이기 때문이다.
762
+ *
763
+ * 명단이 있으면: 살아 있는 사람이 필요 표보다 적을 때 **승계**가 돈다.
487
764
  * 승계 = 최근 `window_days` 안에 커밋한 author 상위 `top` 명이 임시 투표권을 갖는 것.
488
765
  *
489
766
  * 🔴 **승계가 발동한 사실은 반드시 출력에 드러난다** (`formatVerdict` 가 찍는다).
@@ -504,10 +781,7 @@ export function rulesFor(paths, policy, { majority = null } = {}) {
504
781
  * @param {number|null} [args.threshold] 이번 MR 의 필요 표. 없으면 `default.threshold`
505
782
  */
506
783
  export function deriveVoters({ policy, contributors, now, threshold = null }) {
507
- const base = policy?.voters
508
- if (!Array.isArray(base) || base.length === 0) {
509
- throw new GovernanceError('정책에 투표권자가 없습니다.', { exit: EXIT.POLICY_BROKEN, code: 'voters-missing' })
510
- }
784
+ const base = Array.isArray(policy?.voters) ? policy.voters : []
511
785
  const need = threshold ?? policy?.default?.threshold
512
786
  if (!isInt(need) || need < 1) {
513
787
  throw new GovernanceError(`필요 표를 정할 수 없습니다: ${JSON.stringify(need)}`, { exit: EXIT.POLICY_BROKEN, code: 'threshold-unresolved' })
@@ -519,9 +793,6 @@ export function deriveVoters({ policy, contributors, now, threshold = null }) {
519
793
  const nowMs = toMs(now, 'now')
520
794
  const since = nowMs - windowDays * 86400000
521
795
 
522
- const roster = base.map((v) => ({ id: v.id, email: v.email, via: 'policy' }))
523
- const byEmail = new Set(roster.map((v) => fold(v.email)))
524
-
525
796
  /** 창 안에 커밋이 있는 사람. `contributors` 를 못 받았으면 판단을 미룬다. */
526
797
  const recent = new Map() // fold(email) -> {email, name, commits, lastAt}
527
798
  if (Array.isArray(contributors)) {
@@ -540,6 +811,46 @@ export function deriveVoters({ policy, contributors, now, threshold = null }) {
540
811
  }
541
812
  }
542
813
 
814
+ /**
815
+ * 커밋 수 → 최근순 → email 순. 마지막 두 단계는 **결과를 결정론적으로** 만들기
816
+ * 위한 것이다. 같은 입력에 다른 답이 나오는 거버넌스는 못 쓴다.
817
+ */
818
+ const byActivity = (a, b) =>
819
+ (b.commits - a.commits) || (b.lastAt - a.lastAt) || (fold(a.email) < fold(b.email) ? -1 : 1)
820
+
821
+ // 🔴 명단이 없으면 **저장소에 커밋한 사람이 곧 명단이다.**
822
+ // 승계는 돌지 않는다 — 메울 빈자리가 없다. 여기 들어오는 사람은 전부 창 안에
823
+ // 커밋이 있으므로 `alive` 와 같은 목록이다.
824
+ if (base.length === 0) {
825
+ if (!Array.isArray(contributors)) {
826
+ // 이력을 못 받은 것을 "커밋한 사람이 없다" 로 읽으면 살아 있는 저장소가
827
+ // 죽은 것으로 보인다. 못 세면 통과가 아니다 (G5).
828
+ throw new GovernanceError(
829
+ '명단이 없어 기여 이력으로 투표권자를 정해야 하는데 이력을 받지 못했습니다 (contributors=null).',
830
+ { code: 'contributors-missing' },
831
+ )
832
+ }
833
+ const derived = [...recent.values()]
834
+ .sort(byActivity)
835
+ .map((c) => ({ id: c.name ?? c.email, email: c.email, via: 'contributors', commits: c.commits }))
836
+ return {
837
+ voters: derived,
838
+ base: [],
839
+ alive: derived,
840
+ succeeded: [],
841
+ triggered: false,
842
+ derived: true,
843
+ need,
844
+ short: Math.max(0, need - derived.length),
845
+ // `top` 은 null 이다 — 여기서는 자르지 않았다. 숫자를 넣어 두면 읽는 쪽이
846
+ // "상위 몇 명만 들어왔다" 고 잘못 읽는다.
847
+ window: { days: windowDays, since: new Date(since).toISOString(), top: null },
848
+ }
849
+ }
850
+
851
+ const roster = base.map((v) => ({ id: v.id, email: v.email, via: 'policy' }))
852
+ const byEmail = new Set(roster.map((v) => fold(v.email)))
853
+
543
854
  const alive = roster.filter((v) => recent.has(fold(v.email)))
544
855
  const triggered = alive.length < need
545
856
 
@@ -556,9 +867,7 @@ export function deriveVoters({ policy, contributors, now, threshold = null }) {
556
867
  }
557
868
  succeeded = [...recent.values()]
558
869
  .filter((c) => !byEmail.has(fold(c.email)))
559
- // 커밋 수 → 최근순 → email 순. 마지막 두 단계는 **결과를 결정론적으로**
560
- // 만들기 위한 것이다. 같은 입력에 다른 답이 나오는 거버넌스는 못 쓴다.
561
- .sort((a, b) => (b.commits - a.commits) || (b.lastAt - a.lastAt) || (fold(a.email) < fold(b.email) ? -1 : 1))
870
+ .sort(byActivity)
562
871
  .slice(0, topN)
563
872
  .map((c) => ({ id: c.name ?? c.email, email: c.email, via: 'succession', commits: c.commits }))
564
873
  }
@@ -571,6 +880,7 @@ export function deriveVoters({ policy, contributors, now, threshold = null }) {
571
880
  alive,
572
881
  succeeded,
573
882
  triggered,
883
+ derived: false,
574
884
  need,
575
885
  // 승계로도 못 채운 몫. 0 이 아니면 이 저장소는 **막혀 있다.**
576
886
  // 탈출구를 만들지 않는다 — 오픈소스에서 그 상태의 정답은 fork 다.
@@ -599,6 +909,9 @@ export const REASONS = {
599
909
  'self-vote': '자기 표 (G1)',
600
910
  'future-dated': '미래 시각으로 적힌 표',
601
911
  duplicate: '같은 사람의 두 번째 표 (G4)',
912
+ // 🔴 정책이 `vote_note` 를 켰을 때만 난다. 안 켰으면 이 사유는 존재하지 않는다.
913
+ 'note-missing': '이유(note)를 안 적은 표 — 정책이 이유를 요구한다',
914
+ 'note-short': '이유(note)가 정책의 최소 길이보다 짧음',
602
915
  }
603
916
 
604
917
  /** 시계가 조금 어긋난 것까지 위조로 몰지 않는다. 하루면 충분히 넉넉하다. */
@@ -614,9 +927,11 @@ const FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000
614
927
  * @param {number|string} args.now
615
928
  * @param {Array|null} [args.voters] `deriveVoters` 결과. 없으면 `policy.voters`
616
929
  * @param {string|null} [args.branch] 소스 브랜치 이름. 주면 표의 branch 와 대조한다
930
+ * @param {string|null} [args.tipAuthorEmail] 맨 위 커밋의 author email.
931
+ * `self_vote: "tip"` 일 때만 쓰이고, 그때는 **없으면 판정 불가**다
617
932
  * @returns {{approvals:number, counted:Array, rejections:Array, invalid:Array}}
618
933
  */
619
- export function countVotes({ policy, votes, authorEmails, sha, now, voters = null, branch = null }) {
934
+ export function countVotes({ policy, votes, authorEmails, sha, now, voters = null, branch = null, tipAuthorEmail = null }) {
620
935
  const roster = voters ?? policy?.voters ?? null
621
936
  if (!Array.isArray(roster) || roster.length === 0) {
622
937
  throw new GovernanceError('투표권자 명단이 없습니다.', { exit: EXIT.POLICY_BROKEN, code: 'voters-missing' })
@@ -632,7 +947,14 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
632
947
  if (!SHA_RE.test(fold(sha))) {
633
948
  throw new GovernanceError(`머지 대상 커밋(sha)을 읽을 수 없습니다: ${JSON.stringify(sha)}`, { code: 'sha-missing' })
634
949
  }
635
- if (!Array.isArray(authorEmails) || authorEmails.length === 0 || !authorEmails.every(isStr)) {
950
+ // 🔴 배제 범위는 **정책에서 읽는다.** `majorityThreshold` 에는 `judge` 가 같은
951
+ // `selfVoteMode(policy)` 결과를 넘긴다 — 분자와 분모가 같은 값을 보게 하는
952
+ // 자리가 여기 하나뿐이어야 한다.
953
+ const selfVote = selfVoteMode(policy)
954
+ // 🔴 이유 규칙도 **정책에서** 읽는다. 없으면 `null` 이고 아무 표도 이 사유로
955
+ // 떨어지지 않는다 — 이 필드가 없던 저장소의 판정은 그대로다.
956
+ const noteRule = voteNoteRule(policy)
957
+ if (selfVote !== 'off' && !authorsUsable(authorEmails)) {
636
958
  // 자기 표를 못 거르면(G1) 셀 자격이 없다. 미달이 아니라 판정 불가다.
637
959
  throw new GovernanceError(
638
960
  '소스 브랜치 커밋의 author email 을 받지 못했습니다. 자기 표를 걸러낼 수 없으면 세지 않습니다 (G1).',
@@ -641,7 +963,7 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
641
963
  }
642
964
 
643
965
  const nowMs = toMs(now, 'now')
644
- const authors = new Set(authorEmails.map(fold))
966
+ const authors = excludedAuthors(selfVote, authorEmails, tipAuthorEmail)
645
967
  const byEmail = new Map(roster.map((v) => [fold(v.email), v]))
646
968
 
647
969
  const counted = []
@@ -661,9 +983,10 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
661
983
 
662
984
  const voter = byEmail.get(email)
663
985
  if (!voter) { bad('not-a-voter'); continue }
664
- // 정책에 적힌 사람은 id 까지 맞아야 한다. 승계로 들어온 사람은 정책에 id 가
665
- // 없으므로(이름을 저장소가 정해 준 적이 없다) email 만 본다.
666
- if (voter.via !== 'succession' && fold(voter.id) !== fold(v.voter)) {
986
+ // 정책에 적힌 사람은 id 까지 맞아야 한다. 커밋 이력에서 나온 사람(승계·명단
987
+ // 없음)은 정책에 id 가 없으므로이름을 저장소가 정해 준 적이 없다
988
+ // email 본다. git 표시 이름은 사람마다 PC 마다 흔들린다.
989
+ if (voter.via !== 'succession' && voter.via !== 'contributors' && fold(voter.id) !== fold(v.voter)) {
667
990
  bad('identity-mismatch', `명단은 ${voter.id}`); continue
668
991
  }
669
992
 
@@ -672,6 +995,12 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
672
995
 
673
996
  if (authors.has(email)) { bad('self-vote'); continue } // G1
674
997
  if (toMs(v.at, 'votes[].at') > nowMs + FUTURE_TOLERANCE_MS) { bad('future-dated', v.at); continue }
998
+ // 🔴 이유 없는 표는 **찬성이든 반대든** 안 센다. 반대야말로 이유가 필요하다 —
999
+ // 이유 없는 반대는 판정문에 "누군가 막고 있다" 만 남기고 무엇을 고쳐야
1000
+ // 하는지는 안 남긴다. 그리고 duplicate(G4)보다 **먼저** 본다: 이유 없는
1001
+ // 첫 표가 `seen` 을 차지하면, 뒤에 온 제대로 된 표가 중복으로 떨어진다.
1002
+ const noteBad = voteNoteProblem({ note: v.note, at: v.at, rule: noteRule })
1003
+ if (noteBad) { bad(noteBad.reason, noteBad.detail); continue }
675
1004
  if (seen.has(email)) { bad('duplicate', `이미 센 표: ${seen.get(email).voter}`); continue } // G4
676
1005
 
677
1006
  seen.set(email, v)
@@ -710,6 +1039,15 @@ function readVote(raw) {
710
1039
  if (raw.branch !== undefined && !isStr(raw.branch)) {
711
1040
  throw new GovernanceError(`표의 branch 가 문자열이 아닙니다: ${JSON.stringify(raw.branch)}`, { code: 'vote-malformed' })
712
1041
  }
1042
+ // 🔴 `note` 는 **비어 있어도 여기서 던지지 않는다.** 빈 이유는 "표가 깨졌다"
1043
+ // (판정 전체 중단)가 아니라 "이 표는 안 센다"(무효표 한 장)여야 한다.
1044
+ // 여기서 던지면 이유를 안 적은 표 한 장이 MR 전체를 판정 불가로 만든다.
1045
+ // 문자열이 **아닌 것**만 깨진 것으로 친다 — 그건 손으로 쓴 오류다.
1046
+ if (raw.note !== undefined && raw.note !== null && typeof raw.note !== 'string') {
1047
+ throw new GovernanceError(`표의 note 가 문자열이 아닙니다: ${JSON.stringify(raw.note)}`, { code: 'vote-malformed' })
1048
+ }
1049
+ // `agent` 는 검사하지 않는다. 판정에 안 쓰이고, 모양이 늘어날 자리이기 때문이다
1050
+ // — 여기서 모양을 고정하면 필드가 하나 늘 때마다 옛 게이트가 표를 깨진 것으로 본다.
713
1051
  return raw
714
1052
  }
715
1053
 
@@ -727,19 +1065,26 @@ function readVote(raw) {
727
1065
  */
728
1066
  export function judge({
729
1067
  policy, changed, votes, authorEmails, sha, contributors = null, now, branch = null, policyPath = DEFAULT_POLICY_PATH,
1068
+ tipAuthorEmail = null,
730
1069
  }) {
731
1070
  const v = validatePolicy(policy, { policyPath })
732
1071
  if (!v.ok) {
733
1072
  return { exit: v.exit, ok: false, stage: 'policy', problems: v.problems }
734
1073
  }
735
1074
  try {
1075
+ // 🔴 배제 범위를 **여기서 한 번** 읽어 분모 쪽에 넘긴다. 분자 쪽(`countVotes`)은
1076
+ // 같은 `policy` 로 같은 함수를 다시 부르므로 두 값은 갈라질 수 없다.
1077
+ // 갈라지면 "필요한 표" 와 "센 표" 의 분모가 어긋나 판정만 조용히 틀린다.
1078
+ const selfVote = selfVoteMode(policy)
736
1079
  // 🔴 과반은 **정책 명단에서 작성자를 뺀 수**로 센다. 승계로 들어온 사람은
737
1080
  // 분모에 안 들어간다 — 모자라서 부른 사람이 문턱을 같이 올리면 안 된다.
738
1081
  // 그래서 이 계산이 `deriveVoters` 보다 먼저 온다.
739
- const majority = usesMajority(policy) ? majorityThreshold(policy.voters, authorEmails) : null
1082
+ const majority = usesMajority(policy)
1083
+ ? majorityThreshold(policy.voters, authorEmails, { selfVote, tipAuthorEmail })
1084
+ : null
740
1085
  const need = rulesFor(changed, policy, { majority })
741
1086
  const roster = deriveVoters({ policy, contributors, now, threshold: need.threshold })
742
- const tally = countVotes({ policy, votes, authorEmails, sha, now, voters: roster.voters, branch })
1087
+ const tally = countVotes({ policy, votes, authorEmails, sha, now, voters: roster.voters, branch, tipAuthorEmail })
743
1088
  const ok = tally.approvals >= need.threshold
744
1089
  return {
745
1090
  exit: ok ? EXIT.OK : EXIT.SHORT,
@@ -812,6 +1157,13 @@ export function formatVerdict(verdict) {
812
1157
  }
813
1158
  if (tally.counted.length || tally.rejections.length || tally.invalid.length) L.push('')
814
1159
 
1160
+ // 🔴 명단 없이 정해진 투표권자도 조용히 지나가지 않는다. **누가 표를 던질 수
1161
+ // 있는가는 판정의 절반**이고, 그것이 커밋 이력에서 나왔다면 더 그렇다.
1162
+ if (roster.derived) {
1163
+ L.push(` 명단 없음 — 최근 ${roster.window.days}일 안에 커밋한 ${roster.voters.length}명이 투표권자입니다.`)
1164
+ for (const c of roster.voters) L.push(` · ${c.id} <${c.email}> 커밋 ${c.commits}`)
1165
+ }
1166
+
815
1167
  // 🔴 승계는 조용히 발동하지 않는다. 이 줄을 지우면 승계가 우회로가 된다.
816
1168
  if (roster.triggered) {
817
1169
  L.push(` ⚠ 승계 발동 — 살아 있는 투표권자 ${roster.alive.length}명 < 필요 ${roster.need}명`)
package/src/promote.mjs CHANGED
@@ -175,3 +175,90 @@ export function formatPlan(plan) {
175
175
  }
176
176
  return L.join('\n')
177
177
  }
178
+ /**
179
+ * ── 표의 이유를 머지 커밋에 싣는다 ──────────────────────────────────────────
180
+ *
181
+ * 🔴 **왜 GitLab MR 댓글이 아니라 커밋 메시지인가.**
182
+ *
183
+ * AI 여러 대가 MR 을 검토하면 의견이 쌓인다. 그 의견을 어디에 두느냐로 이후가
184
+ * 갈린다. 기본값(MR 댓글)은 세 가지가 나쁘다.
185
+ *
186
+ * 1. **git 안에 없다.** clone 해도 안 따라온다. 다음 에이전트가 `git log` 로
187
+ * "왜 이렇게 머지됐나" 를 못 읽는다
188
+ * 2. **플랫폼에 묶인다.** GitLab 을 떠나면 통째로 사라진다
189
+ * 3. **MR 이 닫히면 아무도 안 본다.** 맥락도 같이 닫힌다
190
+ *
191
+ * 이 저장소는 이미 같은 판단을 두 번 했다 — 장부(`axmap/claims`)도 쪽지
192
+ * (`axmap/bus`)도 서버가 아니라 **고아 브랜치**에 뒀다. 합의의 근거도 같은 자리,
193
+ * 즉 저장소 안에 남긴다. 트레일러로 적으면 `git log --grep` 과
194
+ * `git interpret-trailers --parse` 가 그대로 읽는다.
195
+ *
196
+ * 🔴 **콜론 앞은 영문이어야 한다.** 2026-08-28 실측 — git 은 그 자리를 사람이
197
+ * 읽는 이름이 아니라 **기계가 찾는 열쇠**로 본다. 한글이거나 괄호가 섞이면
198
+ * 그 줄을 트레일러가 아니라 평범한 본문으로 보고 지나친다.
199
+ *
200
+ * Reviewed-by: bob <b@x.com> → 뽑힌다
201
+ * 찬성(Reviewed-by): bob <b@x.com> → 무시된다
202
+ * Reviewed-by(찬성): bob <b@x.com> → 무시된다
203
+ * 찬성: bob <b@x.com> → 무시된다
204
+ *
205
+ * 그래서 열쇠는 영문으로 두고 **한글을 값의 맨 앞**에 놓는다. 사람이 눈으로
206
+ * 읽을 때 먼저 보이는 것은 여전히 '찬성'·'반대' 다.
207
+ *
208
+ * 🔴 **반대표도 싣는다.** 반대는 정족수 계산에 안 들어가므로(거부권은 다른 제도다)
209
+ * 반대가 있어도 머지될 수 있다. 그때 그 반대가 아무 데도 안 남으면, 나중에
210
+ * 문제가 터졌을 때 **"아무도 몰랐다" 로 기록된다.** 알았던 사람이 있었다는 것이
211
+ * 남아야 한다.
212
+ */
213
+
214
+ /** 트레일러 값에 들어갈 수 있게 한 줄로 만든다. */
215
+ function oneLine(s, max = 200) {
216
+ const t = String(s ?? '').replace(/\s+/gu, ' ').trim()
217
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t
218
+ }
219
+
220
+ /**
221
+ * 표 한 장을 트레일러 한 줄로.
222
+ *
223
+ * 🔴 이유가 비어 있으면 `— …` 를 안 붙인다. 정책이 `vote_note` 를 안 켰으면
224
+ * 이유가 없는 것이 정상이고, 빈 꼬리를 붙이면 "이유를 안 적었다" 가 아니라
225
+ * "이유 칸이 깨졌다" 처럼 보인다.
226
+ */
227
+ function trailerLine(key, vote, stance) {
228
+ const who = `${oneLine(vote?.voter, 60) || '?'} <${oneLine(vote?.email, 100) || '?'}>`
229
+ const note = oneLine(vote?.note)
230
+ return `${key}: ${stance} — ${who}${note ? ` · ${note}` : ''}`
231
+ }
232
+
233
+ /**
234
+ * 판정(`gate.mjs --json` 의 verdict)에서 트레일러 줄들을 만든다.
235
+ * 판정이 셀 수 없었으면(정책 깨짐·판정 불가) 빈 배열이다 — 없는 것을 지어내지 않는다.
236
+ */
237
+ export function reviewTrailers(verdict) {
238
+ const t = verdict?.tally
239
+ if (!t) return []
240
+ const out = []
241
+ for (const v of Array.isArray(t.counted) ? t.counted : []) out.push(trailerLine('Reviewed-by', v, '찬성'))
242
+ for (const v of Array.isArray(t.rejections) ? t.rejections : []) out.push(trailerLine('Rejected-by', v, '반대'))
243
+ return out
244
+ }
245
+
246
+ /**
247
+ * 봇이 머지할 때 쓸 커밋 메시지. GitLab 의 `merge_commit_message` 로 넘어간다.
248
+ *
249
+ * 🔴 첫 줄은 GitLab 기본형(`Merge branch 'A' into 'B'`)을 그대로 쓴다. 사람이
250
+ * 누른 머지와 봇이 누른 머지가 이력에서 다르게 보이면, 나중에 이력을 훑는
251
+ * 사람이 **다르게 보이는 것 자체를 신호로 오해한다.** 다른 것은 트레일러뿐이다.
252
+ *
253
+ * 🔴 트레일러가 없으면 본문도 안 붙인다 — `null` 을 내서 부르는 쪽이 그 필드를
254
+ * 아예 안 보내게 한다. 빈 본문을 보내면 GitLab 이 기본 메시지를 덮어쓴다.
255
+ */
256
+ export function mergeCommitMessage({ source, target, verdict }) {
257
+ const trailers = reviewTrailers(verdict)
258
+ if (trailers.length === 0) return null
259
+ const head = `Merge branch '${source}' into '${target}'`
260
+ const approvals = verdict?.approvals ?? verdict?.tally?.approvals ?? 0
261
+ const threshold = verdict?.threshold ?? 0
262
+ const summary = `정족수 ${approvals}/${threshold} · 판정 커밋 ${String(verdict?.sha ?? '').slice(0, 12)}`
263
+ return [head, '', summary, '', ...trailers].join('\n')
264
+ }