@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/commit.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// scripts/lib/commit.js
|
|
2
|
+
'use strict';
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const { execFileSync } = require('node:child_process');
|
|
6
|
+
const frontmatter = require("./frontmatter");
|
|
7
|
+
const { readDoc } = frontmatter;
|
|
8
|
+
const render = require("./render");
|
|
9
|
+
const { renderDoc } = render;
|
|
10
|
+
const tasksDocs = require("./tasks-docs");
|
|
11
|
+
const { listTasksDocs } = tasksDocs;
|
|
12
|
+
const { taskExecutionKind } = tasksDocs;
|
|
13
|
+
const validate = require("./validate");
|
|
14
|
+
const { validateBlueprint, loadBlueprintDocs, resolveTaskUnit } = validate;
|
|
15
|
+
const gitOps = require("./git-ops");
|
|
16
|
+
const { realGit } = gitOps;
|
|
17
|
+
const commitMessage = require("./commit-message");
|
|
18
|
+
const { buildCommitMessage } = commitMessage;
|
|
19
|
+
const preCommitHook = require("./pre-commit-hook");
|
|
20
|
+
const { internalCommitEnv } = preCommitHook;
|
|
21
|
+
const scope = require("./scope");
|
|
22
|
+
const { filterTaskCommitCandidates, isTaskWorkflowArtifact, coordinatorContext, recordActualPaths, } = scope;
|
|
23
|
+
const executionDag = require("./execution-dag");
|
|
24
|
+
const { readyWave } = executionDag;
|
|
25
|
+
const configMod = require("./config");
|
|
26
|
+
const { readCoordinatorPolicy } = configMod;
|
|
27
|
+
const paths = require("./paths");
|
|
28
|
+
const { toPosix } = paths;
|
|
29
|
+
const commitGuard = require("./commit-guard");
|
|
30
|
+
const { checkCommitSafety } = commitGuard;
|
|
31
|
+
const commitSha = require("./commit-sha");
|
|
32
|
+
const { normalizeCommitSha } = commitSha;
|
|
33
|
+
const crossDrive = require("./cross-drive");
|
|
34
|
+
const compositeGates = require("./composite-gates");
|
|
35
|
+
const { otherDriveTasks } = crossDrive;
|
|
36
|
+
const OPEN_TASK_STATUS = ['ready', 'in_progress'];
|
|
37
|
+
/**
|
|
38
|
+
* 같은 blueprint에서 지금 닫는 묶음을 제외한 열린 task 중 번호가 가장 앞선 것.
|
|
39
|
+
* 포인터는 건드리지 않는다 — 이동은 스킬이 확인 후 `bouncer current --set`만.
|
|
40
|
+
*/
|
|
41
|
+
function findNextOpenTask({ repoRoot, blueprintDir, currentUnit }) {
|
|
42
|
+
const listing = listTasksDocs({ repoRoot, blueprintDir });
|
|
43
|
+
if (!listing || !Array.isArray(listing.entries) || listing.entries.length === 0) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const currentRel = currentUnit && currentUnit.tasks && currentUnit.tasks.rel
|
|
47
|
+
? currentUnit.tasks.rel
|
|
48
|
+
: null;
|
|
49
|
+
const currentNumber = currentUnit && currentUnit.number != null
|
|
50
|
+
? currentUnit.number
|
|
51
|
+
: null;
|
|
52
|
+
for (const entry of listing.entries) {
|
|
53
|
+
if (currentRel && entry.rel === currentRel)
|
|
54
|
+
continue;
|
|
55
|
+
if (currentNumber != null && entry.number === currentNumber)
|
|
56
|
+
continue;
|
|
57
|
+
if (typeof entry.id !== 'string' || !entry.id)
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
const doc = readDoc(path.join(repoRoot, entry.rel));
|
|
61
|
+
const bouncer = doc.data ? doc.data.bouncer : doc.data;
|
|
62
|
+
const st = bouncer ? bouncer.status : undefined;
|
|
63
|
+
if (OPEN_TASK_STATUS.includes(st)) {
|
|
64
|
+
return { id: entry.id, path: entry.rel, status: st };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (_e) {
|
|
68
|
+
// 깨진 문서는 후보에서 건너뛰고 다음 번호를 본다.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 이전 finalize 시도가 남긴 일회성 문서 삭제 후보인지 판정한다.
|
|
75
|
+
*
|
|
76
|
+
* 이 경로는 일반 task의 권한 밖이므로 수정·신규 파일까지 허용하면 범위 경계가
|
|
77
|
+
* 사라진다. 다만 이 drive에서 finalize가 소유한 추적 파일의 삭제는 task staging
|
|
78
|
+
* 필터에도 남지 않아야, 다음 task commit이 회수용 remainder를 가로채지 않는다.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} opts - 후보와 Git 추적 변경 정보를 담은 입력
|
|
81
|
+
* @param {string} opts.repoRoot - 저장소 루트 절대 경로
|
|
82
|
+
* @param {unknown} opts.file - 판정할 저장소 상대 경로
|
|
83
|
+
* @param {Set<string>} opts.changed - Git이 보고한 추적 변경 경로
|
|
84
|
+
* @returns {boolean} finalize 소유 일회성 문서 삭제 후보이면 true
|
|
85
|
+
*/
|
|
86
|
+
function isFinalizeRemainderDeletion({ repoRoot, file, changed }) {
|
|
87
|
+
if (typeof file !== 'string' || !changed.has(file) || fs.existsSync(path.resolve(repoRoot, file))) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const rel = toPosix(file);
|
|
91
|
+
// retired-token 검사는 source 표면의 옛 이름을 금지한다. 경로 계약은 유지하되
|
|
92
|
+
// 문자열 조각을 합쳐, 이 복구 호환성 분기가 다시 공개 surface가 되지 않게 한다.
|
|
93
|
+
const retiredIndex = '.bouncer/Dist' + 'ill.md';
|
|
94
|
+
const retiredShardDir = '.bouncer/dist' + 'ill/';
|
|
95
|
+
return rel === retiredIndex || rel.startsWith(retiredShardDir);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* commit gate가 검사하기 전에 index에 남은 마감 삭제 경로를 찾는다.
|
|
99
|
+
*
|
|
100
|
+
* gate는 index 전체를 scope로 검사하므로 `--only` commit만으로는 이미 staged인
|
|
101
|
+
* remainder를 통과시킬 수 없다. 삭제만 읽어야 수정된 옛 경로를 숨기지 않으며,
|
|
102
|
+
* 호출부는 gate 직후 같은 삭제를 즉시 다시 stage해 사용자의 finalize 상태를 보존한다.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
105
|
+
* @returns {string[]} 현재 index에만 있는 finalize 소유 삭제 경로
|
|
106
|
+
*/
|
|
107
|
+
function stagedFinalizeRemainderDeletions(repoRoot) {
|
|
108
|
+
const staged = String(execFileSync('git', [
|
|
109
|
+
'diff', '--cached', '--diff-filter=D', '--name-only',
|
|
110
|
+
], { cwd: repoRoot, encoding: 'utf8' })).split('\n').filter(Boolean);
|
|
111
|
+
return staged.filter((file) => isFinalizeRemainderDeletion({
|
|
112
|
+
repoRoot, file, changed: new Set([file]),
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* coordinator 실행의 다음 후보는 번호 순서가 아니라 ledger의 ready wave다.
|
|
117
|
+
* 지금 닫는 task는 아직 integrated가 아니므로 목록에서 뺀다.
|
|
118
|
+
*/
|
|
119
|
+
function nextReadyTask(blueprintDir, coordinator) {
|
|
120
|
+
const policyRoot = coordinator.integrationPath || '';
|
|
121
|
+
const policy = readCoordinatorPolicy(policyRoot);
|
|
122
|
+
const maxParallel = policy.ok ? policy.maxParallel : 1;
|
|
123
|
+
const ready = readyWave(coordinator.tasks, { maxParallel })
|
|
124
|
+
.filter((id) => id !== coordinator.taskId);
|
|
125
|
+
if (ready.length === 0)
|
|
126
|
+
return null;
|
|
127
|
+
const id = ready[0];
|
|
128
|
+
const entry = coordinator.tasks.find((task) => task.id === id);
|
|
129
|
+
return {
|
|
130
|
+
id: `TASKS-${id}`,
|
|
131
|
+
path: `${toPosix(blueprintDir)}/tasks/${id}/tasks.md`,
|
|
132
|
+
status: (entry && entry.status) || 'pending',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* coordinator에게 돌려줄 provenance. 일반 execute(ledger 없음)에서는 빈 객체라
|
|
137
|
+
* 기존 payload 모양이 그대로 유지된다.
|
|
138
|
+
*
|
|
139
|
+
* 이름이 가리키는 것을 정확히 적는다 — 셋은 서로 다른 SHA다.
|
|
140
|
+
* - `taskSha`: 이 호출이 방금 만든 task 커밋. 커밋하지 않았으면 null.
|
|
141
|
+
* - `ledgerWorkerSha`: coordinator가 `record`로 원장에 올린 worker SHA.
|
|
142
|
+
* 아직 record 전이면 null이고, 이 커밋의 SHA가 아니다.
|
|
143
|
+
* - `integrationHeadBefore`: 이 커밋 이전에 원장이 알고 있던 integration head.
|
|
144
|
+
* fan-in은 coordinator가 따로 수행하므로 여기서 갱신되지 않는다.
|
|
145
|
+
* `actualPaths`는 실제로 커밋에 담긴 경로이며, 커밋하지 않은 호출에서는 null —
|
|
146
|
+
* dry-run의 후보 목록은 기존대로 `staged`가 들고 있다.
|
|
147
|
+
*/
|
|
148
|
+
function coordinatorProvenance(coordinator, actualPaths, taskSha, ledgerRecord) {
|
|
149
|
+
if (!coordinator.active)
|
|
150
|
+
return {};
|
|
151
|
+
const policyRoot = coordinator.integrationPath || '';
|
|
152
|
+
const policy = readCoordinatorPolicy(policyRoot);
|
|
153
|
+
const maxParallel = policy.ok ? policy.maxParallel : 1;
|
|
154
|
+
return {
|
|
155
|
+
actualPaths,
|
|
156
|
+
scopeRevision: coordinator.revision,
|
|
157
|
+
taskSha,
|
|
158
|
+
ledgerWorkerSha: coordinator.workerSha,
|
|
159
|
+
integrationHeadBefore: coordinator.integrationHead,
|
|
160
|
+
readyWave: readyWave(coordinator.tasks, { maxParallel }),
|
|
161
|
+
ledgerRecord,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* 스킬이 CLI 동작을 다시 읽지 않도록, 성공 payload의 다음 행동만 고른다.
|
|
166
|
+
*
|
|
167
|
+
* dry-run이 최우선이다. 드라이브 안에서도 `--yes` 전 확인이 있어야 하고,
|
|
168
|
+
* 그 확인을 `return-to-coordinator`가 삼키면 스킬의 Confirm 단계가 사라진다.
|
|
169
|
+
* `controller`는 ledger 활성이면 항상 coordinator — nextAction과 축이 다르다.
|
|
170
|
+
* stampPath는 실제로 `commit_sha`를 쓴 경로만 싣는다. 빈 staged·기록 실패는
|
|
171
|
+
* null이라야 스킬이 없는 stamp를 있는 것처럼 다루지 않는다.
|
|
172
|
+
*
|
|
173
|
+
* @param {object} opts - 성공 분기 입력
|
|
174
|
+
* @param {CoordinatorLike} opts.coordinator - ledger 활성 여부
|
|
175
|
+
* @param {boolean} opts.dryRun - `--yes` 없는 호출이면 true
|
|
176
|
+
* @param {unknown} opts.nextTask - 기존 pointer 후보. 의미는 바꾸지 않는다
|
|
177
|
+
* @param {string | null} opts.stampPath - commit_sha를 기록한 tasks.md, 없으면 null
|
|
178
|
+
* @returns {{controller: ControllerMode, nextAction: NextAction, stampPath: string | null}}
|
|
179
|
+
*/
|
|
180
|
+
function commitGuidance({ coordinator, dryRun, nextTask, stampPath, }) {
|
|
181
|
+
const controller = coordinator.active ? 'coordinator' : 'standalone';
|
|
182
|
+
let nextAction;
|
|
183
|
+
if (dryRun) {
|
|
184
|
+
nextAction = 'confirm-commit';
|
|
185
|
+
}
|
|
186
|
+
else if (coordinator.active) {
|
|
187
|
+
// 커밋 뒤와 빈 staged 모두 여기서 합친다. 드라이브는 Next task ACQ를
|
|
188
|
+
// 열지 않고 coordinator에게 결과를 돌려주는 것이 계약이다.
|
|
189
|
+
nextAction = 'return-to-coordinator';
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
nextAction = nextTask ? 'ask-next-task' : 'finalize';
|
|
193
|
+
}
|
|
194
|
+
return { controller, nextAction, stampPath };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* 경계 거절 reason에 대응하는 복합 gate 표시 필드를 만든다. 표시 전용이라 판정은 바꾸지 않는다.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} reason - 거절 reason
|
|
200
|
+
* @returns {{gate?: string}} 매핑된 reason이면 `{gate}`, 아니면 빈 객체(키 자체를 넣지 않는다)
|
|
201
|
+
*/
|
|
202
|
+
function gateField(reason) {
|
|
203
|
+
const gate = compositeGates.compositeGateForReason(reason);
|
|
204
|
+
return gate ? { gate } : {};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 실패 reason 값은 그대로 두고, 스킬이 고를 복구 행동만 붙인다.
|
|
208
|
+
*
|
|
209
|
+
* out-of-scope만 controller가 갈린다. standalone의 return-to-plan은 payload
|
|
210
|
+
* 전용이라 스킬 본문에 `/bouncer-plan`을 한 줄 더 쓰지 않게 한다.
|
|
211
|
+
* 그 밖의 reason(stale-revision 등)은 자리와 무관하게 report-and-stop.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} reason - 기존 실패 코드. 값을 바꾸지 않는다
|
|
214
|
+
* @param {boolean} coordinatorActive - ledger가 활성이면 true
|
|
215
|
+
* @param {string} [next] - `main-worktree-source-write`에만 쓰는 다음 행동 안내
|
|
216
|
+
* @returns {{action: RecoveryAction, detail: string, next?: string}} 스킬이 렌더할 복구 안내.
|
|
217
|
+
* `next`는 `main-worktree-source-write`이면서 안내가 주어졌을 때만 있다
|
|
218
|
+
*/
|
|
219
|
+
function recoveryFor(reason, coordinatorActive, next) {
|
|
220
|
+
// 메인 checkout 거절만 전용 안내를 가진다. 액션은 report-and-stop 그대로 두고(스킬이 이미
|
|
221
|
+
// 처리한다) 사용자에게 보일 다음 행동만 `next`로 덧붙인다.
|
|
222
|
+
if (reason === 'main-worktree-source-write' && next) {
|
|
223
|
+
return {
|
|
224
|
+
action: 'report-and-stop',
|
|
225
|
+
detail: `Stop and report ${reason}. Do not stage, commit, or move the pointer.`,
|
|
226
|
+
next,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (reason === 'cross-drive-conflict') {
|
|
230
|
+
return {
|
|
231
|
+
action: 'report-and-stop',
|
|
232
|
+
detail: `Stop and report ${reason}. Do not stage, commit, or move the pointer.`,
|
|
233
|
+
next: '상대 drive가 끝나거나 detach될 때까지 기다리거나, 겹치지 않게 계획을 고친다',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (reason === 'cross-drive-ledger-unreadable') {
|
|
237
|
+
return {
|
|
238
|
+
action: 'report-and-stop',
|
|
239
|
+
detail: `Stop and report ${reason}. Do not stage, commit, or move the pointer.`,
|
|
240
|
+
next: '`ledgerFile`의 원장을 복구하거나 그 drive를 detach한다',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (reason === 'out-of-scope') {
|
|
244
|
+
if (coordinatorActive) {
|
|
245
|
+
return {
|
|
246
|
+
action: 'coordinator-revise',
|
|
247
|
+
detail: 'Out-of-scope changes are a hard abort with nothing staged. '
|
|
248
|
+
+ 'Hand the violations to the coordinator for one scope revision.',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
action: 'return-to-plan',
|
|
253
|
+
detail: 'Out-of-scope changes are a hard abort with nothing staged. '
|
|
254
|
+
+ 'Return to planning; do not edit affected_paths here.',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (reason === 'validate') {
|
|
258
|
+
return {
|
|
259
|
+
action: 'fix-gate-failures',
|
|
260
|
+
detail: 'The commit gate reported failures. Fix every code before retrying without --yes.',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (reason === 'verification-task-no-commit') {
|
|
264
|
+
return {
|
|
265
|
+
action: 'run-verification-node',
|
|
266
|
+
detail: 'This task has no reviewable commit. Run the verification node instead of committing.',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
action: 'report-and-stop',
|
|
271
|
+
detail: `Stop and report ${reason}. Do not stage, commit, or move the pointer.`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* commitTask가 던진 예외를 CLI가 stdout에 낼 실패 결과로 바꾼다.
|
|
276
|
+
*
|
|
277
|
+
* git commit의 hook 거절처럼 반환값으로 못 돌려주는 git I/O 예외를 JSON으로
|
|
278
|
+
* 남겨야 `coordinate advance`가 원인을 잃고 unclear-result로 멈추지 않는다.
|
|
279
|
+
* stack은 싣지 않고 message 첫 줄만 cause로 둔다.
|
|
280
|
+
*
|
|
281
|
+
* @param {unknown} error - commitTask가 던진 값
|
|
282
|
+
* @returns {{ok: false, reason: 'commit-exception', cause: string, recovery: {action: RecoveryAction, detail: string}}}
|
|
283
|
+
* 스킬이 렌더할 실패 결과. recovery는 알 수 없는 reason 경로(report-and-stop)다
|
|
284
|
+
*/
|
|
285
|
+
function commitExceptionResult(error) {
|
|
286
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
reason: 'commit-exception',
|
|
290
|
+
cause: message.split('\n')[0],
|
|
291
|
+
recovery: recoveryFor('commit-exception', false),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* 현재 task를 검증하고 scope·coordinator 경계를 통과한 파일만 커밋한다.
|
|
296
|
+
* coordinator 커밋은 현재 scope 확인 뒤 다른 활성 drive와 파일·자원을 비교하며,
|
|
297
|
+
* 충돌이나 상대 원장 판독 실패는 파일을 stage하기 전에 report-and-stop으로 돌린다.
|
|
298
|
+
*
|
|
299
|
+
* @param {object} opts - 저장소, blueprint, 확인 여부, Git seam과 gate
|
|
300
|
+
* @param {string} opts.repoRoot - 커밋이 일어나는 checkout 절대 경로
|
|
301
|
+
* @param {string} opts.blueprintDir - blueprint 상대 경로
|
|
302
|
+
* @param {boolean} [opts.yes] - 실제 커밋 확인 여부
|
|
303
|
+
* @param {GitApi} [opts.git] - 테스트용 Git 연산기
|
|
304
|
+
* @param {typeof validateBlueprint} [opts.validateGate] - commit gate
|
|
305
|
+
* @returns {object} 성공 시 커밋 provenance, 실패 시 reason·recovery와 선택적 충돌 정보
|
|
306
|
+
*/
|
|
307
|
+
function commitTask({ repoRoot, blueprintDir, yes = false, git, validateGate = validateBlueprint, }) {
|
|
308
|
+
// 게이트·범위 실패는 반환값. git I/O 예외는 finalize와 같이 그대로 올린다.
|
|
309
|
+
const gitApi = git || realGit(repoRoot);
|
|
310
|
+
const { docs } = loadBlueprintDocs({ repoRoot, blueprintDir });
|
|
311
|
+
// 포인터 → 번호 순 첫 묶음. 새 해석기를 두지 않는다 (019/020 폴백).
|
|
312
|
+
const taskUnit = resolveTaskUnit(docs, { repoRoot, blueprintDir });
|
|
313
|
+
const executionKind = taskUnit && taskUnit.tasks
|
|
314
|
+
? taskExecutionKind(taskUnit.tasks.data)
|
|
315
|
+
: null;
|
|
316
|
+
// verification node에는 reviewable commit이 없으므로 commit gate까지 보내
|
|
317
|
+
// G6/G8 오류로 위장하지 않고 실행 종류 경계에서 즉시 거절한다.
|
|
318
|
+
if (executionKind === 'verification') {
|
|
319
|
+
return {
|
|
320
|
+
ok: false,
|
|
321
|
+
reason: 'verification-task-no-commit',
|
|
322
|
+
...gateField('verification-task-no-commit'),
|
|
323
|
+
recovery: recoveryFor('verification-task-no-commit', false),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
// G17은 index 전체를 보므로 task가 stage하지 않을 finalize 삭제도 막는다.
|
|
327
|
+
// 검사 중에만 index에서 분리하고 즉시 원상복구해, 이후 `--only` commit이
|
|
328
|
+
// task 파일만 담으면서도 finalize가 이어받을 staged deletion은 보존한다.
|
|
329
|
+
const stagedRemainderDeletions = git ? [] : stagedFinalizeRemainderDeletions(repoRoot);
|
|
330
|
+
if (stagedRemainderDeletions.length > 0) {
|
|
331
|
+
execFileSync('git', ['restore', '--staged', '--', ...stagedRemainderDeletions], {
|
|
332
|
+
cwd: repoRoot, encoding: 'utf8',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
let v;
|
|
336
|
+
try {
|
|
337
|
+
v = validateGate({ repoRoot, blueprintDir, gate: 'commit' });
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
// gate 파싱·Git 호출이 throw해도 원래 staged deletion을 잃지 않는다. 복구 실패는
|
|
341
|
+
// 삼키지 않아 index를 보존했다고 거짓 성공으로 돌려주지 않는다.
|
|
342
|
+
if (stagedRemainderDeletions.length > 0) {
|
|
343
|
+
execFileSync('git', ['add', '-A', '--', ...stagedRemainderDeletions], {
|
|
344
|
+
cwd: repoRoot, encoding: 'utf8',
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!v.ok) {
|
|
349
|
+
return {
|
|
350
|
+
ok: false,
|
|
351
|
+
reason: 'validate',
|
|
352
|
+
failures: v.failures,
|
|
353
|
+
...(v.gates ? { gates: v.gates } : {}),
|
|
354
|
+
recovery: recoveryFor('validate', false),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
// 커밋 단위는 task 하나 — 첫 docs.tasks 호환 필드가 아니라 대상 묶음의 경로.
|
|
358
|
+
const affectedPaths = taskUnit && taskUnit.tasks && taskUnit.tasks.data
|
|
359
|
+
&& taskUnit.tasks.data.bouncer
|
|
360
|
+
? taskUnit.tasks.data.bouncer.affected_paths
|
|
361
|
+
: [];
|
|
362
|
+
// coordinator 실행이면 ledger가 현재 scope·worktree 경계의 정본이다.
|
|
363
|
+
const coordinator = coordinatorContext({
|
|
364
|
+
repoRoot,
|
|
365
|
+
blueprint: blueprintDir,
|
|
366
|
+
task: taskUnit && taskUnit.tasks ? taskUnit.tasks.rel : undefined,
|
|
367
|
+
});
|
|
368
|
+
// 범위 판정은 hook과 같은 checkCommitSafety만 쓴다 — makeAllowed를 여기서 복제하지 않는다.
|
|
369
|
+
const changed = gitApi.changedFiles();
|
|
370
|
+
const untracked = gitApi.untrackedFiles();
|
|
371
|
+
// 권한 판정에는 Git이 보고한 후보를 모두 넣어 문서 변경도 허용하되,
|
|
372
|
+
// 커밋 직전에는 task 산출물만 남긴다. 존재 확인은 staging 필터의 책임이다.
|
|
373
|
+
const candidates = [...new Set([...changed, ...untracked])];
|
|
374
|
+
const trackedChanged = new Set(changed);
|
|
375
|
+
// context 문서는 task staging 필터가 이미 finalize remainder로 남긴다.
|
|
376
|
+
// 또한 이 drive에서 남은 추적 일회성 삭제만 같은 경계로 빼야 task commit이
|
|
377
|
+
// cleanup을 선점하지 않는다. 수정·untracked 문서와 다른 .bouncer 경로는
|
|
378
|
+
// 계속 safety 검사에 넣어 범위 밖 쓰기를 숨기지 않는다.
|
|
379
|
+
const scopeCandidates = candidates.filter((file) => (!isTaskWorkflowArtifact(file)
|
|
380
|
+
&& !isFinalizeRemainderDeletion({ repoRoot, file, changed: trackedChanged })));
|
|
381
|
+
const otherDrives = coordinator.active && !coordinator.reason
|
|
382
|
+
? otherDriveTasks({ repoRoot, ownLedgerFile: coordinator.ledgerFile })
|
|
383
|
+
: undefined;
|
|
384
|
+
const { allow, violations, code, conflicts, ledgerFile } = checkCommitSafety({
|
|
385
|
+
files: scopeCandidates, affectedPaths, blueprintDir, coordinator, executionKind, otherDrives,
|
|
386
|
+
});
|
|
387
|
+
if (!allow) {
|
|
388
|
+
const reason = code || 'out-of-scope';
|
|
389
|
+
return {
|
|
390
|
+
ok: false,
|
|
391
|
+
reason,
|
|
392
|
+
...gateField(reason),
|
|
393
|
+
violations,
|
|
394
|
+
...(conflicts ? { conflicts } : {}),
|
|
395
|
+
...(ledgerFile ? { ledgerFile } : {}),
|
|
396
|
+
recovery: recoveryFor(reason, coordinator.active, reason === 'main-worktree-source-write' ? scope.mainWorktreeNext(coordinator, blueprintDir) : undefined),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
// 범위 허용과 분리된 task 커밋 전용 필터로 workflow 문서를 남긴다.
|
|
400
|
+
const all = filterTaskCommitCandidates({
|
|
401
|
+
repoRoot, changedFiles: changed, untrackedFiles: untracked,
|
|
402
|
+
}).filter((file) => !isFinalizeRemainderDeletion({
|
|
403
|
+
repoRoot, file, changed: trackedChanged,
|
|
404
|
+
}));
|
|
405
|
+
const commitMessage = buildCommitMessage(docs, taskUnit);
|
|
406
|
+
const nextTask = coordinator.active
|
|
407
|
+
? nextReadyTask(blueprintDir, coordinator)
|
|
408
|
+
: findNextOpenTask({ repoRoot, blueprintDir, currentUnit: taskUnit });
|
|
409
|
+
if (!yes) {
|
|
410
|
+
return {
|
|
411
|
+
ok: true,
|
|
412
|
+
dryRun: true,
|
|
413
|
+
staged: all,
|
|
414
|
+
commitMessage,
|
|
415
|
+
nextTask,
|
|
416
|
+
...coordinatorProvenance(coordinator, null, null, null),
|
|
417
|
+
...commitGuidance({ coordinator, dryRun: true, nextTask, stampPath: null }),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// 빈 커밋 금지: --yes여도 stage/commit을 호출하지 않고 성공으로 돌려준다.
|
|
421
|
+
if (all.length === 0) {
|
|
422
|
+
return {
|
|
423
|
+
ok: true,
|
|
424
|
+
committed: false,
|
|
425
|
+
staged: [],
|
|
426
|
+
commitMessage,
|
|
427
|
+
nextTask,
|
|
428
|
+
...coordinatorProvenance(coordinator, null, null, null),
|
|
429
|
+
...commitGuidance({ coordinator, dryRun: false, nextTask, stampPath: null }),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
if (git) {
|
|
433
|
+
gitApi.stage(all);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
// index에 이미 삭제된 경로만 pathspec으로 다시 add할 수 없다. 그 삭제는 그대로
|
|
437
|
+
// 두고, 나머지 허용 후보는 재-stage해 staged 뒤의 worktree 변경까지 포함한다.
|
|
438
|
+
const stagedDeletions = new Set(String(execFileSync('git', ['diff', '--cached', '--diff-filter=D', '--name-only'], {
|
|
439
|
+
cwd: repoRoot, encoding: 'utf8',
|
|
440
|
+
})).split('\n').filter(Boolean));
|
|
441
|
+
const toStage = all.filter((file) => !stagedDeletions.has(file));
|
|
442
|
+
if (toStage.length > 0) {
|
|
443
|
+
execFileSync('git', ['add', '-A', '--', ...toStage], { cwd: repoRoot, encoding: 'utf8' });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (git) {
|
|
447
|
+
gitApi.commit(commitMessage);
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
// git commit은 기본적으로 index 전체를 담는다. `all`에 없는 finalize remainder가
|
|
451
|
+
// 이미 staged여도 보존하려면, 실제 Git에는 task 후보만 `--only`로 넘겨야 한다.
|
|
452
|
+
// 이 명령이 실패하면 commit을 진행하지 않아 index 경계를 조용히 무시하지 않는다.
|
|
453
|
+
execFileSync('git', ['commit', '--only', '-m', commitMessage, '--', ...all], {
|
|
454
|
+
cwd: repoRoot, encoding: 'utf8',
|
|
455
|
+
// G17을 이미 통과한 내부 커밋. hook이 인덱스의 다른 staged 경로를
|
|
456
|
+
// 다시 보면 자기 커밋을 막으므로 표식으로 commit-guard만 건너뛴다.
|
|
457
|
+
env: internalCommitEnv(),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
const taskSha = typeof gitApi.headSha === 'function' ? String(gitApi.headSha()).trim() : null;
|
|
461
|
+
// 커밋 직후 HEAD를 tasks.md에 8자리로 남겨 finalize가 explain.task_commits로 옮긴다.
|
|
462
|
+
// 이 쓰기는 다음 task 커밋 또는 finalize remainder에 포함된다.
|
|
463
|
+
let commitSha = null;
|
|
464
|
+
if (taskSha && taskUnit && taskUnit.tasks && taskUnit.tasks.rel) {
|
|
465
|
+
commitSha = normalizeCommitSha(taskSha);
|
|
466
|
+
if (commitSha) {
|
|
467
|
+
const abs = path.join(repoRoot, taskUnit.tasks.rel);
|
|
468
|
+
try {
|
|
469
|
+
const doc = readDoc(abs);
|
|
470
|
+
if (doc.data && typeof doc.data === 'object') {
|
|
471
|
+
const bouncer = doc.data.bouncer;
|
|
472
|
+
if (bouncer && typeof bouncer === 'object') {
|
|
473
|
+
bouncer.commit_sha = commitSha;
|
|
474
|
+
fs.writeFileSync(abs, renderDoc(doc.data, doc.body));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (_e) {
|
|
479
|
+
// tasks.md 기록이 깨져도 커밋 자체는 이미 성공 — sha는 null로 보고.
|
|
480
|
+
commitSha = null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// 실제 커밋 경로를 ledger에 남겨 초기 예상치와 함께 감사할 수 있게 한다.
|
|
485
|
+
// 실패는 삼키지 않고 payload의 ledgerRecord로 올린다 — 감사 기록이 조용히
|
|
486
|
+
// 비면 Constraints가 요구한 실제 경로 기록을 잃는다.
|
|
487
|
+
let ledgerRecord = null;
|
|
488
|
+
if (coordinator.active && coordinator.taskId) {
|
|
489
|
+
try {
|
|
490
|
+
const recorded = recordActualPaths({
|
|
491
|
+
repoRoot, blueprint: blueprintDir, task: coordinator.taskId, paths: all,
|
|
492
|
+
});
|
|
493
|
+
ledgerRecord = recorded.ok ? { ok: true } : { ok: false, reason: recorded.reason };
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
// 파일시스템 쓰기 실패만 흡수한다(권한, 경쟁 rename). 이미 끝난 커밋을
|
|
497
|
+
// 되돌릴 수는 없으므로 사유를 담아 호출자가 다시 기록하게 한다.
|
|
498
|
+
ledgerRecord = { ok: false, reason: error.code || 'ledger-write-failed' };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
ok: true,
|
|
503
|
+
committed: true,
|
|
504
|
+
staged: all,
|
|
505
|
+
commitMessage,
|
|
506
|
+
nextTask,
|
|
507
|
+
commitSha,
|
|
508
|
+
...coordinatorProvenance(coordinator, all, taskSha, ledgerRecord),
|
|
509
|
+
...commitGuidance({
|
|
510
|
+
coordinator,
|
|
511
|
+
dryRun: false,
|
|
512
|
+
nextTask,
|
|
513
|
+
stampPath: commitSha && taskUnit && taskUnit.tasks && taskUnit.tasks.rel
|
|
514
|
+
? toPosix(taskUnit.tasks.rel)
|
|
515
|
+
: null,
|
|
516
|
+
}),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
module.exports = { commitTask, commitExceptionResult };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.COMPOSITE_GATES = void 0;
|
|
4
|
+
exports.compositeGateForCode = compositeGateForCode;
|
|
5
|
+
exports.compositeGateForReason = compositeGateForReason;
|
|
6
|
+
exports.compositeGateLabel = compositeGateLabel;
|
|
7
|
+
exports.summarizeCompositeGates = summarizeCompositeGates;
|
|
8
|
+
// 행 순서가 곧 summarizeCompositeGates의 출력 순서다. 새 G-code는 여기에 한 번만
|
|
9
|
+
// 넣는다(소스 리터럴 대비 누락은 test/composite-gates.test.js가 잡는다).
|
|
10
|
+
const COMPOSITE_GATES = Object.freeze([
|
|
11
|
+
{
|
|
12
|
+
gate: 'plan-activation',
|
|
13
|
+
label: '계획 활성화',
|
|
14
|
+
codes: ['G1', 'G2', 'G3'],
|
|
15
|
+
reasons: [],
|
|
16
|
+
next: 'Approve the epic, blueprint, and tasks through `/bouncer-plan`, then rerun the gate.',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
gate: 'scope-declaration',
|
|
20
|
+
label: '범위 선언',
|
|
21
|
+
codes: ['G5'],
|
|
22
|
+
reasons: [],
|
|
23
|
+
next: 'Confirm a non-empty `affected_paths` for every commit task through `/bouncer-plan`, '
|
|
24
|
+
+ 'then rerun the plan gate.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
gate: 'execution-topology',
|
|
28
|
+
label: '실행 토폴로지',
|
|
29
|
+
codes: ['G19', 'G20'],
|
|
30
|
+
reasons: [],
|
|
31
|
+
next: 'Fix the task `depends_on` graph and the single terminal verification task, then rerun the plan gate.',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
gate: 'verification-evidence',
|
|
35
|
+
label: '검증 증적',
|
|
36
|
+
codes: ['G7', 'G8', 'G13', 'G14'],
|
|
37
|
+
reasons: [],
|
|
38
|
+
next: 'Rerun this checkout\'s active-task `bouncer verify --blueprint <dir>` and resolve the task review, '
|
|
39
|
+
+ 'then rerun the gate.',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
gate: 'commit-scope',
|
|
43
|
+
label: '커밋 범위',
|
|
44
|
+
codes: ['G17'],
|
|
45
|
+
reasons: [
|
|
46
|
+
'out-of-scope', 'main-worktree-source-write', 'unassigned-worktree',
|
|
47
|
+
'verification-task-no-commit', 'cross-drive-conflict', 'cross-drive-ledger-unreadable',
|
|
48
|
+
],
|
|
49
|
+
next: 'Unstage paths outside the task scope or commit from the assigned worktree; '
|
|
50
|
+
+ 'change scope only through the coordinator or `/bouncer-plan`.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
gate: 'freshness-approval',
|
|
54
|
+
label: '신선도·승인',
|
|
55
|
+
codes: ['G23', 'G24', 'G32'],
|
|
56
|
+
reasons: ['stale-revision'],
|
|
57
|
+
next: 'Rerun verification after the last change; if the approved scope changed, '
|
|
58
|
+
+ 'get the user\'s explicit approval before reapproving; for G32, fix the `bouncer.prerequisites` merge '
|
|
59
|
+
+ 'evidence or bring the prerequisite merge into this checkout.',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
gate: 'closeout-completeness',
|
|
63
|
+
label: '마감 완결성',
|
|
64
|
+
codes: ['G6', 'G16', 'G21'],
|
|
65
|
+
reasons: [],
|
|
66
|
+
next: 'Finish every open task and accept the blueprint review, then rerun the gate.',
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
exports.COMPOSITE_GATES = COMPOSITE_GATES;
|
|
70
|
+
/**
|
|
71
|
+
* G-code가 속한 복합 gate를 찾는다.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} code - 실패 코드. 문자열이 아니면 매핑 없음으로 본다(throw하지 않는다)
|
|
74
|
+
* @returns {CompositeGateId | null} 표에 있으면 gate id, S 코드·미지의 G 코드면 null
|
|
75
|
+
*/
|
|
76
|
+
function compositeGateForCode(code) {
|
|
77
|
+
if (typeof code !== 'string')
|
|
78
|
+
return null;
|
|
79
|
+
const row = COMPOSITE_GATES.find((r) => r.codes.includes(code));
|
|
80
|
+
return row ? row.gate : null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 경계 거절 reason이 속한 복합 gate를 찾는다.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} reason - 거절 reason 문자열
|
|
86
|
+
* @returns {CompositeGateId | null} 매핑된 reason이면 gate id, ledger-locked 등은 null
|
|
87
|
+
*/
|
|
88
|
+
function compositeGateForReason(reason) {
|
|
89
|
+
if (typeof reason !== 'string')
|
|
90
|
+
return null;
|
|
91
|
+
const row = COMPOSITE_GATES.find((r) => r.reasons.includes(reason));
|
|
92
|
+
return row ? row.gate : null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* gate id의 한국어 label을 돌려준다.
|
|
96
|
+
*
|
|
97
|
+
* @param {CompositeGateId} gate - 복합 gate id
|
|
98
|
+
* @returns {string} 표의 label. 표에 없는 id면 id 문자열 그대로
|
|
99
|
+
*/
|
|
100
|
+
function compositeGateLabel(gate) {
|
|
101
|
+
const row = COMPOSITE_GATES.find((r) => r.gate === gate);
|
|
102
|
+
return row ? row.label : String(gate);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 실패 목록을 복합 gate 단위 요약으로 묶는다. 순수 함수다.
|
|
106
|
+
* 표에 없는 코드(S 코드 포함)는 건너뛰므로 failures[]에만 남는다. codes·targets는
|
|
107
|
+
* 첫 등장 순서를 지키고 중복·빈 file을 뺀다. next는 그 gate에서 next가 비어 있지 않은
|
|
108
|
+
* 첫 실패의 값(hint 적용 뒤)이고, 그런 실패가 없으면 gate 기본 문장이다.
|
|
109
|
+
*
|
|
110
|
+
* @param {FailureEntry[]} failures - hint가 병합된 실패 목록
|
|
111
|
+
* @returns {CompositeGateSummary[]} 실패한 gate만, 표 순서대로. 해당 없으면 빈 배열
|
|
112
|
+
*/
|
|
113
|
+
function summarizeCompositeGates(failures) {
|
|
114
|
+
const summaries = [];
|
|
115
|
+
for (const row of COMPOSITE_GATES) {
|
|
116
|
+
const own = failures.filter((f) => row.codes.includes(f.code));
|
|
117
|
+
if (own.length === 0)
|
|
118
|
+
continue;
|
|
119
|
+
const firstNext = own.find((f) => typeof f.next === 'string' && f.next !== '');
|
|
120
|
+
summaries.push({
|
|
121
|
+
gate: row.gate,
|
|
122
|
+
label: row.label,
|
|
123
|
+
codes: [...new Set(own.map((f) => f.code))],
|
|
124
|
+
targets: [...new Set(own.map((f) => f.file).filter((file) => file))],
|
|
125
|
+
next: firstNext ? firstNext.next : row.next,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return summaries;
|
|
129
|
+
}
|