@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,163 @@
1
+ 'use strict';
2
+ const path = require('node:path');
3
+ const { spawnSync } = require('node:child_process');
4
+ const config = require("./config");
5
+ const { readVerifyPolicy } = config;
6
+ const windowsNpm = require("./windows-npm");
7
+ const { resolveSpawn } = windowsNpm;
8
+ const commandArgv = require("./command-argv");
9
+ const { parseVerifyArgv, isValidVerifyCommand } = commandArgv;
10
+ // 통과한 실행은 명령이 0으로 종료되었다는 증거입니다. tail에는 명령이
11
+ // 끝에 출력하는 요약만 담으면 됩니다. 실패한 실행은 무엇이 잘못됐는지에 대한
12
+ // 증거이므로 훨씬 더 많이 — 그리고 리뷰어가 읽는 문서 본문에, frontmatter에만
13
+ // 두지 않고 — 보관합니다.
14
+ const OUTPUT_TAIL_LINES = 100;
15
+ const PASSING_OUTPUT_TAIL_LINES = 20;
16
+ const MAX_VERIFY_OUTPUT_BYTES = 10 * 1024 * 1024;
17
+ /**
18
+ * 증적 크기를 제한하면서 실패 원인 또는 성공 요약이 있는 마지막 줄을 보존한다.
19
+ * @param {unknown} stdout - 실행 표준 출력
20
+ * @param {unknown} stderr - 실행 표준 오류
21
+ * @param {number} lines - 보존할 최대 줄 수
22
+ * @returns {string} 앞뒤 공백을 제거한 출력 tail
23
+ */
24
+ function outputTail(stdout, stderr, lines = OUTPUT_TAIL_LINES) {
25
+ const combined = [stdout, stderr].filter(Boolean).join('');
26
+ return combined.split('\n').slice(-lines).join('\n').trim();
27
+ }
28
+ /**
29
+ * cwd의 `.bouncer/config.json`에서 런타임 허용 목록을 읽는다.
30
+ * allowlist 옵션을 생략한 finalize `executeVerify(command, { cwd })`가
31
+ * 저장소 정책을 따르게 한다. 파일이 없을 때만 기본 목록이다. 파손된
32
+ * 설정에 기본 목록을 주면 직접 호출이 plan/read와 다른 답을 낸다.
33
+ *
34
+ * @param {string} cwd - 실행 작업 디렉터리
35
+ * @returns {{ ok: true, allowlist: readonly string[] } | { ok: false, output: string }}
36
+ * invalid면 프로세스를 시작하지 말라는 오류 출력
37
+ */
38
+ function resolveRuntimeAllowlist(cwd) {
39
+ const policy = readVerifyPolicy(cwd);
40
+ if (policy.ok === false) {
41
+ return {
42
+ ok: false,
43
+ output: `verification config is invalid: ${path.join(cwd, '.bouncer', 'config.json')}`,
44
+ };
45
+ }
46
+ return { ok: true, allowlist: policy.allowlist };
47
+ }
48
+ /**
49
+ * 파싱된 argv를 shell:false로 실행한다. 허용 목록 밖 argv0·파싱 실패는
50
+ * 프로세스를 시작하기 전에 `{ ok: false }`로 돌려 — throw하지 않는다.
51
+ * finalize가 `readVerifyCommand`만 try/catch하고 `executeVerify`는 bare로
52
+ * 호출하므로, 여기 throw는 cmdFinalize를 스택으로 무너뜨린다. 증적에
53
+ * 남는 command 문자열은 호출자가 넘긴 원문을 유지한다.
54
+ *
55
+ * allowlist를 생략하면 cwd의 저장소 정책을 쓴다. 파손된 config는
56
+ * throw하지 않고 `{ ok: false }`로 돌려 finalize JSON 경로를 유지한다.
57
+ * 테스트만 명시적 allowlist로 덮어쓴다.
58
+ *
59
+ * @param {string} command - 원문 검증 명령 문자열
60
+ * @param {{ cwd: string, exec?: VerifyExec, allowlist?: readonly string[] }} opts - 실행 옵션
61
+ * @returns {{ ok: boolean, exitCode: number, output: string }} 종료 코드와 출력 tail
62
+ */
63
+ function executeVerify(command, { cwd, exec, allowlist }) {
64
+ // 생략과 명시적 전달을 구분한다. 기본 매개변수로 DEFAULT를 붙이면
65
+ // finalize처럼 allowlist 없이 호출해도 저장소 config를 읽지 못한다.
66
+ let resolvedAllowlist;
67
+ if (allowlist !== undefined) {
68
+ resolvedAllowlist = allowlist;
69
+ }
70
+ else {
71
+ const resolved = resolveRuntimeAllowlist(cwd);
72
+ if (resolved.ok === false) {
73
+ return { ok: false, exitCode: 1, output: resolved.output };
74
+ }
75
+ resolvedAllowlist = resolved.allowlist;
76
+ }
77
+ const argv = parseVerifyArgv(command);
78
+ // throw 대신 ok:false — finalize의 `if (!execution.ok)` JSON 경로로 보낸다.
79
+ if (!argv || !isValidVerifyCommand(command, resolvedAllowlist)) {
80
+ return {
81
+ ok: false,
82
+ exitCode: 1,
83
+ output: 'verify command must be a single executable command',
84
+ };
85
+ }
86
+ const file = argv[0];
87
+ const args = argv.slice(1);
88
+ const runOpts = {
89
+ cwd,
90
+ encoding: 'utf8',
91
+ stdio: ['ignore', 'pipe', 'pipe'],
92
+ maxBuffer: MAX_VERIFY_OUTPUT_BYTES,
93
+ shell: false,
94
+ };
95
+ try {
96
+ let result;
97
+ if (!exec) {
98
+ // npm/npx는 Windows에서 .cmd shim이라 shell:false로는 실행되지 않는다. node로 CLI를 직접 띄운다.
99
+ const call = resolveSpawn(file, args);
100
+ result = spawnSync(call.command, call.args, { ...runOpts, shell: call.shell });
101
+ }
102
+ else if (exec.length < 3) {
103
+ // execSync 계약: 원문 command 문자열 + opts. argv를 이어 붙이면
104
+ // 인용 인자가 깨지므로 호출자가 넘긴 원문을 그대로 준다.
105
+ result = exec(command, runOpts);
106
+ }
107
+ else {
108
+ result = exec(file, args, runOpts);
109
+ }
110
+ // spawnSync는 비0에서도 throw하지 않는다. 주입 exec가 Error를 던지는
111
+ // 예전 테스트 계약도 아래 catch에서 흡수한다.
112
+ if (result && typeof result === 'object' && 'error' in result && result.error) {
113
+ const err = result.error;
114
+ const stdout = 'stdout' in result ? result.stdout : '';
115
+ const stderr = 'stderr' in result ? result.stderr : (err && typeof err === 'object' && 'message' in err ? String(err.message) : '');
116
+ return {
117
+ ok: false,
118
+ exitCode: 1,
119
+ output: outputTail(stdout, stderr),
120
+ };
121
+ }
122
+ const status = result && typeof result === 'object' && 'status' in result
123
+ ? result.status
124
+ : 0;
125
+ const stdout = result && typeof result === 'object' && 'stdout' in result
126
+ ? result.stdout
127
+ : result;
128
+ const stderr = result && typeof result === 'object' && 'stderr' in result
129
+ ? result.stderr
130
+ : '';
131
+ // spawnSync는 시그널 종료 시 status=null이다. 그걸 0으로 접으면
132
+ // 실패 실행이 passed 증적으로 남는다.
133
+ if (status === 0) {
134
+ return {
135
+ ok: true,
136
+ exitCode: 0,
137
+ output: outputTail(stdout, stderr, PASSING_OUTPUT_TAIL_LINES),
138
+ };
139
+ }
140
+ return {
141
+ ok: false,
142
+ exitCode: Number.isInteger(status) ? Number(status) : 1,
143
+ output: outputTail(stdout, stderr),
144
+ };
145
+ }
146
+ catch (error) {
147
+ const status = typeof error === 'object' && error !== null && 'status' in error
148
+ ? error.status
149
+ : undefined;
150
+ const stdout = typeof error === 'object' && error !== null && 'stdout' in error
151
+ ? error.stdout
152
+ : undefined;
153
+ const stderr = typeof error === 'object' && error !== null && 'stderr' in error
154
+ ? error.stderr
155
+ : undefined;
156
+ return {
157
+ ok: false,
158
+ exitCode: Number.isInteger(status) ? Number(status) : 1,
159
+ output: outputTail(stdout, stderr),
160
+ };
161
+ }
162
+ }
163
+ module.exports = { OUTPUT_TAIL_LINES, PASSING_OUTPUT_TAIL_LINES, MAX_VERIFY_OUTPUT_BYTES, executeVerify };
@@ -0,0 +1,413 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { createHash } = require('node:crypto');
5
+ const { spawnSync } = require('node:child_process');
6
+ const canonicalJsonMod = require("./canonical-json");
7
+ const { sha256Canonical } = canonicalJsonMod;
8
+ const guards = require("./guards");
9
+ const { isRecord } = guards;
10
+ const frontmatter = require("./frontmatter");
11
+ const { readDoc } = frontmatter;
12
+ const contextIds = require("./context-ids");
13
+ const { parsePointerKey, formatStableTaskId } = contextIds;
14
+ const paths = require("./paths");
15
+ const { toPosix, parsePathIds } = paths;
16
+ const commitSha = require("./commit-sha");
17
+ const { buildStableProvenance } = commitSha;
18
+ const scope = require("./scope");
19
+ const { isUnder, RUNTIME_ARTIFACTS } = scope;
20
+ const verification_contracts_1 = require("./verification-contracts");
21
+ // config.json 부재와 빈 파일·파손 파일을 같은 해시로 접지 않기 위한 고정값.
22
+ // 환경 해시가 플랫폼만 같아도 “설정 없음”을 구분해야 miss가 된다.
23
+ const VERIFY_CONFIG_MISSING_SENTINEL = '__bouncer_verify_config_missing__';
24
+ const SCOPE_KINDS = new Set(['task', 'wave', 'terminal']);
25
+ const TASK_DIR_RE = /(?:^|\/)tasks\/(\d{3})(?:\/|$)/;
26
+ /**
27
+ * scope shape을 검사한다. kind/key가 잘못되면 process를 시작하기 전에 거절한다.
28
+ *
29
+ * @param {unknown} scope - 호출자 또는 포인터에서 온 범위
30
+ * @returns {VerificationScope} 정규화된 scope
31
+ */
32
+ function assertVerificationScope(scope) {
33
+ if (!isRecord(scope)) {
34
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', 'verification scope must be an object');
35
+ }
36
+ const kind = scope.kind;
37
+ const key = scope.key;
38
+ if (typeof kind !== 'string' || !SCOPE_KINDS.has(kind)) {
39
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', 'verification scope.kind must be task, wave, or terminal');
40
+ }
41
+ if (typeof key !== 'string' || key.trim() === '') {
42
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', 'verification scope.key must be a non-empty string');
43
+ }
44
+ return { kind: kind, key };
45
+ }
46
+ /**
47
+ * 활성 effective task 또는 번호 순 첫 task 묶음에서 task scope를 만든다.
48
+ * worker cwd에서 lease를 확인하지 못하면 VERIFY_IDENTITY_INVALID다 —
49
+ * pointer로 폴백하면 다른 task의 evidence key가 섞인다. frontmatter가 있으면
50
+ * stable provenance를 쓰고, epic/blueprint 누락·형식 거절·문서 부재는 경로
51
+ * 숫자로 같은 형식의 키를 만든다. numbered entries가 비면 레거시 루트
52
+ * `blueprintDir/tasks.md`를 본다 — omit-scope 호출자가 예전처럼
53
+ * executeVerify까지 닿게 한다.
54
+ *
55
+ * @param {string} repoRoot - 저장소 루트
56
+ * @param {string} blueprintDir - blueprint 상대 경로
57
+ * @param {EffectiveTask | null} effective - 호출자가 선택한 task 또는 null
58
+ * @param {Array} entries - 기존 대상 선택 경계가 제공한 문서 목록
59
+ * @returns {VerificationScope} `{ kind: 'task', key: 'EPIC-…/BP-…/TASK-…' }` 또는 slug 경로의 `<epic>/<bp>/NNN`
60
+ */
61
+ function resolveDefaultTaskScope(repoRoot, blueprintDir, effective, entries) {
62
+ if (effective && effective.source === null) {
63
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', 'no active lease for worker worktree');
64
+ }
65
+ const bp = toPosix(blueprintDir);
66
+ let tasksRel = null;
67
+ if (effective && typeof effective.path === 'string' && toPosix(effective.blueprint) === bp) {
68
+ tasksRel = toPosix(effective.path);
69
+ }
70
+ else if (entries[0] && entries[0].tasks && entries[0].tasks.rel) {
71
+ tasksRel = toPosix(entries[0].tasks.rel);
72
+ }
73
+ // resolveVerificationRel과 같은 레거시 폴백. listing이 비어도 루트
74
+ // tasks.md가 있으면 omit-scope 호출이 tasksRel 부재로 막히지 않게 한다.
75
+ if (!tasksRel) {
76
+ const legacyRel = `${bp}/tasks.md`;
77
+ if (fs.existsSync(path.join(repoRoot, legacyRel))) {
78
+ tasksRel = legacyRel;
79
+ }
80
+ }
81
+ if (!tasksRel) {
82
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', 'verification task scope requires an active pointer task or task unit');
83
+ }
84
+ const abs = path.join(repoRoot, tasksRel);
85
+ try {
86
+ const { data } = readDoc(abs);
87
+ const bouncer = data && isRecord(data) ? data.bouncer : null;
88
+ if (isRecord(bouncer)) {
89
+ const stable = buildStableProvenance({
90
+ epicId: bouncer.epic_id,
91
+ blueprintId: bouncer.blueprint_id,
92
+ taskId: bouncer.id,
93
+ });
94
+ return { kind: 'task', key: stable.task };
95
+ }
96
+ }
97
+ catch {
98
+ // ENOENT·깨진 frontmatter뿐 아니라 epic/blueprint 누락으로 나는
99
+ // three-digit/TASKS-NNN 거절도 경로 폴백으로 넘긴다. coordinator
100
+ // fixture는 id만 두고 epic_id를 생략하므로, 여기서 hard-fail하면
101
+ // omit-scope integrate가 executeVerify에 닿지 못한다. 경로에서도
102
+ // 키를 못 만들 때만 아래에서 identity 오류로 올린다.
103
+ }
104
+ const ids = parsePathIds(tasksRel);
105
+ const taskMatch = TASK_DIR_RE.exec(tasksRel);
106
+ // 경로 key의 형식(번호/slug)을 모양으로 판정한다. 형식이 섞인 경로는 한 task를 두 체계로
107
+ // 식별하게 되므로 키를 만들지 않는다.
108
+ const keys = ids.epicId && ids.blueprintId ? parsePointerKey(`${ids.epicId}/${ids.blueprintId}`) : null;
109
+ if (!keys || !taskMatch) {
110
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', 'cannot derive stable task scope from pointer or task path');
111
+ }
112
+ return {
113
+ kind: 'task',
114
+ // 번호 경로는 예전과 같은 `EPIC-…/BP-…/TASK-…`(evidence_id 해시 입력)이고 slug 경로는 `<epic>/<bp>/NNN`이다.
115
+ key: formatStableTaskId({ ...keys, task: taskMatch[1] }),
116
+ };
117
+ }
118
+ /**
119
+ * deps.git 또는 spawnSync로 git argv를 실행한다. 비0·예외는 identity 오류다.
120
+ *
121
+ * @param {string} repoRoot - cwd
122
+ * @param {string[]} args - git 인자(git 자체 제외)
123
+ * @param {VerificationDeps} [deps] - 주입 git
124
+ * @returns {string} stdout
125
+ */
126
+ function runGit(repoRoot, args, deps) {
127
+ try {
128
+ if (deps && typeof deps.git === 'function') {
129
+ return String(deps.git(args) ?? '');
130
+ }
131
+ const result = spawnSync('git', args, {
132
+ cwd: repoRoot,
133
+ encoding: 'utf8',
134
+ stdio: ['ignore', 'pipe', 'pipe'],
135
+ });
136
+ if (result.status !== 0) {
137
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', `git ${args.join(' ')} failed: ${String(result.stderr || result.error || 'non-zero exit')}`);
138
+ }
139
+ return String(result.stdout || '');
140
+ }
141
+ catch (error) {
142
+ if ((0, verification_contracts_1.errorCode)(error) === 'VERIFY_IDENTITY_INVALID')
143
+ throw error;
144
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', `git ${args.join(' ')} failed: ${error instanceof Error ? error.message : String(error)}`);
145
+ }
146
+ }
147
+ /**
148
+ * 파일을 읽어 Buffer로 돌려준다. 주입 readFile이 있으면 그걸 쓴다.
149
+ *
150
+ * @param {string} absPath - 절대 경로
151
+ * @param {VerificationDeps} [deps] - 주입 읽기
152
+ * @returns {Buffer} 파일 바이트
153
+ */
154
+ function readBytes(absPath, deps) {
155
+ try {
156
+ if (deps && typeof deps.readFile === 'function') {
157
+ const raw = deps.readFile(absPath);
158
+ return typeof raw === 'string' ? Buffer.from(raw, 'utf8') : raw;
159
+ }
160
+ return fs.readFileSync(absPath);
161
+ }
162
+ catch (error) {
163
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', `failed to read ${absPath}: ${error instanceof Error ? error.message : String(error)}`);
164
+ }
165
+ }
166
+ /**
167
+ * porcelain -z 한 항목과 대상 path의 type·content digest를 만든다.
168
+ * 절대 worktree 경로는 identity에 넣지 않고 repo-relative POSIX만 쓴다.
169
+ *
170
+ * @param {string} repoRoot - 저장소 루트
171
+ * @param {string} xy - status XY 코드
172
+ * @param {string} relPath - dirty 상대경로
173
+ * @param {VerificationDeps} [deps] - 파일 읽기 주입
174
+ * @returns {{ path: string, xy: string, type: string, content_sha256: string }}
175
+ */
176
+ function dirtyEntryDigest(repoRoot, xy, relPath, deps) {
177
+ const posixRel = toPosix(relPath);
178
+ const abs = path.resolve(repoRoot, posixRel);
179
+ const rootReal = fs.realpathSync(repoRoot);
180
+ // resolve만으로도 .. 탈출을 막지만, realpath로 symlink 탈출도 거절한다.
181
+ let targetReal = abs;
182
+ try {
183
+ if (fs.existsSync(abs))
184
+ targetReal = fs.realpathSync(abs);
185
+ }
186
+ catch (_error) {
187
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', `dirty path is not readable: ${posixRel}`);
188
+ }
189
+ const relToRoot = path.relative(rootReal, targetReal);
190
+ // `..foo` 같은 in-repo 이름은 `startsWith('..')`에 걸리면 안 된다.
191
+ // 부모 탈출만 거절: 정확히 `..` 이거나 `../`·`..\` 세그먼트로 시작할 때.
192
+ if (relToRoot === '..'
193
+ || relToRoot.startsWith(`..${path.sep}`)
194
+ || path.isAbsolute(relToRoot)) {
195
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', `dirty path escapes the repository: ${posixRel}`);
196
+ }
197
+ // missing은 catch 경로에서만 확정 — 초기값 할당은 전 분기에서 덮어써 no-useless-assignment에 걸린다.
198
+ let type;
199
+ let contentSha = createHash('sha256').update('', 'utf8').digest('hex');
200
+ try {
201
+ const st = fs.lstatSync(abs);
202
+ if (st.isSymbolicLink()) {
203
+ type = 'symlink';
204
+ contentSha = createHash('sha256').update(fs.readlinkSync(abs), 'utf8').digest('hex');
205
+ }
206
+ else if (st.isDirectory()) {
207
+ type = 'directory';
208
+ contentSha = createHash('sha256').update('dir', 'utf8').digest('hex');
209
+ }
210
+ else if (st.isFile()) {
211
+ type = 'file';
212
+ contentSha = createHash('sha256').update(readBytes(abs, deps)).digest('hex');
213
+ }
214
+ else {
215
+ type = 'other';
216
+ }
217
+ }
218
+ catch (error) {
219
+ if ((0, verification_contracts_1.errorCode)(error) === 'VERIFY_IDENTITY_INVALID')
220
+ throw error;
221
+ // 삭제된 dirty(D) 등은 파일이 없어도 status 항목 자체는 digest에 남긴다.
222
+ type = 'missing';
223
+ }
224
+ return { path: posixRel, xy, type, content_sha256: contentSha };
225
+ }
226
+ /**
227
+ * `git status --porcelain=v1 -z`와 dirty path 내용을 정렬한 digest를 계산한다.
228
+ *
229
+ * @param {string} repoRoot - 저장소 루트
230
+ * @param {VerificationDeps} [deps] - git/readFile 주입
231
+ * @returns {string} dirty digest SHA-256
232
+ */
233
+ function computeDirtyDigest(repoRoot, deps) {
234
+ const raw = runGit(repoRoot, ['status', '--porcelain=v1', '-z'], deps);
235
+ const entries = [];
236
+ const parts = String(raw).split('\0').filter((part) => part.length > 0);
237
+ for (let i = 0; i < parts.length; i += 1) {
238
+ const part = parts[i];
239
+ // porcelain -z: "XY PATH" 또는 rename이면 다음 토큰이 새 경로.
240
+ const xy = part.slice(0, 2);
241
+ const pathPart = part.slice(3);
242
+ const isRename = xy[0] === 'R' || xy[0] === 'C' || xy[1] === 'R' || xy[1] === 'C';
243
+ if (isRename && i + 1 < parts.length) {
244
+ const fromPath = pathPart;
245
+ const toPath = parts[i + 1];
246
+ i += 1;
247
+ entries.push(dirtyEntryDigest(repoRoot, xy, fromPath, deps));
248
+ entries.push(dirtyEntryDigest(repoRoot, xy, toPath, deps));
249
+ }
250
+ else {
251
+ entries.push(dirtyEntryDigest(repoRoot, xy, pathPart, deps));
252
+ }
253
+ }
254
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
255
+ return sha256Canonical(entries);
256
+ }
257
+ /**
258
+ * porcelain 한 경로가 source_digest에서 빠지는지 본다.
259
+ * `.bouncer/`는 execute→commit 사이 문서 상태 변경이 증거를 무효화하지 않게
260
+ * 빼고, RUNTIME_ARTIFACTS는 scope와 같은 목록을 써야 두 경로가 갈라지지 않는다.
261
+ *
262
+ * @param {string} relPath - repo-relative POSIX 경로
263
+ * @returns {boolean} 제외하면 true
264
+ */
265
+ function isExcludedFromSourceDigest(relPath) {
266
+ const posixRel = toPosix(relPath);
267
+ if (isUnder(posixRel, '.bouncer'))
268
+ return true;
269
+ return RUNTIME_ARTIFACTS.some((entry) => isUnder(posixRel, entry));
270
+ }
271
+ /**
272
+ * source_digest에 넣을 한 경로의 content 토큰을 만든다.
273
+ * XY는 넣지 않는다 — 같은 바이트를 stage만 해도 digest가 같아야 commit 직전
274
+ * `git add`가 정상 흐름을 깨지 않는다. 디렉터리는 -uall이 파일을 펼치므로
275
+ * 항목 자체는 건너뛴다.
276
+ *
277
+ * @param {string} repoRoot - 저장소 루트
278
+ * @param {string} relPath - dirty 상대경로
279
+ * @param {VerificationDeps} [deps] - 파일 읽기 주입
280
+ * @returns {{ path: string, content: string } | null} 제외·디렉터리는 null
281
+ */
282
+ function sourceEntryContent(repoRoot, relPath, deps) {
283
+ const posixRel = toPosix(relPath);
284
+ if (!posixRel || isExcludedFromSourceDigest(posixRel))
285
+ return null;
286
+ const abs = path.resolve(repoRoot, posixRel);
287
+ const rootReal = fs.realpathSync(repoRoot);
288
+ let targetReal = abs;
289
+ try {
290
+ if (fs.existsSync(abs))
291
+ targetReal = fs.realpathSync(abs);
292
+ }
293
+ catch (_error) {
294
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', `dirty path is not readable: ${posixRel}`);
295
+ }
296
+ const relToRoot = path.relative(rootReal, targetReal);
297
+ if (relToRoot === '..'
298
+ || relToRoot.startsWith(`..${path.sep}`)
299
+ || path.isAbsolute(relToRoot)) {
300
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', `dirty path escapes the repository: ${posixRel}`);
301
+ }
302
+ try {
303
+ const st = fs.lstatSync(abs);
304
+ if (st.isSymbolicLink()) {
305
+ return { path: posixRel, content: `link:${fs.readlinkSync(abs)}` };
306
+ }
307
+ if (st.isFile()) {
308
+ return {
309
+ path: posixRel,
310
+ content: createHash('sha256').update(readBytes(abs, deps)).digest('hex'),
311
+ };
312
+ }
313
+ // 디렉터리·특수 파일은 -uall이 자식 파일을 별도 항목으로 낸다.
314
+ return null;
315
+ }
316
+ catch (error) {
317
+ if ((0, verification_contracts_1.errorCode)(error) === 'VERIFY_IDENTITY_INVALID')
318
+ throw error;
319
+ return { path: posixRel, content: 'deleted' };
320
+ }
321
+ }
322
+ /**
323
+ * `.bouncer/`와 runtime 산출물을 뺀 dirty 소스의 content digest를 계산한다.
324
+ * `git status --porcelain=v1 -z -uall`를 써서 새 디렉터리 안 파일 내용이
325
+ * 상수 `'dir'`로 접히지 않게 한다. git 실패·repo 탈출은 VERIFY_IDENTITY_INVALID.
326
+ *
327
+ * @param {string} repoRoot - 저장소 루트 절대 경로
328
+ * @param {VerificationDeps} [deps] - git/readFile 주입
329
+ * @returns {string} 경로순 `{path, content}` 배열의 SHA-256 hex
330
+ */
331
+ function computeSourceDigest(repoRoot, deps) {
332
+ const raw = runGit(repoRoot, ['status', '--porcelain=v1', '-z', '-uall'], deps);
333
+ const entries = [];
334
+ const parts = String(raw).split('\0').filter((part) => part.length > 0);
335
+ for (let i = 0; i < parts.length; i += 1) {
336
+ const part = parts[i];
337
+ const xy = part.slice(0, 2);
338
+ const pathPart = part.slice(3);
339
+ const isRename = xy[0] === 'R' || xy[0] === 'C' || xy[1] === 'R' || xy[1] === 'C';
340
+ if (isRename && i + 1 < parts.length) {
341
+ const fromEntry = sourceEntryContent(repoRoot, pathPart, deps);
342
+ const toEntry = sourceEntryContent(repoRoot, parts[i + 1], deps);
343
+ i += 1;
344
+ if (fromEntry)
345
+ entries.push(fromEntry);
346
+ if (toEntry)
347
+ entries.push(toEntry);
348
+ }
349
+ else {
350
+ const entry = sourceEntryContent(repoRoot, pathPart, deps);
351
+ if (entry)
352
+ entries.push(entry);
353
+ }
354
+ }
355
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
356
+ return sha256Canonical(entries);
357
+ }
358
+ /**
359
+ * platform/arch/node/verify config의 environment hash를 계산한다.
360
+ * config 부재는 고정 sentinel로 두어 빈 파일 해시와 구분한다.
361
+ *
362
+ * @param {string} repoRoot - 저장소 루트
363
+ * @param {VerificationDeps} [deps] - platform·arch·node·readFile 주입
364
+ * @returns {string} environment SHA-256
365
+ */
366
+ function computeEnvironmentHash(repoRoot, deps) {
367
+ const configPath = path.join(repoRoot, '.bouncer', 'config.json');
368
+ let verifyConfigHash = VERIFY_CONFIG_MISSING_SENTINEL;
369
+ const exists = fs.existsSync(configPath);
370
+ if (exists) {
371
+ try {
372
+ const st = fs.statSync(configPath);
373
+ if (!st.isFile()) {
374
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', `verification config is not a file: ${configPath}`);
375
+ }
376
+ verifyConfigHash = createHash('sha256').update(readBytes(configPath, deps)).digest('hex');
377
+ }
378
+ catch (error) {
379
+ if ((0, verification_contracts_1.errorCode)(error) === 'VERIFY_IDENTITY_INVALID')
380
+ throw error;
381
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', `failed to hash verification config: ${configPath}`);
382
+ }
383
+ }
384
+ return sha256Canonical({
385
+ platform: (deps && deps.platform) || process.platform,
386
+ arch: (deps && deps.arch) || process.arch,
387
+ node_version: (deps && deps.nodeVersion) || process.version,
388
+ verify_config_hash: verifyConfigHash,
389
+ });
390
+ }
391
+ /**
392
+ * 검증 실행 입력의 content-addressed identity와 evidence_id를 계산한다.
393
+ * Git·dirty·config 조회 실패는 실행으로 우회하지 않고 전용 오류로 중단한다.
394
+ *
395
+ * @param {{ repoRoot: string, command: string, scope: VerificationScope, deps?: VerificationDeps }} opts
396
+ * @returns {{ identity: EvidenceIdentity, evidenceId: string }}
397
+ */
398
+ function computeEvidenceIdentity({ repoRoot, command, scope, deps, }) {
399
+ const head = runGit(repoRoot, ['rev-parse', 'HEAD'], deps).trim();
400
+ if (!head) {
401
+ throw (0, verification_contracts_1.verificationError)('VERIFY_IDENTITY_INVALID', 'git HEAD is empty');
402
+ }
403
+ const identity = {
404
+ head,
405
+ dirty_digest: computeDirtyDigest(repoRoot, deps),
406
+ command,
407
+ cwd: '.',
408
+ environment_hash: computeEnvironmentHash(repoRoot, deps),
409
+ scope,
410
+ };
411
+ return { identity, evidenceId: sha256Canonical(identity) };
412
+ }
413
+ module.exports = { assertVerificationScope, resolveDefaultTaskScope, computeEvidenceIdentity, computeSourceDigest };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+ const current = require("./current");
3
+ const { resolveEffectiveTask } = current;
4
+ const verification = require("./verification");
5
+ const { selectVerificationEntries, readVerifyCommandFor, runVerification, } = verification;
6
+ /**
7
+ * 기본 검증 대상을 한 번 해석하고 실행 계층에 넘길 task 목록을 반환한다.
8
+ * lease와 pointer 선택은 current의 책임으로 두어 실행 모듈이 현재 상태에
9
+ * 직접 의존하지 않게 한다.
10
+ *
11
+ * @param {object} opts - 저장소와 선택 옵션
12
+ * @param {string} opts.repoRoot - 저장소 루트 절대 경로
13
+ * @param {string} opts.blueprintDir - blueprint 상대 경로
14
+ * @param {string} [opts.taskId] - 명시 task id
15
+ * @param {VerificationDeps} [opts.deps] - effective task 조회 seam
16
+ * @returns {{effectiveTask: EffectiveTask | null, entries: TaskDocsEntry[]}} 선택된 실행 대상
17
+ */
18
+ function resolveVerificationTarget({ repoRoot, blueprintDir, taskId, deps }) {
19
+ const effectiveTask = resolveEffectiveTask({ repoRoot, deps: deps });
20
+ const entries = selectVerificationEntries(repoRoot, blueprintDir, { taskId, effectiveTask });
21
+ return { effectiveTask, entries };
22
+ }
23
+ /**
24
+ * 기본 대상을 해석한 뒤 선택된 값만 verification runner에 전달한다.
25
+ *
26
+ * @param {object} opts - 저장소와 실행 옵션
27
+ * @param {string} opts.repoRoot - 저장소 루트 절대 경로
28
+ * @param {string} opts.blueprintDir - blueprint 상대 경로
29
+ * @param {boolean} [opts.markTaskVerified] - 성공 시 대상 commit task 승격 여부
30
+ * @param {VerificationDeps} [opts.deps] - effective task 조회 및 실행 seam
31
+ * @returns {ReturnType<typeof runVerification>} 검증 결과
32
+ */
33
+ function runSelectedVerification({ repoRoot, blueprintDir, markTaskVerified, deps }) {
34
+ const { effectiveTask } = resolveVerificationTarget({ repoRoot, blueprintDir, deps });
35
+ return runVerification({ repoRoot, blueprintDir, effectiveTask, markTaskVerified, deps: deps });
36
+ }
37
+ /**
38
+ * 기본 대상 선택을 포함해 검증 명령을 읽는다.
39
+ *
40
+ * @param {string} repoRoot - 저장소 루트 절대 경로
41
+ * @param {string} [blueprintDir] - 선택할 blueprint
42
+ * @param {string} [taskId] - 명시 task id
43
+ * @returns {string} 단일 검증 명령
44
+ */
45
+ function readVerifyCommand(repoRoot, blueprintDir, taskId) {
46
+ if (!blueprintDir)
47
+ return readVerifyCommandFor(repoRoot, []);
48
+ const { entries } = resolveVerificationTarget({ repoRoot, blueprintDir, taskId });
49
+ return readVerifyCommandFor(repoRoot, entries, blueprintDir);
50
+ }
51
+ module.exports = { resolveVerificationTarget, runSelectedVerification, readVerifyCommand };