axmap-cli 0.0.1

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.
Files changed (78) hide show
  1. package/.claude/commands/ax-done.md +10 -0
  2. package/.claude/commands/ax-setup.md +31 -0
  3. package/.claude/commands/ax-start.md +15 -0
  4. package/.claude/commands/ax-tell.md +14 -0
  5. package/.claude/commands/ax-update.md +19 -0
  6. package/.claude/commands/ax.md +13 -0
  7. package/CLAUDE.md +309 -0
  8. package/LICENSE +20 -0
  9. package/README.md +207 -0
  10. package/app/README.md +366 -0
  11. package/app/eval/edges.mjs +242 -0
  12. package/app/lib/adjacent.mjs +125 -0
  13. package/app/lib/agentcli.mjs +153 -0
  14. package/app/lib/analyze.mjs +1159 -0
  15. package/app/lib/cochange.mjs +421 -0
  16. package/app/lib/datanodes.mjs +127 -0
  17. package/app/lib/entry.mjs +192 -0
  18. package/app/lib/featuregraph.mjs +389 -0
  19. package/app/lib/features.mjs +645 -0
  20. package/app/lib/fetchrepo-run.mjs +37 -0
  21. package/app/lib/fetchrepo.mjs +164 -0
  22. package/app/lib/flow.mjs +1089 -0
  23. package/app/lib/ladder.mjs +387 -0
  24. package/app/lib/langs.mjs +630 -0
  25. package/app/lib/live.mjs +346 -0
  26. package/app/lib/llm.mjs +594 -0
  27. package/app/lib/newfile.mjs +126 -0
  28. package/app/lib/prdiff.mjs +651 -0
  29. package/app/lib/reveal.mjs +316 -0
  30. package/app/lib/roots.mjs +186 -0
  31. package/app/lib/scope.mjs +342 -0
  32. package/app/lib/session.mjs +389 -0
  33. package/app/lib/slots.mjs +233 -0
  34. package/app/lib/ssot.mjs +277 -0
  35. package/app/lib/teamview.mjs +962 -0
  36. package/app/lib/terms.ko.mjs +169 -0
  37. package/app/server.mjs +1959 -0
  38. package/app/web/shell.css +538 -0
  39. package/app/web/shell.html +197 -0
  40. package/app/web/shell.js +638 -0
  41. package/app/web/stage.js +347 -0
  42. package/app/web/words.js +85 -0
  43. package/bin/axmap.mjs +1918 -0
  44. package/governance/GOVERNANCE.md +433 -0
  45. package/governance/gate.mjs +526 -0
  46. package/governance/vote.mjs +501 -0
  47. package/mcp/README.md +254 -0
  48. package/mcp/SETUP-FOR-AI.md +186 -0
  49. package/mcp/install.ps1 +341 -0
  50. package/mcp/install.sh +339 -0
  51. package/mcp/server.mjs +969 -0
  52. package/package.json +48 -0
  53. package/src/closure.mjs +343 -0
  54. package/src/governance.mjs +839 -0
  55. package/src/invariants.mjs +226 -0
  56. package/src/mrtarget.mjs +284 -0
  57. package/src/promote.mjs +177 -0
  58. package/src/protocol.mjs +423 -0
  59. package/src/repotarget.mjs +81 -0
  60. package/src/update.mjs +177 -0
  61. package/src/version.mjs +186 -0
  62. package/tools/bus.mjs +520 -0
  63. package/tools/cluster-experiment.mjs +256 -0
  64. package/tools/cluster-sweep.mjs +226 -0
  65. package/tools/make-icon.mjs +108 -0
  66. package/tools/mcp-register.mjs +269 -0
  67. package/tools/mr-target.mjs +49 -0
  68. package/tools/persona-bench.mjs +362 -0
  69. package/tools/pick-repo.mjs +229 -0
  70. package/tools/promote.mjs +550 -0
  71. package/tools/reveal-demo.mjs +158 -0
  72. package/tools/run-tests.mjs +42 -0
  73. package/tools/setup.mjs +490 -0
  74. package/tools/shortcut.mjs +121 -0
  75. package/tools/smoke.mjs +166 -0
  76. package/tools/topicgraph.py +154 -0
  77. package/tools/vendor.mjs +382 -0
  78. package/tools/version.mjs +115 -0
@@ -0,0 +1,526 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 합의 게이트 — **git 에서 재료를 모아 순수 판정을 부르는 층.**
4
+ *
5
+ * 이 파일이 답하는 질문은 `src/governance.mjs` 와 같다.
6
+ *
7
+ * "이 MR(**Merge Request** — 내 브랜치의 변경을 다른 브랜치로 합쳐 달라는 요청)은
8
+ * 정족수(**통과에 필요한 최소 찬성 수**)를 채웠는가?"
9
+ *
10
+ * 다만 **판정은 여기서 하지 않는다.** 여기서 하는 일은 딱 둘이다.
11
+ *
12
+ * 1. 재료를 모은다 — 정책 · 바뀐 파일 · 대상 커밋 · 소스 커밋의 author · 표
13
+ * 2. `judge()` 를 한 번 부르고 그 결과의 `exit` 를 **그대로** 종료 코드로 쓴다
14
+ *
15
+ * 🔴 게이트가 종료 코드를 다시 고르지 않는다. 이 층의 계약이 곧 종료 코드이고,
16
+ * 계약은 테스트할 수 있는 자리(= 순수 함수)에 있어야 한다. 게이트가 메시지를
17
+ * 보고 코드를 고르기 시작하면 **문구를 다듬는 커밋이 판정을 바꾼다.**
18
+ *
19
+ * ── G2 가 여기 있는 이유 ──────────────────────────────────────────────────
20
+ *
21
+ * G1~G5 중 **G2 만 순수 판정 밖에 있다** (governance/GOVERNANCE.md).
22
+ *
23
+ * G2 정책은 언제나 **타깃 브랜치**(**합쳐 받는 쪽 브랜치**)에서 읽는다
24
+ *
25
+ * 어느 브랜치에서 정책을 읽어오는가는 git 을 만지는 일이라 게이트의 책임이다.
26
+ * 소스 브랜치(**합쳐 달라고 내미는 쪽 브랜치**)에서 읽으면 **정족수를 1 로 낮추는
27
+ * MR 이 자기가 낮춘 규칙으로 통과한다.** 그래서 아래 `readPolicy` 는 타깃 브랜치의
28
+ * 커밋에서만 파일을 꺼내고, 작업 트리(**지금 디스크에 펼쳐져 있는 파일들**)는
29
+ * 쳐다보지 않는다.
30
+ *
31
+ * ── 실패 방향 ────────────────────────────────────────────────────────────
32
+ *
33
+ * fail-open(**애매하면 통과시킨다**) 을 하지 않는다. 재료를 못 모으면 통과가
34
+ * 아니라 **판정 불가(exit 1)** 다. 다만 "아무도 아직 투표 안 함" 은 고장이 아니라
35
+ * 정상적인 상태이므로 **표 0장으로 정상 판정**해서 미달(exit 2)이 나오게 한다.
36
+ * 이 둘을 뭉치면 에이전트가 고장을 "기다리면 되는 일" 로 읽고 영원히 기다린다.
37
+ *
38
+ * ── 부르는 법 ────────────────────────────────────────────────────────────
39
+ *
40
+ * node axmap/governance/gate.mjs # CI: 환경변수
41
+ * node axmap/governance/gate.mjs --source <브랜치> --target <브랜치> # 손으로
42
+ *
43
+ * CI(**Continuous Integration** — 코드를 올릴 때마다 자동으로 검사를 돌리는 것)
44
+ * 에서는 GitLab 이 넣어 주는 `CI_MERGE_REQUEST_SOURCE_BRANCH_NAME` ·
45
+ * `CI_MERGE_REQUEST_TARGET_BRANCH_NAME` 을 읽는다. 플래그가 있으면 플래그가 이긴다.
46
+ * **둘 다 없으면 추측하지 않고 멈춘다** (`tools/version.mjs` 의 `currentBranch()`
47
+ * 가 detached HEAD(**어느 브랜치에도 붙어 있지 않은 상태**) 에서 하는 것과 같다).
48
+ *
49
+ * ── 정책 파일이 어디 있나 ─────────────────────────────────────────────────
50
+ *
51
+ * `--policy <경로>` → 환경변수 `AXMAP_POLICY_PATH` → 기본값
52
+ * (`src/governance.mjs` 의 `DEFAULT_POLICY_PATH`). 플래그가 환경변수를 이긴다.
53
+ * 경로는 **저장소 루트 기준**이다 — 이 파일이 어디서 도는지와 무관하다.
54
+ *
55
+ * 🔴 못 찾으면 다른 자리를 대신 뒤지지 **않는다.** 폴백을 두면 두 자리 중 어느
56
+ * 것이 진짜 정책인지 아무도 모르게 된다. 멈추고 어디를 봤는지 말한다.
57
+ */
58
+
59
+ import { spawnSync } from 'node:child_process'
60
+ import {
61
+ EXIT,
62
+ DEFAULT_POLICY_PATH,
63
+ judge,
64
+ formatVerdict,
65
+ } from '../src/governance.mjs'
66
+
67
+ /** 표가 사는 브랜치. 장부 브랜치(`axmap/claims`)와 같은 방식이다 — 코드와 안 섞는다. */
68
+ const VOTES_BRANCH = 'axmap/votes'
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // git 호출 — bin/axmap.mjs 의 관례를 그대로 쓴다
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * git 이 훅(**커밋 같은 동작 직전·직후에 자동으로 도는 스크립트**)을 실행할 때
76
+ * 심어 놓는 환경변수들. cwd 보다 우선하므로, 다른 저장소·다른 인덱스를 가리키는
77
+ * 채로 남겨두면 게이트가 엉뚱한 곳을 읽는다. bin/axmap.mjs 와 같은 목록이다.
78
+ */
79
+ const GIT_ENV_KEYS = [
80
+ 'GIT_DIR',
81
+ 'GIT_WORK_TREE',
82
+ 'GIT_COMMON_DIR',
83
+ 'GIT_INDEX_FILE',
84
+ 'GIT_OBJECT_DIRECTORY',
85
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
86
+ 'GIT_PREFIX',
87
+ ]
88
+
89
+ function cleanEnv() {
90
+ const e = { ...process.env }
91
+ for (const k of GIT_ENV_KEYS) delete e[k]
92
+ return e
93
+ }
94
+
95
+ let ROOT = null
96
+
97
+ /**
98
+ * git 한 번. `raw` 는 다듬지 않은 stdout 이다 — `-z`(**출력을 NUL 문자로 구분**)
99
+ * 로 받은 목록은 trim 하면 안 되므로 둘을 따로 돌려준다.
100
+ */
101
+ function git(args, opts = {}) {
102
+ const r = spawnSync('git', args, {
103
+ cwd: opts.cwd ?? ROOT ?? undefined,
104
+ input: opts.input,
105
+ env: cleanEnv(),
106
+ encoding: 'utf8',
107
+ windowsHide: true,
108
+ maxBuffer: 64 * 1024 * 1024,
109
+ })
110
+ const out = r.stdout ?? ''
111
+ return { code: r.status ?? 1, out: out.trim(), raw: out, err: (r.stderr ?? '').trim() }
112
+ }
113
+
114
+ /**
115
+ * 판정 불가로 멈춘다.
116
+ *
117
+ * 기본 종료 코드가 1(판정 불가)인 이유: 여기까지 오는 실패는 전부 **환경 문제**다.
118
+ * 투표로는 풀리지 않으므로 미달(2)로 내보내면 사람이 영원히 투표를 기다린다.
119
+ */
120
+ function die(msg, code = EXIT.UNDECIDABLE) {
121
+ console.error(`합의 판정 불가 — ${msg}`)
122
+ process.exit(code)
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // 인자
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /** bin/axmap.mjs 의 parseArgs 와 같은 규칙. `--k v` · `--k=v` · `--k`(=true) */
130
+ function parseArgs(argv) {
131
+ const positional = []
132
+ const flags = {}
133
+ for (let i = 0; i < argv.length; i++) {
134
+ const a = argv[i]
135
+ if (!a.startsWith('--')) { positional.push(a); continue }
136
+ const eq = a.indexOf('=')
137
+ if (eq !== -1) flags[a.slice(2, eq)] = a.slice(eq + 1)
138
+ else if (argv[i + 1] && !argv[i + 1].startsWith('--')) flags[a.slice(2)] = argv[++i]
139
+ else flags[a.slice(2)] = true
140
+ }
141
+ return { positional, flags }
142
+ }
143
+
144
+ /** 플래그 값이 **문자열일 때만** 쓴다. `--source` 만 주면 값은 `true` 다. */
145
+ const str = (v) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : null)
146
+
147
+ const HELP = `합의 게이트 — 이 MR 이 정족수를 채웠는지 판정한다
148
+
149
+ node axmap/governance/gate.mjs [--source <브랜치>] [--target <브랜치>] [--json]
150
+
151
+ --source 합쳐 달라고 내미는 쪽 브랜치. 없으면 CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
152
+ --target 합쳐 받는 쪽 브랜치. 없으면 CI_MERGE_REQUEST_TARGET_BRANCH_NAME
153
+ --policy 정책 파일의 자리 (저장소 루트 기준). 없으면 AXMAP_POLICY_PATH
154
+ → ${DEFAULT_POLICY_PATH}. 못 찾으면 다른 자리를 뒤지지 않고 멈춘다
155
+ --json 판정 결과를 JSON 으로도 낸다 (사람이 읽는 판정문은 stderr 로 간다)
156
+ --votes-ref 표를 읽어올 ref 를 직접 지정한다 (기본: ${VOTES_BRANCH})
157
+ --remote 원격 이름. 없으면 git config axmap.remote → 유일한 원격 → origin
158
+ --no-fetch 원격에서 표·브랜치를 가져오지 않는다 (로컬에 있는 것만 본다)
159
+
160
+ 종료 코드
161
+ 0 정족수 충족 머지해도 된다
162
+ 2 정족수 미달 정상적인 "아직 아니다". 사람에게 투표를 요청한다
163
+ 1 판정 불가 환경을 고친다. 투표로는 안 풀린다
164
+ 4 정책 자체가 깨짐 정책을 고친다 (그 MR 은 개정 문턱을 지난다)
165
+ `
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // 재료 1 — 어느 브랜치에서 어느 브랜치로인가
169
+ // ---------------------------------------------------------------------------
170
+
171
+ function repoRoot() {
172
+ const r = git(['rev-parse', '--show-toplevel'], { cwd: process.cwd() })
173
+ if (r.code !== 0) die('git 저장소 안에서 실행해야 합니다.')
174
+ return r.out
175
+ }
176
+
177
+ /**
178
+ * 원격(**코드를 올려두는 서버 쪽 저장소**)의 이름.
179
+ *
180
+ * bin/axmap.mjs 의 `resolveRemote` 와 **같은 순서**로 고른다. 거기서는 못 고르면
181
+ * 멈추지만 여기서는 `null` 을 돌려주고 로컬만 본다 — 게이트는 아무것도 쓰지 않고,
182
+ * 표를 못 가져와서 생기는 오차는 언제나 **덜 세는 쪽**(= 통과가 아닌 쪽)이라
183
+ * 안전한 방향이기 때문이다.
184
+ */
185
+ function resolveRemote(flags) {
186
+ const all = git(['remote']).out.split('\n').map((s) => s.trim()).filter(Boolean)
187
+ const given = str(flags.remote) ?? str(process.env.AXMAP_REMOTE)
188
+ if (given) {
189
+ if (!all.includes(given)) {
190
+ die(`지정한 원격이 없습니다: ${given}${all.length ? ` (있는 것: ${all.join(', ')})` : ''}`)
191
+ }
192
+ return given
193
+ }
194
+ const cfg = git(['config', '--get', 'axmap.remote']).out
195
+ if (cfg && all.includes(cfg)) return cfg
196
+ if (all.length === 1) return all[0]
197
+ if (all.includes('origin')) return 'origin'
198
+ return null
199
+ }
200
+
201
+ /**
202
+ * 브랜치 이름 하나를 **40자 커밋 해시**로 바꾼다.
203
+ *
204
+ * 이름 대신 해시를 들고 다니는 이유: 재료를 모으는 동안 ref 가 움직여도 판정이
205
+ * 한 커밋 위에서 일관되게 돈다. 표는 sha 에 묶이므로(G3) 여기서 흔들리면 안 된다.
206
+ *
207
+ * CI 는 소스 브랜치만 받아온 상태일 수 있어서 타깃이 로컬에 없는 일이 흔하다.
208
+ * 그래서 로컬 → 원격 추적 ref → 실제 fetch 순으로 찾는다. **끝내 못 찾으면
209
+ * 추측하지 않고 멈춘다** — 엉뚱한 커밋을 타깃으로 삼으면 G2 가 무너진다.
210
+ */
211
+ function resolveCommit(name, remote, what, allowFetch) {
212
+ for (const t of [name, remote ? `refs/remotes/${remote}/${name}` : null]) {
213
+ if (!t) continue
214
+ const r = git(['rev-parse', '--verify', '--quiet', `${t}^{commit}`])
215
+ if (r.code === 0 && r.out) return r.out
216
+ }
217
+ if (allowFetch && remote) {
218
+ const f = git(['fetch', '--quiet', remote, name])
219
+ if (f.code === 0) {
220
+ const r = git(['rev-parse', '--verify', '--quiet', 'FETCH_HEAD^{commit}'])
221
+ if (r.code === 0 && r.out) return r.out
222
+ }
223
+ }
224
+ die(
225
+ `${what} 브랜치를 찾을 수 없습니다: ${name}\n`
226
+ + ` 로컬에도 ${remote ? `${remote}/${name} 에도 ` : ''}없습니다.\n`
227
+ + ` CI 라면 판정 전에 그 브랜치를 받아와야 합니다: git fetch ${remote ?? 'origin'} ${name}`,
228
+ )
229
+ return null // 도달하지 않는다 (die 가 프로세스를 끝낸다).
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // 재료 2 — 정책 (🔴 반드시 타깃 브랜치에서. G2)
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /**
237
+ * 정책 파일의 자리를 정한다: `--policy` 플래그 → 환경변수 `AXMAP_POLICY_PATH`
238
+ * → `DEFAULT_POLICY_PATH`. 플래그가 환경변수를 이긴다 (이 저장소의 다른 CLI 와
239
+ * 같은 관례 — `--remote` 가 `AXMAP_REMOTE` 를 이기는 것과 같다).
240
+ *
241
+ * 경로는 **저장소 루트 기준**이다. `git show <커밋>:<경로>` 에 그대로 들어가므로
242
+ * 앞의 `./` 나 `\` 는 git 이 못 알아본다. 여기서 다듬어 준다.
243
+ */
244
+ function resolvePolicyPath(flags) {
245
+ const given = str(flags.policy) ?? str(process.env.AXMAP_POLICY_PATH)
246
+ const raw = given ?? DEFAULT_POLICY_PATH
247
+ return raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '')
248
+ }
249
+
250
+ function readPolicy(targetRev, targetName, policyPath) {
251
+ // 🔴 `git show <커밋>:<경로>` 다. 디스크의 파일을 읽지 않는다 — 지금 체크아웃된
252
+ // 것은 대개 소스 브랜치이고, 소스의 정책으로 판정하면 G2 가 통째로 무너진다.
253
+ const r = git(['show', `${targetRev}:${policyPath}`])
254
+ if (r.code !== 0) {
255
+ // 🔴 다른 자리를 대신 찾아보지 않는다. 폴백을 두면 두 자리 중 어느 것이
256
+ // 진짜 정책인지 아무도 모르게 되고, 그것이 이 저장소가 "장부가 둘" 로 한 번
257
+ // 아프게 배운 모양이다. 대신 **어디를 봤는지**와 **어떻게 바꾸는지**를 말한다.
258
+ die(
259
+ `타깃 브랜치(${targetName})에서 정책 파일을 읽을 수 없습니다: ${policyPath}\n`
260
+ + ` ${r.err.split('\n')[0]}\n\n`
261
+ + '정책은 **반드시 타깃 브랜치에서** 읽습니다 (G2). 소스 브랜치의 정책을 대신\n'
262
+ + '쓰지 않는 이유: 정족수를 1 로 낮추는 MR 이 자기가 낮춘 규칙으로 통과합니다.\n\n'
263
+ + `본 자리는 ${policyPath} 하나뿐입니다. 다른 자리를 대신 뒤지지 않습니다 —\n`
264
+ + '두 자리를 다 보면 어느 것이 진짜 정책인지 아무도 모르게 되기 때문입니다.\n\n'
265
+ + `해결 1: ${policyPath} 를 타깃 브랜치에 먼저 머지하세요.\n`
266
+ + '해결 2: 자리가 다르면 알려주세요 —\n'
267
+ + ' node <이 파일> --policy <저장소 루트 기준 경로>\n'
268
+ + ' 또는 환경변수 AXMAP_POLICY_PATH=<경로> (플래그가 이깁니다)\n'
269
+ + ` 기본값: ${DEFAULT_POLICY_PATH}`,
270
+ )
271
+ }
272
+ try {
273
+ return JSON.parse(r.out)
274
+ } catch (e) {
275
+ die(`타깃 브랜치(${targetName})의 정책 JSON 을 읽을 수 없습니다: ${policyPath}\n ${e.message}`)
276
+ }
277
+ return null
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // 재료 3 — 무엇이 바뀌었나 / 누가 썼나
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /**
285
+ * 갈림점(**두 브랜치가 마지막으로 같았던 커밋**)부터 소스까지의 변경 목록.
286
+ *
287
+ * 🔴 `diff <타깃> <소스>` 가 아니라 `diff <갈림점> <소스>` 다. 앞의 것을 쓰면
288
+ * **타깃에만 있는 남의 변경까지 내 MR 의 변경으로 세어져** 문턱이 엉뚱하게
289
+ * 올라간다. MR 이 실제로 바꾸는 것은 갈림점 이후의 것뿐이다.
290
+ *
291
+ * 🔴 `-z` 와 `core.quotepath=false` 를 함께 쓴다. git 은 기본으로 ASCII 밖의
292
+ * 글자를 8진 이스케이프로 바꾸고 경로를 따옴표로 감싼다 —
293
+ *
294
+ * docs/bus/방향-전환.md → "docs/bus/\353\260\251\355\226\245-…"
295
+ *
296
+ * 이 저장소는 주석·문서·커밋 메시지가 전부 한국어라 파일 이름도 한국어가 된다.
297
+ * 따옴표가 붙으면 경로 규칙이 그 파일을 못 알아보고, 규칙이 안 걸리면 기본
298
+ * 문턱으로 떨어진다 — **막아야 할 것이 조용히 싸지는** 방향이다.
299
+ * (bin/axmap.mjs 의 `cmdVerify` 가 같은 이유로 같은 옵션을 쓴다.)
300
+ */
301
+ function changedPaths(targetRev, sourceRev) {
302
+ const mb = git(['merge-base', targetRev, sourceRev])
303
+ if (mb.code !== 0 || !mb.out) {
304
+ die(
305
+ '타깃과 소스의 갈림점(merge-base)을 찾지 못했습니다.\n'
306
+ + ' 두 브랜치가 공통 조상을 갖지 않습니다 (얕은 clone 이거나 서로 무관한 이력).\n'
307
+ + ' CI 라면 clone 깊이를 늘리세요 (GIT_DEPTH=0).',
308
+ )
309
+ }
310
+ const d = git(['-c', 'core.quotepath=false', 'diff', '--name-only', '-z', mb.out, sourceRev])
311
+ if (d.code !== 0) die(`변경 목록을 뜨지 못했습니다.\n ${d.err.split('\n')[0]}`)
312
+ return { base: mb.out, paths: d.raw.split('\0').filter((s) => s !== '') }
313
+ }
314
+
315
+ /**
316
+ * 소스에만 있는 커밋들의 author email. 자기 표 배제(G1)의 재료다.
317
+ *
318
+ * 못 모으면 `countVotes` 가 판정 불가로 던진다 — 자기 표를 못 거르면 셀 자격이
319
+ * 없기 때문이다. 그래서 여기서 빈 목록을 그럴듯한 값으로 채우지 않는다.
320
+ */
321
+ function sourceAuthors(targetRev, sourceRev) {
322
+ const r = git(['log', '--format=%aE', `${targetRev}..${sourceRev}`])
323
+ if (r.code !== 0) die(`소스 브랜치의 커밋 author 를 읽지 못했습니다.\n ${r.err.split('\n')[0]}`)
324
+ return [...new Set(r.out.split('\n').map((s) => s.trim()).filter(Boolean))]
325
+ }
326
+
327
+ /**
328
+ * 승계(**투표권자가 모자랄 때 최근 기여자에게 임시 투표권을 주는 것**)용 재료.
329
+ *
330
+ * `deriveVoters` 가 기대하는 모양은 **커밋 하나가 항목 하나**인 `{email, name, at}`
331
+ * 배열이다 (src/governance.mjs 의 주석). 사람 단위로 미리 묶지 않는다 — 커밋 수를
332
+ * 세고 순서를 정하는 것은 저쪽의 일이고, 여기서 묶으면 규칙이 두 군데로 갈라진다.
333
+ *
334
+ * `--since` 는 **커밋 날짜** 기준이고 `deriveVoters` 는 **author 날짜**로 다시
335
+ * 거른다. rebase(**커밋을 다른 기반 위로 옮겨 붙이는 것**) 하면 둘이 어긋나므로
336
+ * 창을 며칠 넉넉히 잡고 진짜 판정은 저쪽에 맡긴다. 넉넉히 잡는 쪽이 안전한 이유:
337
+ * 여기서 더 주워 와도 창 밖이면 저쪽이 버리지만, 여기서 빠뜨리면 저쪽은 그 사람이
338
+ * **죽은 것으로** 본다.
339
+ */
340
+ function contributorsFor(policy, targetRev, sourceRev) {
341
+ const days = Number.isInteger(policy?.succession?.window_days) ? policy.succession.window_days : 90
342
+ const r = git(['log', `--since=${days + 7}.days.ago`, '--format=%aE%x00%aN%x00%aI', targetRev, sourceRev])
343
+ if (r.code !== 0) die(`최근 기여 이력을 읽지 못했습니다.\n ${r.err.split('\n')[0]}`)
344
+ const out = []
345
+ for (const line of r.out.split('\n')) {
346
+ if (!line.trim()) continue
347
+ const [email, name, at] = line.split('\0')
348
+ if (!email || !at) continue
349
+ out.push({ email, name: name || null, at })
350
+ }
351
+ return out
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // 재료 4 — 표
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * 표를 읽어올 ref 를 정한다. 없으면 `null` — **그건 고장이 아니다.**
360
+ * "아무도 아직 투표 안 함" 은 표 0장으로 정상 판정해야 하고, 그러면 미달(2)이 난다.
361
+ */
362
+ function resolveVotesRef(remote, flags) {
363
+ const given = str(flags['votes-ref'])
364
+ if (given) {
365
+ const r = git(['rev-parse', '--verify', '--quiet', `${given}^{commit}`])
366
+ if (r.code !== 0 || !r.out) die(`--votes-ref 가 가리키는 것을 찾을 수 없습니다: ${given}`)
367
+ return { rev: r.out, from: given }
368
+ }
369
+ // 원격이 진실이다. 장부의 `syncLedger` 가 fetch 뒤 reset --hard 로 원격을 그대로
370
+ // 덮어쓰는 것과 같은 태도 — 표도 병합 대상이 아니다.
371
+ if (remote && flags['no-fetch'] !== true) {
372
+ if (git(['fetch', '--quiet', remote, VOTES_BRANCH]).code === 0) {
373
+ const r = git(['rev-parse', '--verify', '--quiet', 'FETCH_HEAD^{commit}'])
374
+ if (r.code === 0 && r.out) return { rev: r.out, from: `${remote}/${VOTES_BRANCH}` }
375
+ }
376
+ }
377
+ const local = `refs/heads/${VOTES_BRANCH}`
378
+ const tracked = remote ? `refs/remotes/${remote}/${VOTES_BRANCH}` : null
379
+ for (const cand of [local, tracked]) {
380
+ if (!cand) continue
381
+ const r = git(['rev-parse', '--verify', '--quiet', `${cand}^{commit}`])
382
+ if (r.code === 0 && r.out) return { rev: r.out, from: cand }
383
+ }
384
+ return null
385
+ }
386
+
387
+ /**
388
+ * `votes/<소스브랜치>/*.json` 을 전부 읽는다.
389
+ *
390
+ * 🔴 `committerEmail` 은 **파일에 적힌 값을 믿지 않고 git 에서 채운다.** 그 파일을
391
+ * 추가한 커밋의 committer(**커밋을 실제로 기록한 git 신원**)다. 표를 쓰는 사람이
392
+ * 적으면 대조가 아니라 자기 신고가 되고, 그러면 남의 이름으로 표를 만드는 데
393
+ * 아무 비용이 안 든다. 못 채우면 `null` 로 두어 `committer-unknown` 으로
394
+ * 안 세지게 한다 (fail-closed — **판단이 갈리면 안전한 쪽으로 닫는다**).
395
+ *
396
+ * 🔴 깨진 JSON 은 **건너뛰지 않는다.** 건너뛰면 깨진 표가 "없는 표" 가 되고,
397
+ * 없는 표는 아무 경고도 만들지 않는다. bin/axmap.mjs 의 `readClaims` 와 같은 규칙.
398
+ */
399
+ function readVotes(votes, branch) {
400
+ if (!votes) return []
401
+ const prefix = `votes/${branch}/`
402
+ const ls = git(['-c', 'core.quotepath=false', 'ls-tree', '-r', '--name-only', '-z', votes.rev, '--', prefix])
403
+ if (ls.code !== 0) die(`표 목록을 읽지 못했습니다 (${votes.from}).\n ${ls.err.split('\n')[0]}`)
404
+
405
+ const names = ls.raw
406
+ .split('\0')
407
+ .filter((s) => s !== '')
408
+ // 브랜치 이름의 `/` 는 그대로 디렉터리 구분자다. 그래서 `feat/x` 를 훑으면
409
+ // `feat/x/y` 의 표까지 딸려온다. **바로 아래 한 겹만** 본다 — 딸려온 표는
410
+ // `wrong-branch` 로 걸리기는 하지만, 남의 브랜치 표가 이 판정문에 섞여
411
+ // 보이는 것 자체가 오독을 만든다.
412
+ .filter((n) => n.startsWith(prefix) && n.endsWith('.json') && !n.slice(prefix.length).includes('/'))
413
+ .sort()
414
+
415
+ const out = []
416
+ for (const name of names) {
417
+ const blob = git(['show', `${votes.rev}:${name}`])
418
+ if (blob.code !== 0) die(`표 파일을 읽지 못했습니다: ${name}\n ${blob.err.split('\n')[0]}`)
419
+ let rec
420
+ try {
421
+ rec = JSON.parse(blob.out)
422
+ } catch (e) {
423
+ die(
424
+ `표 파일의 JSON 이 깨졌습니다: ${name}\n ${e.message}\n\n`
425
+ + '건너뛰지 않습니다 — 깨진 표를 없는 표로 세면 아무 경고 없이 조용히 사라집니다.',
426
+ )
427
+ }
428
+ // `--root` 는 부모 없는 첫 커밋에서 추가된 표까지 잡기 위한 것이다.
429
+ // 표 브랜치는 고아 브랜치(**아무 이력에도 붙지 않은 브랜치**)라 첫 표가
430
+ // 실제로 root 커밋에 들어간다.
431
+ const c = git(['log', '--root', '--diff-filter=A', '--format=%cE', '-1', votes.rev, '--', name])
432
+ const committerEmail = c.code === 0 && c.out ? c.out.split('\n')[0].trim() : null
433
+ const base = rec && typeof rec === 'object' && !Array.isArray(rec) ? rec : { malformed: rec }
434
+ out.push({
435
+ ...base,
436
+ // 파일에 적혀 있었더라도 **덮어쓴다.** 자기 신고를 받지 않는다.
437
+ committerEmail,
438
+ file: name,
439
+ })
440
+ }
441
+ return out
442
+ }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // 본체
446
+ // ---------------------------------------------------------------------------
447
+
448
+ const { flags } = parseArgs(process.argv.slice(2))
449
+ if (flags.help || flags.h) {
450
+ console.log(HELP)
451
+ process.exit(0)
452
+ }
453
+
454
+ ROOT = repoRoot()
455
+
456
+ const sourceName = str(flags.source) ?? str(process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME)
457
+ const targetName = str(flags.target) ?? str(process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME)
458
+
459
+ if (!sourceName || !targetName) {
460
+ // 🔴 추측하지 않는다. 타깃을 잘못 집으면 **엉뚱한 브랜치의 정책**으로 판정하게
461
+ // 되고(G2), 그건 아무 증상 없이 조용히 틀리는 종류의 실패다.
462
+ die(
463
+ '어느 브랜치에서 어느 브랜치로 합치는지 알 수 없습니다.\n'
464
+ + ` --source: ${sourceName ?? '(없음)'} --target: ${targetName ?? '(없음)'}\n\n`
465
+ + ' 손으로 : node axmap/governance/gate.mjs --source <브랜치> --target <브랜치>\n'
466
+ + ' CI 에서 : CI_MERGE_REQUEST_SOURCE_BRANCH_NAME / CI_MERGE_REQUEST_TARGET_BRANCH_NAME\n'
467
+ + ' (MR 파이프라인에서만 들어오는 값입니다)\n\n'
468
+ + '추측하지 않는 이유: 타깃을 잘못 집으면 엉뚱한 브랜치의 정책으로 판정합니다 (G2).',
469
+ )
470
+ }
471
+
472
+ const remote = resolveRemote(flags)
473
+ const allowFetch = flags['no-fetch'] !== true
474
+ const targetRev = resolveCommit(targetName, remote, '타깃', allowFetch)
475
+ const sourceRev = resolveCommit(sourceName, remote, '소스', allowFetch)
476
+
477
+ const policyPath = resolvePolicyPath(flags)
478
+ const policy = readPolicy(targetRev, targetName, policyPath)
479
+ const { base, paths: changed } = changedPaths(targetRev, sourceRev)
480
+ const authorEmails = sourceAuthors(targetRev, sourceRev)
481
+ const contributors = contributorsFor(policy, targetRev, sourceRev)
482
+ const votesRef = resolveVotesRef(remote, flags)
483
+ const votes = readVotes(votesRef, sourceName)
484
+
485
+ // 재료는 stderr 로 낸다. 판정문(stdout)과 섞이지 않으면서 CI 로그에는 남는다 —
486
+ // 판정이 이상할 때 사람이 제일 먼저 보는 것이 "무엇을 보고 판정했나" 다.
487
+ console.error([
488
+ `합의 게이트 ${sourceName} → ${targetName}`,
489
+ ` 소스 커밋 : ${sourceRev}`,
490
+ ` 갈림점 : ${base}`,
491
+ ` 정책 : ${targetName}:${policyPath} (🔴 타깃에서 읽습니다 — G2)`,
492
+ ` 바뀐 파일 : ${changed.length}개`,
493
+ ` 표 : ${votesRef ? `${votes.length}장 (${votesRef.from})` : `0장 (${VOTES_BRANCH} 브랜치가 아직 없습니다)`}`,
494
+ ].join('\n'))
495
+
496
+ /**
497
+ * 🔴 여기를 try/catch 로 감싸지 않는다.
498
+ *
499
+ * `judge` 는 `GovernanceError` 를 이미 결과(`exit`)로 바꿔서 돌려준다. 그 밖의
500
+ * 예외는 **우리 버그**이므로 스택 트레이스와 함께 그대로 터져야 한다. 삼키면
501
+ * 버그가 "판정 불가" 로 위장하고, 위장한 버그는 영원히 안 고쳐진다.
502
+ */
503
+ const verdict = judge({
504
+ policy,
505
+ changed,
506
+ votes,
507
+ authorEmails,
508
+ sha: sourceRev,
509
+ contributors,
510
+ now: Date.now(),
511
+ branch: sourceName,
512
+ policyPath,
513
+ })
514
+
515
+ const text = formatVerdict(verdict)
516
+ if (flags.json) {
517
+ // `axmap audit --json` 과 같은 관례: stdout 은 기계가 파싱할 수 있게 JSON 만 둔다.
518
+ // 사람이 읽는 판정문은 stderr 로 보내 CI 로그에서는 여전히 보이게 한다.
519
+ console.error(text)
520
+ console.log(JSON.stringify(verdict, null, 2))
521
+ } else {
522
+ console.log(text)
523
+ }
524
+
525
+ // 🔴 코드를 다시 고르지 않는다. 판정이 정한 것을 그대로 낸다.
526
+ process.exit(verdict.exit)