@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.
- package/LICENSE +202 -0
- package/README.md +31 -2
- package/bin/bouncer +26 -0
- package/bin/bouncer-root +25 -0
- package/bin/build-stamp.js +78 -0
- package/bin/register-path.js +71 -0
- package/dist/.build-stamp +1 -0
- package/dist/approval-snapshot.js +99 -0
- package/dist/bouncer-root.js +80 -0
- package/dist/canonical-json.js +37 -0
- package/dist/cli-commit-guard-command.js +74 -0
- package/dist/cli-config-command.js +47 -0
- package/dist/cli-context-review-command.js +273 -0
- package/dist/cli-current-command.js +374 -0
- package/dist/cli-dispatch-command.js +119 -0
- package/dist/cli-doc-commands.js +352 -0
- package/dist/cli-epic-command.js +130 -0
- package/dist/cli-flags.js +50 -0
- package/dist/cli-git-commands.js +931 -0
- package/dist/cli-intent-command.js +491 -0
- package/dist/cli-metrics-command.js +134 -0
- package/dist/cli-ontology-command.js +109 -0
- package/dist/cli-project-commands.js +580 -0
- package/dist/cli-retro-command.js +138 -0
- package/dist/cli-review-command.js +251 -0
- package/dist/cli-review-dispatch-command.js +338 -0
- package/dist/cli-usage.js +72 -0
- package/dist/cli.js +70 -0
- package/dist/codex-agents.js +189 -0
- package/dist/command-argv.js +125 -0
- package/dist/commit-guard.js +72 -0
- package/dist/commit-hook.js +470 -0
- package/dist/commit-message.js +77 -0
- package/dist/commit-sha.js +107 -0
- package/dist/commit.js +519 -0
- package/dist/composite-gates.js +129 -0
- package/dist/comprehension.js +127 -0
- package/dist/config.js +298 -0
- package/dist/context-ids.js +288 -0
- package/dist/context-migration-rewrite.js +421 -0
- package/dist/context-ontology.js +746 -0
- package/dist/context-review-contracts.js +2 -0
- package/dist/context-review-projection.js +147 -0
- package/dist/context-review-state.js +824 -0
- package/dist/context-review-store.js +602 -0
- package/dist/context-review-transitions.js +412 -0
- package/dist/coordinate-advance.js +253 -0
- package/dist/coordinate-next.js +998 -0
- package/dist/coordinate-output.js +57 -0
- package/dist/coordinator-command-contracts.js +2 -0
- package/dist/coordinator-commands.js +1908 -0
- package/dist/coordinator-fanin.js +1021 -0
- package/dist/coordinator-git.js +121 -0
- package/dist/coordinator-input.js +37 -0
- package/dist/coordinator-ledger.js +381 -0
- package/dist/coordinator-prepare.js +1047 -0
- package/dist/coordinator-repair.js +1416 -0
- package/dist/coordinator-verification.js +42 -0
- package/dist/coordinator-worker.js +388 -0
- package/dist/coordinator.js +7 -0
- package/dist/cross-drive.js +85 -0
- package/dist/current.js +1148 -0
- package/dist/dag-shape.js +93 -0
- package/dist/doc-shape-contracts.js +241 -0
- package/dist/drive-metrics.js +691 -0
- package/dist/epic-index.js +347 -0
- package/dist/epic-registry.js +453 -0
- package/dist/execute-prepare.js +262 -0
- package/dist/execution-contracts.js +46 -0
- package/dist/execution-dag.js +148 -0
- package/dist/finalize-checks.js +177 -0
- package/dist/finalize-collect.js +343 -0
- package/dist/finalize-comprehension.js +116 -0
- package/dist/finalize-digest-collect.js +388 -0
- package/dist/finalize-digest-model.js +595 -0
- package/dist/finalize-digest.js +164 -0
- package/dist/finalize-explain.js +176 -0
- package/dist/finalize-pr.js +610 -0
- package/dist/finalize-release-main.js +652 -0
- package/dist/finalize.js +714 -0
- package/dist/frontmatter.js +22 -0
- package/dist/git-ops.js +49 -0
- package/dist/graph-exec.js +179 -0
- package/dist/graph-scope.js +268 -0
- package/dist/graphify.js +995 -0
- package/dist/guards.js +82 -0
- package/dist/import-git.js +78 -0
- package/dist/import-history.js +392 -0
- package/dist/import-render.js +59 -0
- package/dist/import-types.js +2 -0
- package/dist/init.js +571 -0
- package/dist/intent-bundle.js +936 -0
- package/dist/intent-provenance.js +927 -0
- package/dist/layout.js +131 -0
- package/dist/lease.js +145 -0
- package/dist/legacy-comments.js +71 -0
- package/dist/migrate-context-slugs.js +262 -0
- package/dist/migrate-merge-legacy-epics.js +392 -0
- package/dist/migrate-review-scope.js +144 -0
- package/dist/migrate-task-layout.js +194 -0
- package/dist/path-overlap.js +38 -0
- package/dist/paths.js +45 -0
- package/dist/plan-approve.js +218 -0
- package/dist/plan-inspect.js +575 -0
- package/dist/plan-prerequisites.js +197 -0
- package/dist/plan-snapshot-contract.js +98 -0
- package/dist/plan-snapshot.js +138 -0
- package/dist/plugin-root.js +237 -0
- package/dist/posix-path.js +14 -0
- package/dist/pre-commit-hook.js +170 -0
- package/dist/print-dispatch.js +285 -0
- package/dist/render.js +8 -0
- package/dist/retention-migration.js +595 -0
- package/dist/retro-read.js +259 -0
- package/dist/retro-sources.js +318 -0
- package/dist/review-dispatch.js +1054 -0
- package/dist/review-policy.js +28 -0
- package/dist/review-record.js +447 -0
- package/dist/review-repair-state.js +52 -0
- package/dist/review-snapshot.js +659 -0
- package/dist/run-detach.js +165 -0
- package/dist/run-preflight.js +368 -0
- package/dist/runtime-state.js +1038 -0
- package/dist/scaffold.js +533 -0
- package/dist/schema.js +294 -0
- package/dist/scope.js +1073 -0
- package/dist/seed-worktree.js +926 -0
- package/dist/session-graph.js +337 -0
- package/dist/subagents.js +86 -0
- package/dist/symbol-index.js +1390 -0
- package/dist/task-brief-hash.js +34 -0
- package/dist/task-commits.js +124 -0
- package/dist/tasks-docs.js +192 -0
- package/dist/templates.js +469 -0
- package/dist/time.js +21 -0
- package/dist/validate-docs.js +302 -0
- package/dist/validate-gates-finalize.js +303 -0
- package/dist/validate-gates-plan.js +720 -0
- package/dist/validate-gates-run.js +420 -0
- package/dist/validate-gates-shared.js +124 -0
- package/dist/validate-gates.js +87 -0
- package/dist/validate-sections.js +654 -0
- package/dist/validate-structural.js +347 -0
- package/dist/validate.js +417 -0
- package/dist/verification-contracts.js +26 -0
- package/dist/verification-evidence.js +233 -0
- package/dist/verification-execution.js +163 -0
- package/dist/verification-identity.js +413 -0
- package/dist/verification-target.js +51 -0
- package/dist/verification.js +383 -0
- package/dist/windows-npm.js +38 -0
- package/package.json +32 -4
- package/resources/graphify-compat.json +9 -0
- package/vendor/README.md +23 -0
- package/vendor/js-yaml.LICENSE +21 -0
- package/vendor/js-yaml.js +3022 -0
package/dist/finalize.js
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
// scripts/lib/finalize.js
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { execFileSync } = require('node:child_process');
|
|
6
|
+
const { createHash } = require('node:crypto');
|
|
7
|
+
const paths = require("./paths");
|
|
8
|
+
const { toPosix } = paths;
|
|
9
|
+
const validate = require("./validate");
|
|
10
|
+
const { validateBlueprint, loadBlueprintDocs } = validate;
|
|
11
|
+
const current = require("./current");
|
|
12
|
+
const { clearCurrent, nextBlueprint, readCurrent } = current;
|
|
13
|
+
const epicRegistry = require("./epic-registry");
|
|
14
|
+
const { readEpicRegistry } = epicRegistry;
|
|
15
|
+
const taskCommits = require("./task-commits");
|
|
16
|
+
const { resolveTaskCommits } = taskCommits;
|
|
17
|
+
const frontmatter = require("./frontmatter");
|
|
18
|
+
const { parseFrontmatter } = frontmatter;
|
|
19
|
+
const render = require("./render");
|
|
20
|
+
const { renderDoc } = render;
|
|
21
|
+
const scope = require("./scope");
|
|
22
|
+
const { makeFinalizeAllowed, isRuntimeArtifact, readCoordinatorLedger } = scope;
|
|
23
|
+
const verification = require("./verification");
|
|
24
|
+
const { executeVerify } = verification;
|
|
25
|
+
const verificationTarget = require("./verification-target");
|
|
26
|
+
const { readVerifyCommand } = verificationTarget;
|
|
27
|
+
const gitOps = require("./git-ops");
|
|
28
|
+
const { realGit, resolveCheckoutBranch } = gitOps;
|
|
29
|
+
const commitMessage = require("./commit-message");
|
|
30
|
+
const { buildFinalizeCommitMessage } = commitMessage;
|
|
31
|
+
const finalizeCollect = require("./finalize-collect");
|
|
32
|
+
const { collectTransientRels, collectTaskCommits, collectCoordinatorProvenance, provenanceFromLedgerRead, stableIdFromTaskData, } = finalizeCollect;
|
|
33
|
+
const finalizeChecks = require("./finalize-checks");
|
|
34
|
+
const { buildIntegration, coordinatorEvidenceMismatches, collectMustFixWarnings, } = finalizeChecks;
|
|
35
|
+
const finalizeExplain = require("./finalize-explain");
|
|
36
|
+
const { writeExplainTaskCommits, buildTaskContext, writeExplainTaskContext } = finalizeExplain;
|
|
37
|
+
const execution_contracts_1 = require("./execution-contracts");
|
|
38
|
+
// seed-worktree는 paths·layout·scope만 import하므로 finalize를 끌어와 순환을 만들지 않는다.
|
|
39
|
+
// 설치 판정(lockfile·marker)과 npm ci 인자를 seed/fan-in과 한 구현으로 공유하려는 import.
|
|
40
|
+
const seedWorktree = require("./seed-worktree");
|
|
41
|
+
const { prepareDependencies } = seedWorktree;
|
|
42
|
+
/**
|
|
43
|
+
* trailer 해석용 base를 고른다. 원장 base가 정본이고, 없으면 같은 blueprint pointer.
|
|
44
|
+
* 해석 실패는 null — finalize를 막지 않고 commit_sha 폴백으로 간다.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} opts
|
|
47
|
+
* @param {string} opts.repoRoot - 저장소 루트
|
|
48
|
+
* @param {string} opts.blueprintDir - blueprint 상대 경로
|
|
49
|
+
* @param {string | null | undefined} opts.ledgerBase - 원장 base
|
|
50
|
+
* @returns {string | null}
|
|
51
|
+
*/
|
|
52
|
+
function resolveFinalizeTrailerBase({ repoRoot, blueprintDir, ledgerBase, }) {
|
|
53
|
+
if (typeof ledgerBase === 'string' && ledgerBase.trim())
|
|
54
|
+
return ledgerBase.trim();
|
|
55
|
+
try {
|
|
56
|
+
const pointer = readCurrent({ repoRoot });
|
|
57
|
+
if (pointer
|
|
58
|
+
&& typeof pointer.blueprint === 'string'
|
|
59
|
+
&& toPosix(pointer.blueprint) === toPosix(blueprintDir)
|
|
60
|
+
&& typeof pointer.base === 'string'
|
|
61
|
+
&& pointer.base.trim()) {
|
|
62
|
+
return pointer.base.trim();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (_error) {
|
|
66
|
+
// CURRENT_AMBIGUOUS 등은 trailer 생략과 같다 — finalize를 실패시키지 않는다.
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function adaptInjectedVerifyExec(verifyExec) {
|
|
71
|
+
return (command, opts) => {
|
|
72
|
+
const result = verifyExec(command, opts);
|
|
73
|
+
if (result
|
|
74
|
+
&& typeof result === 'object'
|
|
75
|
+
&& 'ok' in result
|
|
76
|
+
&& result.ok === false) {
|
|
77
|
+
const failure = result;
|
|
78
|
+
const error = new Error(typeof failure.output === 'string' ? failure.output : 'verify failed');
|
|
79
|
+
error.status = failure.exitCode;
|
|
80
|
+
error.stdout = failure.output;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
if (result && typeof result === 'object' && 'output' in result) {
|
|
84
|
+
return result.output;
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function codedErrorCode(error) {
|
|
90
|
+
if (typeof error === 'object' && error !== null && 'code' in error) {
|
|
91
|
+
const code = error.code;
|
|
92
|
+
return typeof code === 'string' ? code : undefined;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
function asRecord(value) {
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
// blueprint index.md를 읽어 잠금 대상인지 판정한다. 파일이 없거나 프론트매터
|
|
100
|
+
// 파싱이 깨지면 target.data는 null — closedLockPath/writeClosedLock 양쪽이
|
|
101
|
+
// 이를 "잠글 것 없음"으로 취급해 finalize를 실패시키지 않는다.
|
|
102
|
+
function resolveLockTarget({ repoRoot, blueprintDir }) {
|
|
103
|
+
const rel = `${toPosix(blueprintDir)}/index.md`;
|
|
104
|
+
const abs = path.join(repoRoot, rel);
|
|
105
|
+
if (!fs.existsSync(abs))
|
|
106
|
+
return { rel, data: null, body: null };
|
|
107
|
+
try {
|
|
108
|
+
const { data, body } = parseFrontmatter(fs.readFileSync(abs, 'utf8'));
|
|
109
|
+
return { rel, data, body };
|
|
110
|
+
}
|
|
111
|
+
catch (_e) {
|
|
112
|
+
return { rel, data: null, body: null };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// dry-run과 --yes 양쪽에서 같은 판정을 쓴다: 이미 closed거나 대상이 없으면
|
|
116
|
+
// null. closed → approved 역전이는 제공하지 않으므로 이 함수는 오직
|
|
117
|
+
// "아직 closed가 아님" 방향으로만 경로를 반환한다.
|
|
118
|
+
function closedLockPath(target) {
|
|
119
|
+
const bouncer = target.data && typeof target.data === 'object' ? asRecord(target.data).bouncer : null;
|
|
120
|
+
if (!bouncer || typeof bouncer !== 'object' || asRecord(bouncer).status === 'closed')
|
|
121
|
+
return null;
|
|
122
|
+
return target.rel;
|
|
123
|
+
}
|
|
124
|
+
// 실제로 파일을 closed로 재기록한다. 호출 전에 closedLockPath가 non-null임을
|
|
125
|
+
// 확인해야 함 — target.data가 없으면 여기서도 아무것도 쓰지 않는다.
|
|
126
|
+
function writeClosedLock(repoRoot, target) {
|
|
127
|
+
if (!target.data || typeof target.data !== 'object')
|
|
128
|
+
return;
|
|
129
|
+
asRecord(asRecord(target.data).bouncer).status = 'closed';
|
|
130
|
+
fs.writeFileSync(path.join(repoRoot, target.rel), renderDoc(target.data, target.body));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* staged 목록에 경로를 중복 없이 덧붙인다. 삭제 대상이 git 변경에 이미
|
|
134
|
+
* 있어도 한 번만 남긴다.
|
|
135
|
+
*
|
|
136
|
+
* @param {string[]} list - 기존 staged 후보
|
|
137
|
+
* @param {string[]} extra - 합류할 경로
|
|
138
|
+
* @returns {string[]} 합쳐진 목록
|
|
139
|
+
*/
|
|
140
|
+
function appendUnique(list, extra) {
|
|
141
|
+
const out = [...list];
|
|
142
|
+
for (const rel of extra) {
|
|
143
|
+
if (!out.includes(rel))
|
|
144
|
+
out.push(rel);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
// out-of-scope 판정 뒤에만 부르는 stage 목록 합류. lockPath는 항상
|
|
149
|
+
// `${blueprintDir}/index.md`이고 scope.makeAllowed가 blueprintDir 하위 전체를
|
|
150
|
+
// 허용하므로 이 경로를 다시 allowed()에 통과시키지 않는다.
|
|
151
|
+
function mergeLocked(list, lockPath) {
|
|
152
|
+
if (!lockPath)
|
|
153
|
+
return list;
|
|
154
|
+
return list.includes(lockPath) ? list : [...list, lockPath];
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* 재실행 출력의 실패 경로가 모두 분류된 범위 밖 경로 안에 있는지 판정한다.
|
|
158
|
+
* 출력에서 저장소 파일 경로(디렉터리 구분자가 있는 토큰, node_modules·node: 내부 제외)를 뽑아
|
|
159
|
+
* 하나 이상 있어야 하고 전부 분류 경로(파일 또는 디렉터리)에 속해야 true다. 경로를 하나도 못
|
|
160
|
+
* 찾거나 분류 밖 경로가 하나라도 있으면 false라 호출자가 VERIFY_FAILED로 막는다(보수적 판정).
|
|
161
|
+
*
|
|
162
|
+
* @param {string} output - 재실행 검증 출력
|
|
163
|
+
* @param {string[]} classified - 분류 당시 기록한 범위 밖 경로
|
|
164
|
+
* @param {string} repoRoot - 절대 경로 접두를 벗길 저장소 루트
|
|
165
|
+
* @returns {boolean} 모든 실패 경로가 분류 안이면 true
|
|
166
|
+
*/
|
|
167
|
+
function failuresWithinClassifiedPaths(output, classified, repoRoot) {
|
|
168
|
+
const root = toPosix(repoRoot).replace(/\/+$/, '');
|
|
169
|
+
const norm = (value) => value.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
170
|
+
const allowed = classified.map((entry) => norm(toPosix(entry)));
|
|
171
|
+
const found = new Set();
|
|
172
|
+
for (const match of output.matchAll(/[\w@.~:/\\-]*[\w@.-]+\/[\w@./\\-]*[\w-]\.[A-Za-z0-9]+/g)) {
|
|
173
|
+
let token = toPosix(match[0]).replace(/^file:\/\//, '');
|
|
174
|
+
if (token.includes('node_modules') || token.startsWith('node:') || /^[a-z]+:\/\//i.test(token))
|
|
175
|
+
continue;
|
|
176
|
+
if (token.startsWith(`${root}/`))
|
|
177
|
+
token = token.slice(root.length + 1);
|
|
178
|
+
found.add(norm(token));
|
|
179
|
+
}
|
|
180
|
+
if (found.size === 0)
|
|
181
|
+
return false;
|
|
182
|
+
return [...found].every((token) => allowed.some((entry) => token === entry || token.startsWith(`${entry}/`)));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* finalize remainder 검증 실패가 원장에 입증된 범위 밖 terminal 실패와 같은 원인인지 판정한다.
|
|
186
|
+
* 원장이 없거나(비-drive) 분류가 없거나 stale이면, 명령이나 종료 코드가 분류된 실패와 다르면 null이라
|
|
187
|
+
* 호출자는 기존 VERIFY_FAILED로 막는다. 맞으면 이번 실행의 output digest를 원본 evidence id에
|
|
188
|
+
* 연결한 새 identity를 돌려준다(원문 출력은 싣지 않는다).
|
|
189
|
+
*
|
|
190
|
+
* @param {LedgerReadLike} read - finalize가 한 번 읽은 원장 결과
|
|
191
|
+
* @param {string} command - 이번 remainder 검증 명령
|
|
192
|
+
* @param {{ exitCode: number, output: string }} execution - 실패한 검증 결과
|
|
193
|
+
* @param {string} repoRoot - 출력의 절대 경로를 상대 경로로 바꾸는 기준
|
|
194
|
+
* @returns {RemainderOutsideScope | null} 같은 범위 밖 원인이면 연결 정보, 아니면 null
|
|
195
|
+
*/
|
|
196
|
+
function remainderOutsideScope(read, command, execution, repoRoot) {
|
|
197
|
+
if (!read.ok)
|
|
198
|
+
return null;
|
|
199
|
+
const failure = asRecord(asRecord(read.ledger).terminalFailure);
|
|
200
|
+
const taskId = typeof failure.task === 'string' ? failure.task : '';
|
|
201
|
+
const disposition = (0, execution_contracts_1.validOutsideScope)(read.ledger, taskId);
|
|
202
|
+
if (!disposition)
|
|
203
|
+
return null;
|
|
204
|
+
if (failure.command !== command || failure.exitCode !== execution.exitCode)
|
|
205
|
+
return null;
|
|
206
|
+
// 같은 명령·종료 코드는 같은 원인의 증거가 못 된다(npm run ci는 새 범위 내 실패도 exit 1이다).
|
|
207
|
+
// 재실행 출력이 가리키는 실패 경로가 분류 때 기록한 범위 밖 경로에만 한정될 때만 연결한다.
|
|
208
|
+
if (!failuresWithinClassifiedPaths(String(execution.output), disposition.paths, repoRoot))
|
|
209
|
+
return null;
|
|
210
|
+
return {
|
|
211
|
+
task: taskId,
|
|
212
|
+
disposition,
|
|
213
|
+
remainder: {
|
|
214
|
+
command,
|
|
215
|
+
exit_code: execution.exitCode,
|
|
216
|
+
output_sha: createHash('sha256').update(String(execution.output)).digest('hex'),
|
|
217
|
+
evidence_id: disposition.evidence_id,
|
|
218
|
+
},
|
|
219
|
+
note: `범위 밖 실패 evidence ${disposition.evidence_id}는 id로만 PR에 싣고 로컬 경로는 공유 링크에 넣지 않는다.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
// prepareDependencies의 기본 stdio는 inherit이다. finalize 결과는 CLI가 stdout에
|
|
223
|
+
// JSON으로 그대로 쓰므로, npm 출력이 같은 stdout에 섞이면 소비자가 결과를 파싱하지
|
|
224
|
+
// 못한다. 그래서 finalize 경로만 capture로 고정하고 공유 helper는 바꾸지 않는다.
|
|
225
|
+
const FINALIZE_INSTALL_STDIO = ['ignore', 'pipe', 'pipe'];
|
|
226
|
+
// 복구 안내에 쓰는 명령. helper가 실행하는 argv와 같은 문자열이어야 사용자가
|
|
227
|
+
// 손으로 같은 설치를 재현한다.
|
|
228
|
+
const FINALIZE_INSTALL_COMMAND = 'npm ci --include=dev --ignore-scripts --no-audit --no-fund';
|
|
229
|
+
/**
|
|
230
|
+
* finalize 검증 직전에 repoRoot의 npm 의존성을 준비한다.
|
|
231
|
+
* 설치 필요 판정(lockfile 존재, marker 부재 또는 lockfile sha256 stamp 불일치)과 npm ci 인자는 seed-worktree의
|
|
232
|
+
* prepareDependencies를 그대로 쓰고, 이 wrapper는 실행 seam만 바꿔 끼운다:
|
|
233
|
+
* cwd는 항상 repoRoot, stdio는 capture로 고정해 npm 출력이 CLI JSON stdout에
|
|
234
|
+
* 섞이지 않게 한다. 설치 실패(throw)는 helper가 ok:false로 변환한다.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} repoRoot - 검증할 checkout 절대 경로. 설치 cwd이기도 하다
|
|
237
|
+
* @param {DependencyExec} dependencyExec - execFileSync 호환 실행 함수
|
|
238
|
+
* @returns {{ ok: true } | { ok: false, cause: string }} 설치 불필요·성공은 ok:true,
|
|
239
|
+
* npm ci 실패면 ok:false와 helper message를 문자열로 바꾼 cause
|
|
240
|
+
*/
|
|
241
|
+
function prepareFinalizeDependencies(repoRoot, dependencyExec) {
|
|
242
|
+
const capture = ((file, argv, options) => dependencyExec(file, argv,
|
|
243
|
+
// helper가 넘긴 옵션 위에 cwd·stdio를 덮어쓴다. helper 쪽 기본값(inherit)이
|
|
244
|
+
// 바뀌거나 다른 cwd가 들어와도 finalize 계약은 흔들리지 않는다.
|
|
245
|
+
{ ...options, cwd: repoRoot, stdio: FINALIZE_INSTALL_STDIO }));
|
|
246
|
+
const prepared = prepareDependencies(repoRoot, { execFileSync: capture });
|
|
247
|
+
if (prepared.ok)
|
|
248
|
+
return { ok: true };
|
|
249
|
+
// helper의 message는 Error.message(unknown)다. throw된 값이 Error가 아니면
|
|
250
|
+
// undefined일 수 있어, 원인 불명이라는 사실을 문자열로 남긴다.
|
|
251
|
+
const { message } = prepared;
|
|
252
|
+
const cause = typeof message === 'string' && message !== ''
|
|
253
|
+
? message
|
|
254
|
+
: `${prepared.reason}: ${String(message)}`;
|
|
255
|
+
return { ok: false, cause };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* blueprint를 닫는다. dry-run은 계획만 보고, `--yes`는 검증·잠금·커밋까지 간다.
|
|
259
|
+
* partial-finalize는 별도 main checkout action이며 일반 `--yes`와 별도 확인 없이는 쓰지 않는다.
|
|
260
|
+
* drive면 원장에서 읽은 정리 목록을 top-level `worktrees`에 싣고 cleanup이
|
|
261
|
+
* 읽게 한다. explain frontmatter에는 쓰지 않는다 — drive 실행 기록은 원장이
|
|
262
|
+
* 살아있는 동안에만 의미가 있고, 이미 있는 옛 `bouncer.coordinator`는
|
|
263
|
+
* 마이그레이션하지 않는다.
|
|
264
|
+
*
|
|
265
|
+
* @param {object} opts
|
|
266
|
+
* @param {string} opts.repoRoot - 저장소 루트 절대 경로
|
|
267
|
+
* @param {string} opts.blueprintDir - blueprint 상대 경로
|
|
268
|
+
* @param {string} [opts.cwd] - 실제 호출 cwd. partial-finalize는 main checkout과 일치해야 한다
|
|
269
|
+
* @param {boolean} [opts.yes=false] - true면 잠금·커밋을 수행한다
|
|
270
|
+
* @param {GitApi} [opts.git] - git seam. 없으면 `realGit(repoRoot)`
|
|
271
|
+
* @param {(opts: { repoRoot: string }) => boolean} [opts.clearPointer] - pointer 삭제 seam
|
|
272
|
+
* @param {(opts: { repoRoot: string, blueprintDir: unknown }) => unknown} [opts.next] - next 후보 seam
|
|
273
|
+
* @param {VerifyExec} [opts.verifyExec] - 검증 실행 seam
|
|
274
|
+
* @param {DependencyExec} [opts.dependencyExec] - 검증 전 npm ci 실행 seam. 기본은 Node execFileSync.
|
|
275
|
+
* verifyExec 주입은 검증만 바꾸며 설치를 생략시키지 않는다
|
|
276
|
+
* @returns {object} 성공이면 `ok: true`와 worktrees·branch, 실패면 reason.
|
|
277
|
+
* 설치 실패는 `reason: 'dependency-install-failed'`·`code: 'DEPENDENCY_INSTALL_FAILED'`와
|
|
278
|
+
* cause·next·integration·branch로, 검증 실패(`VERIFY_FAILED`)와 구분된다.
|
|
279
|
+
* `VERIFY_FAILED`는 verify 출력 마지막 100줄 이하인 `output_tail: string`을 함께 돌려준다
|
|
280
|
+
* (설정 오류·설치 실패 응답에는 이 키가 없다)
|
|
281
|
+
*/
|
|
282
|
+
function finalize({ repoRoot, blueprintDir, cwd = process.cwd(), yes = false, git, clearPointer = clearCurrent, next = nextBlueprint, verifyExec, dependencyExec = execFileSync, partialFinalize = false, confirmPartial = false, }) {
|
|
283
|
+
const gitApi = git || realGit(repoRoot);
|
|
284
|
+
let partialLedgerRead = null;
|
|
285
|
+
if (partialFinalize) {
|
|
286
|
+
const runtime = require('./runtime-state');
|
|
287
|
+
const main = runtime.runtimePaths({ repoRoot });
|
|
288
|
+
if (!main.projectRoot || fs.realpathSync(repoRoot) !== fs.realpathSync(main.projectRoot)
|
|
289
|
+
|| fs.realpathSync(cwd) !== fs.realpathSync(main.projectRoot)) {
|
|
290
|
+
return {
|
|
291
|
+
ok: false, reason: 'partial-finalize-requires-main-checkout', state: 'partial-finalize', preserved: true,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
partialLedgerRead = readCoordinatorLedger({ repoRoot, blueprint: blueprintDir });
|
|
295
|
+
if (!partialLedgerRead.ok || !partialLedgerRead.integrationPath) {
|
|
296
|
+
return { ok: false, reason: 'partial-evidence-missing', state: 'partial-finalize', preserved: true };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const inspectRoot = partialLedgerRead && partialLedgerRead.ok
|
|
300
|
+
? partialLedgerRead.integrationPath : repoRoot;
|
|
301
|
+
const preflightTarget = resolveLockTarget({ repoRoot: inspectRoot, blueprintDir });
|
|
302
|
+
const preflightBouncer = preflightTarget.data && typeof preflightTarget.data === 'object'
|
|
303
|
+
? asRecord(asRecord(preflightTarget.data).bouncer) : {};
|
|
304
|
+
if (preflightBouncer.status === 'partial_closed') {
|
|
305
|
+
const collected = collectCoordinatorProvenance({ repoRoot, blueprintDir });
|
|
306
|
+
if (partialFinalize) {
|
|
307
|
+
const runtime = require('./runtime-state');
|
|
308
|
+
const main = runtime.runtimePaths({ repoRoot });
|
|
309
|
+
if (!main.projectRoot || fs.realpathSync(repoRoot) !== fs.realpathSync(main.projectRoot)
|
|
310
|
+
|| fs.realpathSync(cwd) !== fs.realpathSync(main.projectRoot)) {
|
|
311
|
+
return {
|
|
312
|
+
ok: false, reason: 'partial-finalize-requires-main-checkout', state: 'partial-finalize', preserved: true,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const partialRead = partialLedgerRead || readCoordinatorLedger({ repoRoot, blueprint: blueprintDir });
|
|
317
|
+
if (!partialRead.ok || !partialRead.ledger) {
|
|
318
|
+
const digest = { state: 'partial-finalize', status: 'partial_closed', tasks: [],
|
|
319
|
+
terminalFailure: null, decision: null, handoff: null,
|
|
320
|
+
worktrees: collected ? collected.worktrees : [], merge: false };
|
|
321
|
+
if (partialFinalize) {
|
|
322
|
+
return { ok: false, reason: 'partial-evidence-missing', state: 'partial-finalize', digest, preserved: true };
|
|
323
|
+
}
|
|
324
|
+
const nextPlan = path.join(repoRoot, 'NEXT_PLAN.md');
|
|
325
|
+
return { ok: false, reason: 'partial-closed', status: 'partial_closed', state: 'partial-finalize',
|
|
326
|
+
action: 'awaiting-confirmation', digest, worktrees: digest.worktrees,
|
|
327
|
+
nextPlan: fs.existsSync(nextPlan) ? nextPlan : null, preserved: true,
|
|
328
|
+
message: 'NEXT_PLAN.md를 확인하고 후속 계획 진행 여부를 승인해 주세요.' };
|
|
329
|
+
}
|
|
330
|
+
const ledger = partialRead.ledger;
|
|
331
|
+
const handoff = ledger.handoff && typeof ledger.handoff === 'object'
|
|
332
|
+
? ledger.handoff : null;
|
|
333
|
+
const handoffPath = handoff && typeof handoff.path === 'string'
|
|
334
|
+
? path.join(repoRoot, handoff.path) : null;
|
|
335
|
+
const nextPlan = handoffPath && fs.existsSync(handoffPath)
|
|
336
|
+
? handoffPath : path.join(repoRoot, 'NEXT_PLAN.md');
|
|
337
|
+
const decision = ledger.userConfirmed === true && ledger.status === 'partial_closed';
|
|
338
|
+
// 기존 read-only digest가 task verification/review 문서를 해석하는 정본이다.
|
|
339
|
+
// partial ledger 자체의 상태 집계는 아래에 남겨, 일반 digest가 불완전해도 handoff를 잃지 않는다.
|
|
340
|
+
const partialDigestModule = require('./finalize-digest');
|
|
341
|
+
const fullDigest = partialDigestModule.prepareFinalizeDigest({ repoRoot, blueprintDir });
|
|
342
|
+
const digestTasks = fullDigest.ok ? fullDigest.tasks : [];
|
|
343
|
+
const digest = {
|
|
344
|
+
state: 'partial-finalize', status: 'partial_closed',
|
|
345
|
+
tasks: (Array.isArray(ledger.tasks) ? ledger.tasks : []).map((task) => {
|
|
346
|
+
const item = task;
|
|
347
|
+
const details = digestTasks.find((entry) => (entry.id === item.id || entry.id === `TASKS-${String(item.id).padStart(3, '0')}`));
|
|
348
|
+
return {
|
|
349
|
+
id: item.id ?? null, status: item.status ?? null, sha: item.sha ?? null,
|
|
350
|
+
verification: details ? details.verification : null,
|
|
351
|
+
review: details ? details.review : null,
|
|
352
|
+
};
|
|
353
|
+
}),
|
|
354
|
+
terminalFailure: ledger.terminalFailure ?? null,
|
|
355
|
+
decision: decision ? 'partial-close-user-confirmed' : null,
|
|
356
|
+
handoff: handoff && handoffPath && fs.existsSync(handoffPath)
|
|
357
|
+
? { ...handoff, absolutePath: handoffPath } : null,
|
|
358
|
+
worktrees: collected ? collected.worktrees : [],
|
|
359
|
+
digestFailure: fullDigest.ok ? null : fullDigest.reason,
|
|
360
|
+
merge: false,
|
|
361
|
+
};
|
|
362
|
+
if (partialFinalize && (!decision || !digest.handoff)) {
|
|
363
|
+
return { ok: false, reason: 'partial-evidence-missing', state: 'partial-finalize', digest, preserved: true };
|
|
364
|
+
}
|
|
365
|
+
if (partialFinalize && !confirmPartial) {
|
|
366
|
+
return { ok: false, reason: 'partial-confirmation-required', state: 'partial-finalize',
|
|
367
|
+
action: 'awaiting-confirmation', digest, preserved: true };
|
|
368
|
+
}
|
|
369
|
+
if (partialFinalize && yes && confirmPartial) {
|
|
370
|
+
// 부분 종료는 성공/closed 전이가 아니다. main checkout에서 근거를 먼저 확인한
|
|
371
|
+
// 뒤에만 pointer와 명시된 drive worktree를 정리한다.
|
|
372
|
+
const removed = [];
|
|
373
|
+
try {
|
|
374
|
+
// 원장이 들어 있는 integration을 마지막에 지워야 worker 정리 실패 시
|
|
375
|
+
// 다음 호출이 같은 증거와 handoff를 다시 확인할 수 있다.
|
|
376
|
+
for (const worktree of [...digest.worktrees].reverse()) {
|
|
377
|
+
if (fs.existsSync(worktree)) {
|
|
378
|
+
execFileSync('git', ['worktree', 'remove', '--force', worktree], { cwd: repoRoot, stdio: 'ignore' });
|
|
379
|
+
removed.push(worktree);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const pointerCleared = clearPointer({ repoRoot });
|
|
383
|
+
return { ok: true, state: 'partial-finalize', status: 'partial_closed', digest,
|
|
384
|
+
pointerCleared, cleanup: { removed }, preserved: false };
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
return { ok: false, reason: 'partial-cleanup-failed', state: 'partial-finalize', digest,
|
|
388
|
+
cleanup: { removed }, preserved: true, cause: error.message };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
ok: false, reason: 'partial-closed', status: 'partial_closed',
|
|
393
|
+
state: 'partial-finalize', action: 'awaiting-confirmation', digest,
|
|
394
|
+
worktrees: collected ? collected.worktrees : [],
|
|
395
|
+
nextPlan: fs.existsSync(nextPlan) ? nextPlan : null,
|
|
396
|
+
preserved: true,
|
|
397
|
+
message: 'NEXT_PLAN.md를 확인하고 후속 계획 진행 여부를 승인해 주세요.',
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (partialFinalize) {
|
|
401
|
+
return { ok: false, reason: 'partial-not-closed', state: 'partial-finalize', preserved: true };
|
|
402
|
+
}
|
|
403
|
+
// 원장은 G16보다 먼저 한 번만 읽는다. 증적 대조와 아래 provenance·integration이
|
|
404
|
+
// 같은 읽기를 써야 dry-run과 --yes, 그리고 두 필드가 서로 다른 원장을 보지 않는다.
|
|
405
|
+
const ledgerRead = readCoordinatorLedger({ repoRoot, blueprint: blueprintDir });
|
|
406
|
+
// drive에서 원장만 integrated로 앞서고 integration 문서가 따라오지 않으면 G16은
|
|
407
|
+
// 원인 없이 "open tasks remain"만 낸다. 그 판정 전에 어긋난 task를 이름으로 멈춘다.
|
|
408
|
+
// 문서와 커밋은 쓰지 않으며 dry-run과 --yes가 같은 자리에서 멈춘다.
|
|
409
|
+
if (ledgerRead.ok) {
|
|
410
|
+
const mismatches = coordinatorEvidenceMismatches(ledgerRead, blueprintDir);
|
|
411
|
+
if (mismatches.length > 0) {
|
|
412
|
+
return { ok: false, reason: 'coordinator-evidence-mismatch', tasks: mismatches };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const v = validateBlueprint({ repoRoot, blueprintDir, gate: 'finalize' });
|
|
416
|
+
if (!v.ok) {
|
|
417
|
+
// gates는 validate가 요약했을 때만 있다 — 있을 때만 그대로 싣는다.
|
|
418
|
+
return v.gates
|
|
419
|
+
? { ok: false, reason: 'validate', failures: v.failures, gates: v.gates }
|
|
420
|
+
: { ok: false, reason: 'validate', failures: v.failures };
|
|
421
|
+
}
|
|
422
|
+
const { docs } = loadBlueprintDocs({ repoRoot, blueprintDir });
|
|
423
|
+
const affectedPaths = docs.tasks && asRecord(docs.tasks.data).bouncer
|
|
424
|
+
? asRecord(asRecord(docs.tasks.data).bouncer).affected_paths : [];
|
|
425
|
+
const allowed = makeFinalizeAllowed({ repoRoot, affectedPaths, blueprintDir });
|
|
426
|
+
const changed = gitApi.changedFiles();
|
|
427
|
+
const untracked = gitApi.untrackedFiles();
|
|
428
|
+
// 범위 권한은 Git이 보고한 전체 후보로 판정한다. 존재 확인과 staging
|
|
429
|
+
// 후보 축소는 아래에서 별도로 처리해 makeAllowed 권한을 완화하지 않는다.
|
|
430
|
+
const allCandidates = [...new Set([...changed, ...untracked])]
|
|
431
|
+
.filter((f) => !isRuntimeArtifact(f));
|
|
432
|
+
const violations = allCandidates.filter((f) => !allowed(f));
|
|
433
|
+
if (violations.length)
|
|
434
|
+
return { ok: false, reason: 'out-of-scope', violations };
|
|
435
|
+
// subject는 blueprint title이고 body는 blueprint 본문의 `## Intent` 정본이다.
|
|
436
|
+
// task 스캔이나 verification title을 섞으면 finalize가 task authored 필드를
|
|
437
|
+
// 재사용하게 되므로, 실행 경로도 buildFinalizeCommitMessage와 같은 출처를 쓴다.
|
|
438
|
+
const commitMessage = buildFinalizeCommitMessage(docs);
|
|
439
|
+
// 위임 실행이면 원장이 이 마감의 provenance 정본이다. dry-run과 --yes가 같은
|
|
440
|
+
// 값을 보고해야 사용자가 미리 본 정리 대상과 실제 정리 대상이 갈라지지 않는다.
|
|
441
|
+
// integration은 위에서 읽은 같은 원장에서 접는다 — 보고 전용이라 거절 reason을
|
|
442
|
+
// 바꾸지 않고, 두 번 읽으면 그 사이 원장이 바뀌었을 때 두 필드가 어긋난다.
|
|
443
|
+
const collected = provenanceFromLedgerRead(ledgerRead);
|
|
444
|
+
const integration = buildIntegration(ledgerRead);
|
|
445
|
+
// 깨진 원장으로는 fan-in이 끝났는지 판정할 수 없다. 여기서 멈추지 않으면
|
|
446
|
+
// 빈 provenance와 빈 정리 목록으로 blueprint가 닫혀, 통합되지 않은 task가
|
|
447
|
+
// 완료로 기록되고 복구에 필요한 worktree가 목록에서 사라진다.
|
|
448
|
+
if (collected && collected.status === 'unreadable') {
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
reason: 'coordinator-ledger',
|
|
452
|
+
code: 'UNREADABLE_LEDGER',
|
|
453
|
+
ledgerFile: collected.ledgerFile,
|
|
454
|
+
integrationPath: collected.integrationPath,
|
|
455
|
+
integration,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const branch = collected ? collected.integrationBranch : resolveCheckoutBranch(repoRoot);
|
|
459
|
+
// top-level `worktrees`는 cleanup이 읽는 안정된 자리다(위 buildCoordinatorProvenance 주석).
|
|
460
|
+
const worktrees = collected ? collected.worktrees : [];
|
|
461
|
+
// next 후보 계산이 finalize를 깨면 안 됨: next()가 throw하면 빈 handoff
|
|
462
|
+
// 형태로 뭉개 ok/exit는 commit 작업에만 묶임.
|
|
463
|
+
const computeNext = () => {
|
|
464
|
+
try {
|
|
465
|
+
return next({ repoRoot, blueprintDir });
|
|
466
|
+
}
|
|
467
|
+
catch (_e) {
|
|
468
|
+
return { next: null, remaining: [], sameEpicPending: [] };
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
// out-of-scope 검사(위)를 통과한 뒤에만 잠금 판정을 본다 — 위반이 있으면
|
|
472
|
+
// 문서를 건드리지 않고 이미 return한 상태.
|
|
473
|
+
const lockTarget = resolveLockTarget({ repoRoot, blueprintDir });
|
|
474
|
+
const lockPath = closedLockPath(lockTarget);
|
|
475
|
+
// 잠금 경로는 파일에 쓰기 전에 staged에 합류시킨다. 「커밋할 것이 있을
|
|
476
|
+
// 때만 검증」과 「잠금 전에 검증」을 같이 지키려면, 예전처럼
|
|
477
|
+
// writeClosedLock을 여기서 먼저 부르면 안 된다 — 실패해도 blueprint는
|
|
478
|
+
// closed인데 커밋만 없는 상태가 남고, 재실행은 already-closed로 잠금만
|
|
479
|
+
// 건너뛰어 remainder가 다시 미검증으로 들어간다.
|
|
480
|
+
// 이번에 closed로 전이할 때만 일회성 문서를 지운다.
|
|
481
|
+
// 이미 closed면 lockPath가 null — 보존 문서를 소급 삭제하지 않는다.
|
|
482
|
+
const transientRels = lockPath
|
|
483
|
+
? collectTransientRels({ repoRoot, blueprintDir })
|
|
484
|
+
: [];
|
|
485
|
+
// `diff --name-only HEAD` omits an unchanged tracked transient document.
|
|
486
|
+
// Keep the diff list for scope/staging candidates, but use the index inventory
|
|
487
|
+
// to decide whether an unlinked transient path must be staged as a deletion.
|
|
488
|
+
const tracked = new Set(gitApi.trackedFiles ? gitApi.trackedFiles() : changed);
|
|
489
|
+
// 추적 파일의 삭제는 stage해야 하지만, untracked 파일은 삭제만 하면 된다.
|
|
490
|
+
// 이미 존재하지 않는 untracked 경로는 git add가 실패하므로 후보에서 제외한다.
|
|
491
|
+
const transientSet = new Set(transientRels);
|
|
492
|
+
const existingUntracked = untracked.filter((f) => (typeof f === 'string' && fs.existsSync(path.resolve(repoRoot, f))));
|
|
493
|
+
const stageCandidates = [...new Set([...changed, ...existingUntracked])]
|
|
494
|
+
.filter((f) => !isRuntimeArtifact(f));
|
|
495
|
+
const all = stageCandidates.filter((f) => (!transientSet.has(f) || tracked.has(f)));
|
|
496
|
+
const trackedTransient = transientRels.filter((rel) => tracked.has(rel));
|
|
497
|
+
// dry-run도 같은 목록을 보고해 "무엇이 지워질지"를 미리 보여 준다.
|
|
498
|
+
const staged = mergeLocked(appendUnique(all, trackedTransient), lockPath);
|
|
499
|
+
// dry-run: 쓰지 않고 "쓰게 될" 경로만 closed/staged에 반영해 보고한다.
|
|
500
|
+
// explain.md는 task_commits 기록으로 remainder에 포함될 수 있다.
|
|
501
|
+
const explainRel = `${toPosix(blueprintDir)}/explain.md`;
|
|
502
|
+
const dryStaged = (lockPath && fs.existsSync(path.join(repoRoot, explainRel)))
|
|
503
|
+
? appendUnique(staged, [explainRel])
|
|
504
|
+
: staged;
|
|
505
|
+
if (!yes) {
|
|
506
|
+
// blueprint 루트 리뷰가 deferred로 넘긴 must_fix를 --yes 전에 보이게 한다.
|
|
507
|
+
// 이미 읽은 docs만 쓰고(파일을 다시 읽지 않음), 실제 finalize cwd의 문서를 본다 —
|
|
508
|
+
// prepare는 integration checkout을 보지만 drive의 finalize cwd도 그 checkout이라 같다.
|
|
509
|
+
// task 범위(review_scope 부재)는 경고를 만들지 않는다.
|
|
510
|
+
const bpBouncer = asRecord(asRecord(docs.blueprintIndex && docs.blueprintIndex.data).bouncer);
|
|
511
|
+
const warnings = bpBouncer.review_scope === 'blueprint'
|
|
512
|
+
? collectMustFixWarnings(asRecord(asRecord(asRecord(docs.review && docs.review.data).bouncer).review).findings)
|
|
513
|
+
: [];
|
|
514
|
+
// 읽기 전용 보고가 config.verify 전체를 끌고 오면 안 되므로 검증도 생략.
|
|
515
|
+
return {
|
|
516
|
+
ok: true,
|
|
517
|
+
dryRun: true,
|
|
518
|
+
staged: dryStaged,
|
|
519
|
+
commitMessage,
|
|
520
|
+
next: computeNext(),
|
|
521
|
+
closed: lockPath,
|
|
522
|
+
branch,
|
|
523
|
+
worktrees,
|
|
524
|
+
integration,
|
|
525
|
+
warnings,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
// 빈 커밋 금지: remainder도 잠금도 없으면 stage/commit을 건너뛰고
|
|
529
|
+
// 포인터만 비운다. task 커밋은 003 `bouncer commit`이 이미 끝냈다는 전제.
|
|
530
|
+
// 스테이징 대상이 없으면 검증할 커밋도 없다.
|
|
531
|
+
if (staged.length === 0) {
|
|
532
|
+
const pointerCleared = clearPointer({ repoRoot });
|
|
533
|
+
return {
|
|
534
|
+
ok: true,
|
|
535
|
+
committed: false,
|
|
536
|
+
staged: [],
|
|
537
|
+
commitMessage,
|
|
538
|
+
pointerCleared,
|
|
539
|
+
next: computeNext(),
|
|
540
|
+
closed: lockPath,
|
|
541
|
+
branch,
|
|
542
|
+
worktrees,
|
|
543
|
+
integration,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
// remainder가 없어도 잠금만으로 커밋이 생기면 그 커밋도 저장소를 바꾼다.
|
|
547
|
+
// 예외를 두면 「어떤 finalize 커밋은 검증되지 않는다」가 된다.
|
|
548
|
+
// 해석 오류는 throw하지 않는다 — cmdFinalize/runCli에 최상위 처리기가
|
|
549
|
+
// 없어 스택이 JSON 결과를 밀어내고 종료 코드 계약(0/1)이 깨진다.
|
|
550
|
+
let command;
|
|
551
|
+
try {
|
|
552
|
+
command = readVerifyCommand(repoRoot, blueprintDir);
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
const code = codedErrorCode(error);
|
|
556
|
+
if (code) {
|
|
557
|
+
return { ok: false, reason: 'verify', code, command: null, exitCode: null, integration, branch };
|
|
558
|
+
}
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
// 설치는 verify config 해석이 성공한 뒤에만 한다 — 설정 오류·gate 거부·dry-run·
|
|
562
|
+
// 빈 종료는 위에서 이미 반환했으므로 npm을 부르지 않는다. integration checkout은
|
|
563
|
+
// git worktree라 ignored node_modules가 없으므로, 여기서 채우지 않으면 검증이
|
|
564
|
+
// 코드와 무관한 모듈 누락으로 실패한다. 설치 실패는 VERIFY_FAILED와 다른 원인이라
|
|
565
|
+
// 별도 코드로 반환하고, 문서 snapshot·삭제·stage·commit·pointer 해제 전에 멈춘다.
|
|
566
|
+
const deps = prepareFinalizeDependencies(repoRoot, dependencyExec);
|
|
567
|
+
if (!deps.ok) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
reason: 'dependency-install-failed',
|
|
571
|
+
code: 'DEPENDENCY_INSTALL_FAILED',
|
|
572
|
+
cause: deps.cause,
|
|
573
|
+
next: `${repoRoot}에서 \`${FINALIZE_INSTALL_COMMAND}\`로 의존성을 복구한 뒤 `
|
|
574
|
+
+ `같은 \`bouncer finalize --blueprint ${blueprintDir} --yes\` 명령을 다시 실행하세요.`,
|
|
575
|
+
integration,
|
|
576
|
+
branch,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const execution = executeVerify(command, {
|
|
580
|
+
cwd: repoRoot,
|
|
581
|
+
...(verifyExec ? { exec: adaptInjectedVerifyExec(verifyExec) } : {}),
|
|
582
|
+
});
|
|
583
|
+
// remainder 검증이 실패해도 이미 입증된 범위 밖 terminal 실패와 같은 원인이면 PR 인계를 막지 않는다.
|
|
584
|
+
// 같은 원인의 근거는 명령·종료 코드 일치에 더해 출력의 실패 경로가 분류 경로에 한정되는 것이다.
|
|
585
|
+
// 명령이나 종료 코드가 다르면 새 실패라 기존처럼 막는다. 새 실행의 output digest를 원본 evidence에
|
|
586
|
+
// 묶어 새 identity로 남기며, 원본 evidence는 id로만 싣는다(로컬 경로를 공유 링크에 새기지 않는다).
|
|
587
|
+
const sameCause = !execution.ok ? remainderOutsideScope(ledgerRead, command, execution, repoRoot) : null;
|
|
588
|
+
if (!execution.ok && !sameCause) {
|
|
589
|
+
return {
|
|
590
|
+
ok: false,
|
|
591
|
+
reason: 'verify',
|
|
592
|
+
code: 'VERIFY_FAILED',
|
|
593
|
+
command,
|
|
594
|
+
exitCode: execution.exitCode,
|
|
595
|
+
// 종료 코드만으로는 일시적 실패와 회귀를 가를 수 없어, executeVerify가 이미
|
|
596
|
+
// 100줄로 자른 출력(stdout+stderr tail)을 그대로 싣는다. 새 상수를 두지 않아
|
|
597
|
+
// task verify 증적의 output_tail과 같은 상한을 공유한다.
|
|
598
|
+
output_tail: execution.output,
|
|
599
|
+
integration,
|
|
600
|
+
branch,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
// 검증 성공 뒤에만 삭제·closed 전이·stage를 수행한다.
|
|
604
|
+
// stage/commit이 throw하면 삭제 전 바이트와 approved 상태로 되돌린 뒤
|
|
605
|
+
// 예외를 그대로 전파한다 — 반만 지워진 closed를 남기지 않기 위함.
|
|
606
|
+
const snapshots = transientRels.map((rel) => {
|
|
607
|
+
const abs = path.join(repoRoot, rel);
|
|
608
|
+
return { rel, abs, content: fs.readFileSync(abs) };
|
|
609
|
+
});
|
|
610
|
+
const indexAbs = lockPath ? path.join(repoRoot, lockPath) : null;
|
|
611
|
+
const indexBefore = indexAbs && fs.existsSync(indexAbs)
|
|
612
|
+
? fs.readFileSync(indexAbs)
|
|
613
|
+
: null;
|
|
614
|
+
// task_commits는 삭제 전에 tasks.md에서 읽어 explain에 옮긴다.
|
|
615
|
+
// explain 스냅샷은 쓰기 실패 복구용 — 소급 편집이 아니라 이번 전이의 일부다.
|
|
616
|
+
const explainAbs = path.join(repoRoot, explainRel);
|
|
617
|
+
const explainBefore = fs.existsSync(explainAbs)
|
|
618
|
+
? fs.readFileSync(explainAbs)
|
|
619
|
+
: null;
|
|
620
|
+
// trailer 맵은 collectTaskCommits·buildTaskContext가 같은 출처를 쓰도록 한 번만 푼다.
|
|
621
|
+
// base 부재·git 실패는 맵 없이 진행 — finalize를 막지 않고 commit_sha로 폴백한다.
|
|
622
|
+
let trailerCommits;
|
|
623
|
+
const trailerBase = resolveFinalizeTrailerBase({
|
|
624
|
+
repoRoot,
|
|
625
|
+
blueprintDir,
|
|
626
|
+
ledgerBase: collected ? collected.base : null,
|
|
627
|
+
});
|
|
628
|
+
if (trailerBase) {
|
|
629
|
+
try {
|
|
630
|
+
const head = typeof gitApi.headSha === 'function'
|
|
631
|
+
? gitApi.headSha()
|
|
632
|
+
: execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
633
|
+
cwd: repoRoot,
|
|
634
|
+
encoding: 'utf8',
|
|
635
|
+
}).trim();
|
|
636
|
+
const stableIds = (Array.isArray(docs.taskUnits) ? docs.taskUnits : [])
|
|
637
|
+
.map((unit) => stableIdFromTaskData(unit.tasks && unit.tasks.data))
|
|
638
|
+
.filter((id) => Boolean(id));
|
|
639
|
+
if (head && stableIds.length > 0) {
|
|
640
|
+
// 등록부가 있으면 옛 번호 trailer를 이동한 위치(moved_to)로 정규화해 찾는다. 없거나 깨졌으면
|
|
641
|
+
// null — 정규화 없이 정확 일치로 조회한다(깨진 등록부의 거절은 S35 검증 소관이다).
|
|
642
|
+
const registryRead = readEpicRegistry(repoRoot);
|
|
643
|
+
const trailerRegistry = registryRead.status === 'ok' ? registryRead.registry : null;
|
|
644
|
+
trailerCommits = resolveTaskCommits({
|
|
645
|
+
repoRoot,
|
|
646
|
+
base: trailerBase,
|
|
647
|
+
head,
|
|
648
|
+
stableIds,
|
|
649
|
+
registry: trailerRegistry,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
catch (_error) {
|
|
654
|
+
// rev-parse·log 실패는 trailer만 생략한다. commit_sha 경로로 계속한다.
|
|
655
|
+
trailerCommits = undefined;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const taskCommits = lockPath
|
|
659
|
+
? collectTaskCommits({ repoRoot, blueprintDir, commits: trailerCommits })
|
|
660
|
+
: [];
|
|
661
|
+
const taskContext = lockPath
|
|
662
|
+
? buildTaskContext(docs.taskUnits, trailerCommits)
|
|
663
|
+
: '';
|
|
664
|
+
const restoreTransient = () => {
|
|
665
|
+
for (const snap of snapshots) {
|
|
666
|
+
fs.mkdirSync(path.dirname(snap.abs), { recursive: true });
|
|
667
|
+
fs.writeFileSync(snap.abs, snap.content);
|
|
668
|
+
}
|
|
669
|
+
if (indexAbs && indexBefore)
|
|
670
|
+
fs.writeFileSync(indexAbs, indexBefore);
|
|
671
|
+
if (explainAbs && explainBefore)
|
|
672
|
+
fs.writeFileSync(explainAbs, explainBefore);
|
|
673
|
+
};
|
|
674
|
+
const stageList = (lockPath && explainBefore)
|
|
675
|
+
? appendUnique(staged, [explainRel])
|
|
676
|
+
: staged;
|
|
677
|
+
try {
|
|
678
|
+
if (lockPath && explainBefore) {
|
|
679
|
+
writeExplainTaskCommits({ repoRoot, blueprintDir, taskCommits });
|
|
680
|
+
writeExplainTaskContext({ repoRoot, blueprintDir, taskContext });
|
|
681
|
+
// explain은 제품 동작 기록이다. drive 원장 색인을 싣으면 검색·PR 입력이
|
|
682
|
+
// 실행 기록을 재사용한다.
|
|
683
|
+
}
|
|
684
|
+
for (const snap of snapshots)
|
|
685
|
+
fs.unlinkSync(snap.abs);
|
|
686
|
+
// 이미 closed면 lockPath가 null이라 여기서 아무것도 쓰지 않는다.
|
|
687
|
+
if (lockPath)
|
|
688
|
+
writeClosedLock(repoRoot, lockTarget);
|
|
689
|
+
gitApi.stage(stageList);
|
|
690
|
+
gitApi.commit(commitMessage);
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
restoreTransient();
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
// blueprint는 끝남. pointer를 남기면 commit guard가 이후 모든 commit에
|
|
697
|
+
// 이 blueprint의 affected_paths를 계속 강제함.
|
|
698
|
+
const pointerCleared = clearPointer({ repoRoot });
|
|
699
|
+
return {
|
|
700
|
+
ok: true,
|
|
701
|
+
committed: true,
|
|
702
|
+
staged: stageList,
|
|
703
|
+
commitMessage,
|
|
704
|
+
pointerCleared,
|
|
705
|
+
next: computeNext(),
|
|
706
|
+
closed: lockPath,
|
|
707
|
+
taskCommits,
|
|
708
|
+
branch,
|
|
709
|
+
worktrees,
|
|
710
|
+
integration,
|
|
711
|
+
...(sameCause ? { outside_scope: sameCause } : {}),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
module.exports = { finalize };
|