@cheongfish/bouncer 0.0.0-stage → 0.1.13

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 (156) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/bin/bouncer +26 -0
  4. package/bin/bouncer-root +25 -0
  5. package/bin/build-stamp.js +78 -0
  6. package/bin/register-path.js +71 -0
  7. package/dist/.build-stamp +1 -0
  8. package/dist/approval-snapshot.js +99 -0
  9. package/dist/bouncer-root.js +80 -0
  10. package/dist/canonical-json.js +37 -0
  11. package/dist/cli-commit-guard-command.js +74 -0
  12. package/dist/cli-config-command.js +47 -0
  13. package/dist/cli-context-review-command.js +273 -0
  14. package/dist/cli-current-command.js +374 -0
  15. package/dist/cli-dispatch-command.js +119 -0
  16. package/dist/cli-doc-commands.js +352 -0
  17. package/dist/cli-epic-command.js +130 -0
  18. package/dist/cli-flags.js +50 -0
  19. package/dist/cli-git-commands.js +931 -0
  20. package/dist/cli-intent-command.js +491 -0
  21. package/dist/cli-metrics-command.js +134 -0
  22. package/dist/cli-ontology-command.js +109 -0
  23. package/dist/cli-project-commands.js +580 -0
  24. package/dist/cli-retro-command.js +138 -0
  25. package/dist/cli-review-command.js +251 -0
  26. package/dist/cli-review-dispatch-command.js +338 -0
  27. package/dist/cli-usage.js +72 -0
  28. package/dist/cli.js +70 -0
  29. package/dist/codex-agents.js +189 -0
  30. package/dist/command-argv.js +125 -0
  31. package/dist/commit-guard.js +72 -0
  32. package/dist/commit-hook.js +470 -0
  33. package/dist/commit-message.js +77 -0
  34. package/dist/commit-sha.js +107 -0
  35. package/dist/commit.js +519 -0
  36. package/dist/composite-gates.js +129 -0
  37. package/dist/comprehension.js +127 -0
  38. package/dist/config.js +298 -0
  39. package/dist/context-ids.js +288 -0
  40. package/dist/context-migration-rewrite.js +421 -0
  41. package/dist/context-ontology.js +746 -0
  42. package/dist/context-review-contracts.js +2 -0
  43. package/dist/context-review-projection.js +147 -0
  44. package/dist/context-review-state.js +824 -0
  45. package/dist/context-review-store.js +602 -0
  46. package/dist/context-review-transitions.js +412 -0
  47. package/dist/coordinate-advance.js +253 -0
  48. package/dist/coordinate-next.js +998 -0
  49. package/dist/coordinate-output.js +57 -0
  50. package/dist/coordinator-command-contracts.js +2 -0
  51. package/dist/coordinator-commands.js +1908 -0
  52. package/dist/coordinator-fanin.js +1021 -0
  53. package/dist/coordinator-git.js +121 -0
  54. package/dist/coordinator-input.js +37 -0
  55. package/dist/coordinator-ledger.js +381 -0
  56. package/dist/coordinator-prepare.js +1047 -0
  57. package/dist/coordinator-repair.js +1416 -0
  58. package/dist/coordinator-verification.js +42 -0
  59. package/dist/coordinator-worker.js +388 -0
  60. package/dist/coordinator.js +7 -0
  61. package/dist/cross-drive.js +85 -0
  62. package/dist/current.js +1148 -0
  63. package/dist/dag-shape.js +93 -0
  64. package/dist/doc-shape-contracts.js +241 -0
  65. package/dist/drive-metrics.js +691 -0
  66. package/dist/epic-index.js +347 -0
  67. package/dist/epic-registry.js +453 -0
  68. package/dist/execute-prepare.js +262 -0
  69. package/dist/execution-contracts.js +46 -0
  70. package/dist/execution-dag.js +148 -0
  71. package/dist/finalize-checks.js +177 -0
  72. package/dist/finalize-collect.js +343 -0
  73. package/dist/finalize-comprehension.js +116 -0
  74. package/dist/finalize-digest-collect.js +388 -0
  75. package/dist/finalize-digest-model.js +595 -0
  76. package/dist/finalize-digest.js +164 -0
  77. package/dist/finalize-explain.js +176 -0
  78. package/dist/finalize-pr.js +610 -0
  79. package/dist/finalize-release-main.js +652 -0
  80. package/dist/finalize.js +714 -0
  81. package/dist/frontmatter.js +22 -0
  82. package/dist/git-ops.js +49 -0
  83. package/dist/graph-exec.js +179 -0
  84. package/dist/graph-scope.js +268 -0
  85. package/dist/graphify.js +995 -0
  86. package/dist/guards.js +82 -0
  87. package/dist/import-git.js +78 -0
  88. package/dist/import-history.js +392 -0
  89. package/dist/import-render.js +59 -0
  90. package/dist/import-types.js +2 -0
  91. package/dist/init.js +571 -0
  92. package/dist/intent-bundle.js +936 -0
  93. package/dist/intent-provenance.js +927 -0
  94. package/dist/layout.js +131 -0
  95. package/dist/lease.js +145 -0
  96. package/dist/legacy-comments.js +71 -0
  97. package/dist/migrate-context-slugs.js +262 -0
  98. package/dist/migrate-merge-legacy-epics.js +392 -0
  99. package/dist/migrate-review-scope.js +144 -0
  100. package/dist/migrate-task-layout.js +194 -0
  101. package/dist/path-overlap.js +38 -0
  102. package/dist/paths.js +45 -0
  103. package/dist/plan-approve.js +218 -0
  104. package/dist/plan-inspect.js +575 -0
  105. package/dist/plan-prerequisites.js +197 -0
  106. package/dist/plan-snapshot-contract.js +98 -0
  107. package/dist/plan-snapshot.js +138 -0
  108. package/dist/plugin-root.js +237 -0
  109. package/dist/posix-path.js +14 -0
  110. package/dist/pre-commit-hook.js +170 -0
  111. package/dist/print-dispatch.js +285 -0
  112. package/dist/render.js +8 -0
  113. package/dist/retention-migration.js +595 -0
  114. package/dist/retro-read.js +259 -0
  115. package/dist/retro-sources.js +318 -0
  116. package/dist/review-dispatch.js +1054 -0
  117. package/dist/review-policy.js +28 -0
  118. package/dist/review-record.js +447 -0
  119. package/dist/review-repair-state.js +52 -0
  120. package/dist/review-snapshot.js +659 -0
  121. package/dist/run-detach.js +165 -0
  122. package/dist/run-preflight.js +368 -0
  123. package/dist/runtime-state.js +1038 -0
  124. package/dist/scaffold.js +533 -0
  125. package/dist/schema.js +294 -0
  126. package/dist/scope.js +1073 -0
  127. package/dist/seed-worktree.js +926 -0
  128. package/dist/session-graph.js +337 -0
  129. package/dist/subagents.js +86 -0
  130. package/dist/symbol-index.js +1390 -0
  131. package/dist/task-brief-hash.js +34 -0
  132. package/dist/task-commits.js +124 -0
  133. package/dist/tasks-docs.js +192 -0
  134. package/dist/templates.js +469 -0
  135. package/dist/time.js +21 -0
  136. package/dist/validate-docs.js +302 -0
  137. package/dist/validate-gates-finalize.js +303 -0
  138. package/dist/validate-gates-plan.js +720 -0
  139. package/dist/validate-gates-run.js +420 -0
  140. package/dist/validate-gates-shared.js +124 -0
  141. package/dist/validate-gates.js +87 -0
  142. package/dist/validate-sections.js +654 -0
  143. package/dist/validate-structural.js +347 -0
  144. package/dist/validate.js +417 -0
  145. package/dist/verification-contracts.js +26 -0
  146. package/dist/verification-evidence.js +233 -0
  147. package/dist/verification-execution.js +163 -0
  148. package/dist/verification-identity.js +413 -0
  149. package/dist/verification-target.js +51 -0
  150. package/dist/verification.js +383 -0
  151. package/dist/windows-npm.js +38 -0
  152. package/package.json +32 -4
  153. package/resources/graphify-compat.json +9 -0
  154. package/vendor/README.md +23 -0
  155. package/vendor/js-yaml.LICENSE +21 -0
  156. package/vendor/js-yaml.js +3022 -0
@@ -0,0 +1,659 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const nodeBuffer = require('node:buffer');
5
+ const { execFileSync } = require('node:child_process');
6
+ const { randomBytes } = require('node:crypto');
7
+ const canonicalJsonMod = require("./canonical-json");
8
+ const { canonicalJson, sha256Canonical } = canonicalJsonMod;
9
+ const pathsMod = require("./paths");
10
+ const { toPosix } = pathsMod;
11
+ // 실행 리뷰가 보는 "최종 파일 상태"를 한 번 동결해 보존하는 공유 모듈이다.
12
+ // 규모 분류(review-dispatch)와 이후의 라운드 바인딩·acceptance 검사가 같은
13
+ // 수집 규칙·같은 해시를 써야 두 경로가 서로 다른 대상을 가리키지 않으므로,
14
+ // 수집과 일치 검사를 이 파일 한 곳에만 둔다.
15
+ const SNAPSHOT_VERSION = 1;
16
+ const SNAPSHOT_ID_RE = /^[0-9a-f]{64}$/;
17
+ const SNAPSHOT_DIR = '.bouncer/runtime/review-snapshots';
18
+ const RUNTIME_PREFIX = '.bouncer/runtime/';
19
+ const CONTEXT_PREFIX = '.bouncer/context/';
20
+ // 리뷰·검증 CLI가 스스로 쓰는 증거 문서. 이것이 id에 들어가면 증거를 쓰는 순간
21
+ // 자기 자신을 stale로 만들어 acceptance가 영원히 통과하지 못한다.
22
+ const EVIDENCE_FILES = ['review.md', 'context-review.md', 'verification.md'];
23
+ // git은 텍스트 diff 하나가 기본 execFileSync 버퍼(1MB)를 쉽게 넘는다. 넘치면 ENOBUFS로
24
+ // 실패해 큰 변경일수록 분류가 안 되는 역전이 생기므로 넉넉히 잡는다.
25
+ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
26
+ // 잠금·pathspec 해석을 고정하는 git 전역 옵션. --no-optional-locks는 diff/ls-files가
27
+ // index stat 캐시를 갱신하며 index 파일을 다시 쓰는 일을 막아 "수집은 index를 바꾸지
28
+ // 않는다"를 지키고, --literal-pathspecs는 특수 파일명이 glob으로 해석되는 것을 막는다.
29
+ const GIT_PREFIX = ['--no-optional-locks', '--literal-pathspecs'];
30
+ /** 내부 흐름을 끊고 공개 경계에서 `{ok:false}`로 바꾸기 위한 typed 실패. */
31
+ class SnapshotError extends Error {
32
+ reason;
33
+ cause_;
34
+ next;
35
+ constructor(reason, cause, next) {
36
+ super(`${reason}: ${cause}`);
37
+ this.reason = reason;
38
+ this.cause_ = cause;
39
+ this.next = next;
40
+ }
41
+ }
42
+ const NEXT_RETRY = 'fix the reported cause, then run review-dispatch execute again';
43
+ const NEXT_REDISPATCH = 'run review-dispatch execute again on the current worktree and re-review';
44
+ /**
45
+ * 실제 Git·파일 시스템에 묶인 기본 I/O 경계를 만든다.
46
+ * 테스트는 이 중 일부를 덮어써 읽기 실패·수집 중 변경·저장 실패를 주입한다.
47
+ *
48
+ * @param {string} repoRoot - git 명령의 cwd
49
+ * @returns {SnapshotDeps} 기본 주입 경계
50
+ */
51
+ function defaultDeps(repoRoot) {
52
+ return {
53
+ runGit: (args) => execFileSync('git', args, {
54
+ cwd: repoRoot, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'pipe'],
55
+ }),
56
+ // O_NOFOLLOW: lstat 뒤 읽기 전에 일반 파일이 symlink로 바뀌어도 외부 내용을
57
+ // 따라 읽지 않는다. 열기 실패는 읽기 실패로 올라간다.
58
+ readFile: (absPath) => {
59
+ const fd = fs.openSync(absPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
60
+ try {
61
+ return fs.readFileSync(fd);
62
+ }
63
+ finally {
64
+ fs.closeSync(fd);
65
+ }
66
+ },
67
+ lstat: (absPath) => fs.lstatSync(absPath),
68
+ readlink: (absPath) => Buffer.from(fs.readlinkSync(absPath, { encoding: 'buffer' })),
69
+ persistArtifact,
70
+ };
71
+ }
72
+ /**
73
+ * artifact를 같은 디렉터리 임시 파일에 쓴 뒤 rename으로 게시한다.
74
+ * 중간에 죽어도 읽는 쪽은 완전한 파일이나 부재만 본다. 실패하면 throw한다.
75
+ *
76
+ * @param {string} absPath - 최종 artifact 절대 경로
77
+ * @param {Buffer} content - 쓸 바이트
78
+ * @returns {void}
79
+ */
80
+ function persistArtifact(absPath, content) {
81
+ const dir = path.dirname(absPath);
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ const tmp = path.join(dir, `.${path.basename(absPath)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
84
+ // 열기 실패(EEXIST 등)는 우리가 만든 파일이 없으므로 정리 없이 올린다.
85
+ const fd = fs.openSync(tmp, 'wx', 0o644);
86
+ try {
87
+ try {
88
+ fs.writeFileSync(fd, content);
89
+ }
90
+ finally {
91
+ fs.closeSync(fd);
92
+ }
93
+ fs.renameSync(tmp, absPath);
94
+ }
95
+ catch (error) {
96
+ try {
97
+ fs.unlinkSync(tmp);
98
+ }
99
+ catch (unlinkError) {
100
+ // ENOENT만 흡수: rename이 이미 tmp를 옮긴 경우라 지울 것이 없다. 그 외는 원인을 가리지 않는다.
101
+ if (unlinkError.code !== 'ENOENT')
102
+ throw unlinkError;
103
+ }
104
+ throw error;
105
+ }
106
+ }
107
+ /**
108
+ * 자동 증거 경로인지 판정한다. 수집과 일치 검사가 같은 함수를 써야 한다.
109
+ * `.bouncer/runtime/**`와 `.bouncer/context` 아래 review.md·context-review.md·
110
+ * verification.md 및 그 원자 쓰기 임시 파일(`.<name>.…tmp`)만 제외한다.
111
+ * 계획 문서·설정·일반 소스는 리뷰 대상이므로 제외하지 않는다.
112
+ *
113
+ * @param {string} rel - 저장소 상대 POSIX 경로
114
+ * @returns {boolean} 자동 증거면 true
115
+ */
116
+ function isAutoEvidence(rel) {
117
+ if (rel === RUNTIME_PREFIX.slice(0, -1) || rel.startsWith(RUNTIME_PREFIX))
118
+ return true;
119
+ if (!rel.startsWith(CONTEXT_PREFIX))
120
+ return false;
121
+ const base = rel.slice(rel.lastIndexOf('/') + 1);
122
+ return EVIDENCE_FILES.some((name) => (base === name || (base.startsWith(`.${name}.`) && base.endsWith('.tmp'))));
123
+ }
124
+ /**
125
+ * git이 돌려준 경로가 저장소 안의 정규 상대 경로인지 확인한다.
126
+ * 절대경로·`..`·빈 세그먼트는 저장소 밖 파일을 읽게 만들 수 있어 거절한다.
127
+ *
128
+ * @param {string} rel - git이 보고한 경로
129
+ * @returns {string} 같은 경로(검증 통과)
130
+ */
131
+ function assertSafeRel(rel) {
132
+ const segments = rel.split('/');
133
+ if (!rel || rel.startsWith('/') || segments.some((s) => s === '' || s === '.' || s === '..')) {
134
+ throw new SnapshotError('unsafe-path', `path is not a normalized repo-relative path: ${JSON.stringify(rel)}`, NEXT_RETRY);
135
+ }
136
+ return rel;
137
+ }
138
+ /**
139
+ * lstat을 감싸 부재(ENOENT/ENOTDIR)만 null로 접는다.
140
+ * 권한 오류 등 나머지는 "없음"으로 오인하지 않고 읽기 실패로 올린다.
141
+ *
142
+ * @param {SnapshotDeps} deps - 주입 경계
143
+ * @param {string} abs - 절대 경로
144
+ * @returns {FileMeta | null} 메타데이터, 없으면 null
145
+ */
146
+ function statOrNull(deps, abs) {
147
+ try {
148
+ return deps.lstat(abs);
149
+ }
150
+ catch (error) {
151
+ const code = error.code;
152
+ if (code === 'ENOENT' || code === 'ENOTDIR')
153
+ return null;
154
+ throw new SnapshotError('read-failed', `lstat ${abs}: ${error.message}`, NEXT_RETRY);
155
+ }
156
+ }
157
+ /**
158
+ * 외부 I/O 한 호출의 어떤 실패든 같은 reason의 typed 실패로 바꾼다.
159
+ * 한 호출만 감싸므로 서로 다른 단계의 실패가 하나로 뭉개지지 않고, 원문 메시지는 cause에 남는다.
160
+ *
161
+ * @param {string} reason - 실패 reason
162
+ * @param {() => T} fn - 감쌀 I/O 호출 하나
163
+ * @returns {T} fn의 반환값
164
+ */
165
+ function guard(reason, fn) {
166
+ try {
167
+ return fn();
168
+ }
169
+ catch (error) {
170
+ if (error instanceof SnapshotError)
171
+ throw error;
172
+ const message = error instanceof Error ? error.message : String(error);
173
+ throw new SnapshotError(reason, message, NEXT_RETRY);
174
+ }
175
+ }
176
+ /**
177
+ * `rel`의 조상 디렉터리 중 symlink가 있으면 거절한다. symlink 조상을 통과하면
178
+ * 저장소 밖 내용을 읽거나 runtime 밖에 쓸 수 있다. 아직 없는 조상에서 멈춘다.
179
+ *
180
+ * @param {string} repoRoot - 저장소 루트
181
+ * @param {string} rel - 저장소 상대 경로
182
+ * @param {SnapshotDeps} deps - 주입 경계
183
+ * @param {Set<string>} verified - 이미 확인한 조상 캐시
184
+ * @returns {void}
185
+ */
186
+ function assertPlainAncestors(repoRoot, rel, deps, verified) {
187
+ const parts = rel.split('/').slice(0, -1);
188
+ let current = '';
189
+ for (const part of parts) {
190
+ current = current ? `${current}/${part}` : part;
191
+ if (verified.has(current))
192
+ continue;
193
+ const meta = statOrNull(deps, path.join(repoRoot, current));
194
+ if (!meta)
195
+ return;
196
+ if (meta.isSymbolicLink()) {
197
+ throw new SnapshotError('unsafe-path', `ancestor directory is a symlink: ${current}`, NEXT_RETRY);
198
+ }
199
+ verified.add(current);
200
+ }
201
+ }
202
+ /**
203
+ * snapshot id로 artifact의 저장소 상대 경로를 유도한다. 수집과 로더가 같은 규칙을 쓴다.
204
+ *
205
+ * @param {string} snapshotId - 64자 hex id
206
+ * @returns {string} 저장소 상대 POSIX 경로
207
+ */
208
+ function snapshotPathOf(snapshotId) {
209
+ return `${SNAPSHOT_DIR}/${snapshotId}/snapshot.json`;
210
+ }
211
+ /**
212
+ * 파일 메타데이터 서명. 읽는 동안 바뀌었는지 비교하는 용도다.
213
+ *
214
+ * @param {FileMeta | null} meta - lstat 결과, 부재면 null
215
+ * @returns {string} 비교용 문자열
216
+ */
217
+ function metaSig(meta) {
218
+ return meta ? `${meta.mode}:${meta.size}:${meta.mtimeMs}:${meta.ino}` : 'absent';
219
+ }
220
+ /**
221
+ * git 명령 한 번을 typed 실패로 감싸 실행한다.
222
+ *
223
+ * @param {SnapshotDeps} deps - 주입 경계
224
+ * @param {string[]} args - git 하위 명령과 인자(전역 옵션은 여기서 붙인다)
225
+ * @returns {Buffer} stdout
226
+ */
227
+ function git(deps, args) {
228
+ return guard('git-failed', () => Buffer.from(deps.runGit([...GIT_PREFIX, ...args])));
229
+ }
230
+ /**
231
+ * ref를 커밋 SHA로 해석한다.
232
+ *
233
+ * @param {SnapshotDeps} deps - 주입 경계
234
+ * @param {string} ref - ref 또는 SHA
235
+ * @returns {string} 해석된 커밋 SHA
236
+ */
237
+ function resolveCommit(deps, ref) {
238
+ return git(deps, ['rev-parse', '--verify', `${ref}^{commit}`]).toString('utf8').trim();
239
+ }
240
+ /**
241
+ * NUL로 끝나는 git -z 출력을 항목 배열로 자른다.
242
+ *
243
+ * @param {Buffer} out - git -z stdout
244
+ * @returns {string[]} 빈 항목 없는 항목들
245
+ */
246
+ function splitZ(out) {
247
+ return out.toString('utf8').split('\0').filter((s) => s !== '');
248
+ }
249
+ /**
250
+ * `git diff --raw -z --no-renames`를 파싱한다. 병합 안 된 항목과 gitlink(submodule)는
251
+ * 표현할 수 없어 거절한다.
252
+ *
253
+ * @param {Buffer} out - git diff --raw -z stdout
254
+ * @returns {RawDiffEntry[]} 상태·경로 목록(증거 필터 전)
255
+ */
256
+ function parseRawDiff(out) {
257
+ const tokens = splitZ(out);
258
+ const entries = [];
259
+ for (let i = 0; i + 1 < tokens.length; i += 2) {
260
+ const meta = /^:(\d+) (\d+) [0-9a-f]+ [0-9a-f]+ ([A-Z])\d*$/.exec(tokens[i]);
261
+ if (!meta) {
262
+ throw new SnapshotError('git-failed', `unparseable diff --raw row: ${tokens[i]}`, NEXT_RETRY);
263
+ }
264
+ const [, oldMode, newMode, status] = meta;
265
+ if (status === 'U') {
266
+ throw new SnapshotError('unsupported-state', `unmerged path cannot be represented: ${tokens[i + 1]}`, 'resolve the merge conflict, then run review-dispatch execute again');
267
+ }
268
+ if (oldMode === '160000' || newMode === '160000') {
269
+ throw new SnapshotError('unsupported-state', `submodule (gitlink) change cannot be frozen: ${tokens[i + 1]}`, 'keep submodule pointer changes out of the reviewed range');
270
+ }
271
+ entries.push({ status, path: tokens[i + 1] });
272
+ }
273
+ return entries;
274
+ }
275
+ /**
276
+ * `git ls-files -s -z`와 `git ls-tree -r -z`로 index가 base tree와 다른 항목만 모은다.
277
+ * 같은 최종 파일 내용이라도 staged 여부가 다르면 id가 달라져야 하므로 index 쪽 정보를 담는다.
278
+ * 전체 index를 담지 않는 이유: 변경과 무관한 수천 항목이 artifact를 부풀리고,
279
+ * base와 같은 항목은 id에 정보를 더하지 못한다.
280
+ *
281
+ * @param {Buffer} indexOut - ls-files -s -z stdout
282
+ * @param {Buffer} treeOut - ls-tree -r -z stdout (base)
283
+ * @returns {IndexEntry[]} 경로순 정렬된 차이 항목(자동 증거 제외)
284
+ */
285
+ function diffIndexAgainstBase(indexOut, treeOut) {
286
+ const index = new Map();
287
+ for (const row of splitZ(indexOut)) {
288
+ const m = /^(\d+) ([0-9a-f]+) (\d)\t([\s\S]*)$/.exec(row);
289
+ if (!m)
290
+ throw new SnapshotError('git-failed', `unparseable ls-files row: ${row}`, NEXT_RETRY);
291
+ if (m[3] !== '0') {
292
+ throw new SnapshotError('unsupported-state', `conflicted index entry (stage ${m[3]}): ${m[4]}`, 'resolve the merge conflict, then run review-dispatch execute again');
293
+ }
294
+ if (m[1] === '160000') {
295
+ throw new SnapshotError('unsupported-state', `submodule (gitlink) in index cannot be frozen: ${m[4]}`, 'keep submodule pointer changes out of the reviewed range');
296
+ }
297
+ index.set(m[4], { mode: m[1], blob: m[2] });
298
+ }
299
+ const tree = new Map();
300
+ for (const row of splitZ(treeOut)) {
301
+ const m = /^(\d+) (\w+) ([0-9a-f]+)\t([\s\S]*)$/.exec(row);
302
+ if (!m)
303
+ throw new SnapshotError('git-failed', `unparseable ls-tree row: ${row}`, NEXT_RETRY);
304
+ tree.set(m[4], { mode: m[1], blob: m[3] });
305
+ }
306
+ const entries = [];
307
+ for (const p of new Set([...index.keys(), ...tree.keys()])) {
308
+ if (isAutoEvidence(p))
309
+ continue;
310
+ const a = index.get(p);
311
+ const b = tree.get(p);
312
+ if (a && b && a.mode === b.mode && a.blob === b.blob)
313
+ continue;
314
+ entries.push({ path: p, mode: a ? a.mode : null, blob: a ? a.blob : null });
315
+ }
316
+ return entries.sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
317
+ }
318
+ /**
319
+ * `git diff --numstat -z`의 경로별 추가·삭제 줄을 읽는다. binary(`-`)는 null.
320
+ *
321
+ * @param {Buffer} out - numstat -z stdout
322
+ * @returns {Map<string, number | null>} 경로 → 줄 수 합(binary는 null)
323
+ */
324
+ function parseNumstat(out) {
325
+ const lines = new Map();
326
+ for (const row of splitZ(out)) {
327
+ const m = /^(-|\d+)\t(-|\d+)\t([\s\S]*)$/.exec(row);
328
+ if (!m)
329
+ throw new SnapshotError('git-failed', `unparseable numstat row: ${row}`, NEXT_RETRY);
330
+ lines.set(m[3], m[1] === '-' || m[2] === '-' ? null : Number(m[1]) + Number(m[2]));
331
+ }
332
+ return lines;
333
+ }
334
+ /**
335
+ * 텍스트 파일의 줄 수. 마지막 줄에 개행이 없어도 한 줄로 센다.
336
+ *
337
+ * @param {string} text - 파일 내용
338
+ * @returns {number} 줄 수
339
+ */
340
+ function countLines(text) {
341
+ if (text === '')
342
+ return 0;
343
+ const newlines = text.split('\n').length - 1;
344
+ return text.endsWith('\n') ? newlines : newlines + 1;
345
+ }
346
+ /**
347
+ * 변경 감지용 git 상태 지문. 수집 전후로 두 번 읽어 같은지 비교한다.
348
+ *
349
+ * @param {SnapshotDeps} deps - 주입 경계
350
+ * @param {string} base - 해석된 base SHA
351
+ * @returns {{ head: string, index: Buffer, untracked: Buffer, raw: Buffer }} 원 출력들
352
+ */
353
+ function readGitState(deps, base) {
354
+ return {
355
+ head: resolveCommit(deps, 'HEAD'),
356
+ index: git(deps, ['ls-files', '-s', '-z', '--full-name']),
357
+ untracked: git(deps, ['ls-files', '--others', '--exclude-standard', '-z', '--full-name']),
358
+ raw: git(deps, ['diff', '--raw', '-z', '--no-renames', '--no-abbrev', '--no-ext-diff', base]),
359
+ };
360
+ }
361
+ /**
362
+ * 두 git 상태가 같은지 비교한다.
363
+ *
364
+ * @param {ReturnType<typeof readGitState>} a - 이전 상태
365
+ * @param {ReturnType<typeof readGitState>} b - 이후 상태
366
+ * @returns {boolean} 같으면 true
367
+ */
368
+ function sameGitState(a, b) {
369
+ return a.head === b.head && a.index.equals(b.index)
370
+ && a.untracked.equals(b.untracked) && a.raw.equals(b.raw);
371
+ }
372
+ /**
373
+ * base 이후의 최종 작업 트리 상태를 메모리에 수집한다(저장하지 않는다).
374
+ * 수집과 일치 검사가 이 함수를 공유해, 같은 입력이 항상 같은 id가 되게 한다.
375
+ *
376
+ * 수집 중 파일·index가 바뀌면 changed-during-capture로 거절한다. 일관되지 않은
377
+ * 스냅샷을 성공으로 돌려주면 리뷰어가 본 내용과 acceptance 대상이 갈라진다.
378
+ *
379
+ * @param {ScopeArgs} args - 저장소·범위·base/head·주입 경계
380
+ * @param {SnapshotDeps} deps - 기본값과 병합된 주입 경계
381
+ * @returns {ReviewSnapshot} 수집된 snapshot
382
+ */
383
+ function collectSnapshot(args, deps) {
384
+ const { repoRoot } = args;
385
+ // 1. head는 현재 HEAD와 같아야 한다. 과거 HEAD는 작업 트리 상태와 짝이 안 맞아
386
+ // "그 커밋 시점의 리뷰"로 오해될 수 있어 지원하지 않는다.
387
+ const base = resolveCommit(deps, args.base);
388
+ const head = resolveCommit(deps, args.head);
389
+ const before = readGitState(deps, base);
390
+ if (head !== before.head) {
391
+ throw new SnapshotError('head-mismatch', `head ${head} is not the current HEAD ${before.head}`, 'review the current HEAD: pass --head HEAD (resolved) and run review-dispatch execute again');
392
+ }
393
+ // 2. 변경 경로 집합: 추적 파일의 base 대비 최종 차이 + git ignore를 따르는 untracked.
394
+ const verifiedDirs = new Set();
395
+ const tracked = parseRawDiff(before.raw)
396
+ .map((e) => ({ ...e, path: assertSafeRel(e.path) }))
397
+ .filter((e) => !isAutoEvidence(e.path));
398
+ const untracked = splitZ(before.untracked)
399
+ // 내용이 추적되지 않는 중첩 git 저장소(예: 별도 worktree)는 `dir/` 한 항목으로 보고된다.
400
+ // 부모 저장소의 최종 상태가 아니므로 ignored와 같이 제외한다. 추적 중인 submodule의 변경은
401
+ // 위의 gitlink 검사(parseRawDiff)가 따로 거절한다.
402
+ .filter((p) => !p.endsWith('/'))
403
+ .map(assertSafeRel)
404
+ .filter((p) => !isAutoEvidence(p));
405
+ const untrackedSet = new Set(untracked);
406
+ for (const e of tracked) {
407
+ if (untrackedSet.has(e.path)) {
408
+ // index에서 지웠지만 같은 경로 파일이 남은 상태는 삭제+추가가 겹쳐 한 번만 세는 규칙을 깬다.
409
+ throw new SnapshotError('unsupported-state', `path is removed from the index but present untracked: ${e.path}`, 'stage or restore the path, then run review-dispatch execute again');
410
+ }
411
+ }
412
+ // 3. 파일 내용 동결. 읽기 전후 lstat이 같아야 하고, 마지막에 한 번 더 확인한다.
413
+ const sigs = new Map();
414
+ const files = [];
415
+ const untrackedLines = new Map();
416
+ const readOne = (rel, expectGone) => {
417
+ assertPlainAncestors(repoRoot, rel, deps, verifiedDirs);
418
+ const abs = path.join(repoRoot, rel);
419
+ const meta = statOrNull(deps, abs);
420
+ if (!meta) {
421
+ if (!expectGone) {
422
+ throw new SnapshotError('changed-during-capture', `file disappeared while capturing: ${rel}`, NEXT_REDISPATCH);
423
+ }
424
+ sigs.set(rel, 'absent');
425
+ files.push({ path: rel, mode: null, kind: 'deleted', content: '' });
426
+ return;
427
+ }
428
+ if (expectGone) {
429
+ throw new SnapshotError('changed-during-capture', `file reappeared while capturing: ${rel}`, NEXT_REDISPATCH);
430
+ }
431
+ let file;
432
+ if (meta.isSymbolicLink()) {
433
+ // 링크 target 문자열만 담는다. 가리키는 외부 내용은 따라 읽지 않는다.
434
+ const target = guard('read-failed', () => deps.readlink(abs)).toString('utf8');
435
+ file = { path: rel, mode: '120000', kind: 'symlink', content: target };
436
+ }
437
+ else if (meta.isFile()) {
438
+ const bytes = guard('read-failed', () => deps.readFile(abs));
439
+ const mode = (meta.mode & 0o111) !== 0 ? '100755' : '100644';
440
+ file = nodeBuffer.isUtf8(bytes) && !bytes.includes(0)
441
+ ? { path: rel, mode, kind: 'text', content: bytes.toString('utf8') }
442
+ : { path: rel, mode, kind: 'binary', content: bytes.toString('base64') };
443
+ }
444
+ else {
445
+ throw new SnapshotError('unsupported-state', `special file cannot be frozen: ${rel}`, NEXT_RETRY);
446
+ }
447
+ if (metaSig(statOrNull(deps, abs)) !== metaSig(meta)) {
448
+ throw new SnapshotError('changed-during-capture', `file changed while it was read: ${rel}`, NEXT_REDISPATCH);
449
+ }
450
+ sigs.set(rel, metaSig(meta));
451
+ files.push(file);
452
+ if (untrackedSet.has(rel) && file.kind === 'text')
453
+ untrackedLines.set(rel, countLines(file.content));
454
+ };
455
+ for (const e of tracked)
456
+ readOne(e.path, e.status === 'D');
457
+ for (const p of untracked)
458
+ readOne(p, false);
459
+ // 4. 추적 파일의 동결 diff·줄 수. 변경이 없으면 pathspec이 비어 전체 diff가 되므로 건너뛴다.
460
+ let diff = '';
461
+ let trackedLines = new Map();
462
+ if (tracked.length > 0) {
463
+ const paths = tracked.map((e) => e.path);
464
+ const diffArgs = ['diff', '--no-ext-diff', '--no-textconv', '--no-color', '--no-renames'];
465
+ diff = git(deps, [...diffArgs, base, '--', ...paths]).toString('utf8');
466
+ trackedLines = parseNumstat(git(deps, [...diffArgs, '--numstat', '-z', base, '--', ...paths]));
467
+ }
468
+ let changedLines = 0;
469
+ for (const e of tracked)
470
+ changedLines += trackedLines.get(e.path) ?? 0;
471
+ for (const n of untrackedLines.values())
472
+ changedLines += n;
473
+ const indexEntries = diffIndexAgainstBase(before.index, git(deps, ['ls-tree', '-r', '-z', '--full-tree', base]));
474
+ // 5. 수집 중 변경 검사: git 상태와 모든 파일 서명이 처음과 같아야 한다.
475
+ if (!sameGitState(before, readGitState(deps, base))) {
476
+ throw new SnapshotError('changed-during-capture', 'HEAD, index, or the changed-path set moved while capturing', NEXT_REDISPATCH);
477
+ }
478
+ for (const [rel, sig] of sigs) {
479
+ if (metaSig(statOrNull(deps, path.join(repoRoot, rel))) !== sig) {
480
+ throw new SnapshotError('changed-during-capture', `file changed during capture: ${rel}`, NEXT_REDISPATCH);
481
+ }
482
+ }
483
+ files.sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
484
+ return {
485
+ version: SNAPSHOT_VERSION,
486
+ scope: { blueprint: toPosix(args.blueprintDir), task: args.taskId ? args.taskId : null },
487
+ target: { base, head },
488
+ diff,
489
+ files,
490
+ index_entries: indexEntries,
491
+ changed_files: files.length,
492
+ changed_lines: changedLines,
493
+ };
494
+ }
495
+ /**
496
+ * 공개 경계: 내부 SnapshotError만 `{ok:false}`로 바꾸고 나머지(프로그래밍 오류)는 다시 던진다.
497
+ *
498
+ * @param {() => SnapshotOk} fn - 실행할 본문
499
+ * @returns {SnapshotResult} 성공 또는 typed 실패
500
+ */
501
+ function toResult(fn) {
502
+ try {
503
+ return fn();
504
+ }
505
+ catch (error) {
506
+ if (error instanceof SnapshotError) {
507
+ return { ok: false, reason: error.reason, cause: error.cause_, next: error.next };
508
+ }
509
+ throw error;
510
+ }
511
+ }
512
+ /**
513
+ * 최종 작업 트리 상태를 한 번 동결해 runtime artifact로 보존한다.
514
+ * 같은 입력은 같은 id를 낸다. 같은 id 파일이 이미 있으면 내용 해시를 검증해 재사용하고,
515
+ * 검증에 실패하면 덮어쓰지 않고 거절한다. 소스·index·브랜치는 바꾸지 않는다.
516
+ *
517
+ * @param {ScopeArgs} args - repoRoot, blueprintDir, taskId, 해석된 base/head, deps
518
+ * @returns {SnapshotResult} 성공 시 id·상대 경로·snapshot, 실패 시 reason·cause·next
519
+ */
520
+ function captureReviewSnapshot(args) {
521
+ const deps = { ...defaultDeps(args.repoRoot), ...(args.deps || {}) };
522
+ return toResult(() => {
523
+ const snapshot = collectSnapshot(args, deps);
524
+ const snapshotId = sha256Canonical(snapshot);
525
+ const rel = snapshotPathOf(snapshotId);
526
+ const abs = path.join(args.repoRoot, rel);
527
+ assertPlainAncestors(args.repoRoot, rel, deps, new Set());
528
+ if (statOrNull(deps, abs)) {
529
+ // 검증 없이 덮어쓰면 손상된 파일이 정상 id 뒤에 숨는다. 같은 내용일 때만 재사용한다.
530
+ const loaded = readVerified(deps, abs, snapshotId);
531
+ if (!loaded) {
532
+ throw new SnapshotError('snapshot-corrupt', `existing artifact does not match its id: ${rel}`, 'remove the corrupt dir under .bouncer/runtime/review-snapshots, then run review-dispatch execute again');
533
+ }
534
+ }
535
+ else {
536
+ guard('persist-failed', () => deps.persistArtifact(abs, Buffer.from(`${canonicalJson(snapshot)}\n`)));
537
+ }
538
+ return { ok: true, snapshot_id: snapshotId, snapshot_path: rel, snapshot };
539
+ });
540
+ }
541
+ /**
542
+ * artifact 파일을 읽어 내용 해시가 id와 같을 때만 snapshot을 돌려준다.
543
+ *
544
+ * @param {SnapshotDeps} deps - 주입 경계
545
+ * @param {string} abs - artifact 절대 경로
546
+ * @param {string} snapshotId - 기대 id
547
+ * @returns {ReviewSnapshot | null} 검증된 snapshot, 손상·형식 위반이면 null
548
+ */
549
+ function readVerified(deps, abs, snapshotId) {
550
+ const bytes = guard('read-failed', () => deps.readFile(abs));
551
+ let parsed;
552
+ try {
553
+ parsed = JSON.parse(bytes.toString('utf8'));
554
+ }
555
+ catch (error) {
556
+ // SyntaxError만 손상으로 흡수한다: 잘린 파일은 "손상"이고 호출자가 같은 reason으로 처리한다.
557
+ if (error instanceof SyntaxError)
558
+ return null;
559
+ throw error;
560
+ }
561
+ if (!isSnapshotShape(parsed) || sha256Canonical(parsed) !== snapshotId)
562
+ return null;
563
+ return parsed;
564
+ }
565
+ /**
566
+ * snapshot 최소 형태 검사. 해시가 맞아도 손으로 만든 다른 모양을 소비자에게 넘기지 않는다.
567
+ *
568
+ * @param {unknown} value - 파싱된 JSON
569
+ * @returns {boolean} 계약 형태이면 true
570
+ */
571
+ function isSnapshotShape(value) {
572
+ if (!value || typeof value !== 'object')
573
+ return false;
574
+ const s = value;
575
+ const scope = s.scope;
576
+ const target = s.target;
577
+ return s.version === SNAPSHOT_VERSION
578
+ && !!scope && typeof scope.blueprint === 'string'
579
+ && (scope.task === null || typeof scope.task === 'string')
580
+ && !!target && typeof target.base === 'string' && typeof target.head === 'string'
581
+ && typeof s.diff === 'string'
582
+ && Array.isArray(s.files) && Array.isArray(s.index_entries)
583
+ && Number.isInteger(s.changed_files) && Number.isInteger(s.changed_lines);
584
+ }
585
+ /**
586
+ * 저장된 snapshot artifact를 id로 읽어 내용 해시·형태를 검증한다.
587
+ *
588
+ * @param {{ repoRoot: string, snapshotId: string, deps?: Partial<SnapshotDeps> }} args - 저장소·id·주입 경계
589
+ * @returns {SnapshotResult} 성공 시 snapshot, 실패 시 reason은
590
+ * invalid-snapshot-id|snapshot-missing|snapshot-corrupt|read-failed|unsafe-path
591
+ */
592
+ function loadReviewSnapshot(args) {
593
+ const deps = { ...defaultDeps(args.repoRoot), ...(args.deps || {}) };
594
+ return toResult(() => loadOrThrow(args.repoRoot, args.snapshotId, deps));
595
+ }
596
+ /**
597
+ * loadReviewSnapshot의 본문. 일치 검사가 typed 실패를 그대로 올리도록 throw 형태로 둔다.
598
+ *
599
+ * @param {string} repoRoot - 저장소 루트
600
+ * @param {string} snapshotId - 64자 hex id
601
+ * @param {SnapshotDeps} deps - 주입 경계
602
+ * @returns {SnapshotOk} 검증된 snapshot
603
+ */
604
+ function loadOrThrow(repoRoot, snapshotId, deps) {
605
+ // id를 경로에 쓰기 전에 형식을 고정한다. `../` 같은 값이 runtime 밖 파일을 읽게 하지 않는다.
606
+ if (typeof snapshotId !== 'string' || !SNAPSHOT_ID_RE.test(snapshotId)) {
607
+ throw new SnapshotError('invalid-snapshot-id', 'snapshot id must be 64 lowercase hex characters', 'pass the snapshot_id printed by review-dispatch execute');
608
+ }
609
+ const rel = snapshotPathOf(snapshotId);
610
+ const abs = path.join(repoRoot, rel);
611
+ assertPlainAncestors(repoRoot, rel, deps, new Set());
612
+ const meta = statOrNull(deps, abs);
613
+ if (!meta) {
614
+ throw new SnapshotError('snapshot-missing', `no artifact at ${rel}`, NEXT_REDISPATCH);
615
+ }
616
+ if (!meta.isFile()) {
617
+ throw new SnapshotError('snapshot-corrupt', `artifact is not a regular file: ${rel}`, NEXT_REDISPATCH);
618
+ }
619
+ const snapshot = readVerified(deps, abs, snapshotId);
620
+ if (!snapshot) {
621
+ throw new SnapshotError('snapshot-corrupt', `artifact content does not hash to its id: ${rel}`, NEXT_REDISPATCH);
622
+ }
623
+ return { ok: true, snapshot_id: snapshotId, snapshot_path: rel, snapshot };
624
+ }
625
+ /**
626
+ * 저장된 snapshot이 현재 작업 트리 상태와 아직 같은지 검사한다(저장하지 않는다).
627
+ * 범위(blueprint·task)와 base/head가 다르거나, 현재 상태를 같은 규칙으로 다시 수집한 id가
628
+ * 저장 id와 다르면 stale로 거절한다. 리뷰 중에 바뀐 대상을 accepted로 기록하지 않기 위함이다.
629
+ *
630
+ * @param {ScopeArgs & { snapshotId: string }} args - 저장소·id·범위·base/head·주입 경계
631
+ * @returns {SnapshotResult} 일치하면 로드된 snapshot, 아니면 reason은
632
+ * scope-mismatch|target-mismatch|head-mismatch|snapshot-stale 등
633
+ */
634
+ function checkReviewSnapshotCurrent(args) {
635
+ const deps = { ...defaultDeps(args.repoRoot), ...(args.deps || {}) };
636
+ return toResult(() => {
637
+ const loaded = loadOrThrow(args.repoRoot, args.snapshotId, deps);
638
+ const stored = loaded.snapshot;
639
+ const scope = { blueprint: toPosix(args.blueprintDir), task: args.taskId ? args.taskId : null };
640
+ if (stored.scope.blueprint !== scope.blueprint || stored.scope.task !== scope.task) {
641
+ throw new SnapshotError('scope-mismatch', `snapshot is for ${JSON.stringify(stored.scope)}, not ${JSON.stringify(scope)}`, NEXT_REDISPATCH);
642
+ }
643
+ const current = collectSnapshot(args, deps);
644
+ if (stored.target.base !== current.target.base || stored.target.head !== current.target.head) {
645
+ throw new SnapshotError('target-mismatch', 'snapshot base/head differ from the requested base/head', NEXT_REDISPATCH);
646
+ }
647
+ if (sha256Canonical(current) !== args.snapshotId) {
648
+ throw new SnapshotError('snapshot-stale', 'the worktree changed after the snapshot was frozen', NEXT_REDISPATCH);
649
+ }
650
+ return loaded;
651
+ });
652
+ }
653
+ module.exports = {
654
+ SNAPSHOT_VERSION,
655
+ isAutoEvidence,
656
+ captureReviewSnapshot,
657
+ loadReviewSnapshot,
658
+ checkReviewSnapshotCurrent,
659
+ };