@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
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { execFileSync } = require('node:child_process');
|
|
4
|
+
const guards = require("./guards");
|
|
5
|
+
const { isRecord } = guards;
|
|
6
|
+
const layout = require("./layout");
|
|
7
|
+
const { CONTEXT_ROOT } = layout;
|
|
8
|
+
const contextIds = require("./context-ids");
|
|
9
|
+
const { normalizeStableId } = contextIds;
|
|
10
|
+
const epicRegistry = require("./epic-registry");
|
|
11
|
+
const { serializeEpicRegistry, REGISTRY_REL } = epicRegistry;
|
|
12
|
+
const epicIndex = require("./epic-index");
|
|
13
|
+
const { formatEpicIndexLine, CONTEXT_INDEX_REL } = epicIndex;
|
|
14
|
+
const frontmatter = require("./frontmatter");
|
|
15
|
+
const { parseFrontmatter } = frontmatter;
|
|
16
|
+
const render = require("./render");
|
|
17
|
+
const { renderDoc } = render;
|
|
18
|
+
const canonicalJson = require("./canonical-json");
|
|
19
|
+
const { sha256Canonical } = canonicalJson;
|
|
20
|
+
const validateSections = require("./validate-sections");
|
|
21
|
+
const { findingFingerprint, findingAutoId } = validateSections;
|
|
22
|
+
const planSnapshotContract = require("./plan-snapshot-contract");
|
|
23
|
+
const { digestPlanContent, normalizeStructure, structureKindOf } = planSnapshotContract;
|
|
24
|
+
const taskBriefHashModule = require("./task-brief-hash");
|
|
25
|
+
const { taskBriefHash } = taskBriefHashModule;
|
|
26
|
+
const runtimeState = require("./runtime-state");
|
|
27
|
+
const { runtimePaths, listNamespacePointers, COORDINATOR_LEDGER_REL } = runtimeState;
|
|
28
|
+
const schema = require("./schema");
|
|
29
|
+
const { ID_PREFIX } = schema;
|
|
30
|
+
const TASK_UNIT_DIR_RE = /\/tasks\/\d{3}\//;
|
|
31
|
+
// blueprint 루트에 놓일 수 있는 증적 문서 type. 구형 루트 tasks 문서는 id 규칙이 달라 여기 넣지 않는다.
|
|
32
|
+
const ROOT_DOC_TYPES = ['bouncer.review', 'bouncer.explain', 'bouncer.context_review'];
|
|
33
|
+
const TASK_DOC_RE = /^(.*)\/tasks\/(\d{3})\/tasks\.md$/;
|
|
34
|
+
function escapeRegExp(value) {
|
|
35
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 기본 git 실행기. 실패는 던진다 — `git status`가 실패한 저장소를 깨끗하다고 보면 안 된다.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} repoRoot - git cwd
|
|
41
|
+
* @returns {(args: string[]) => { stdout: string }} `git` 뒤 argv를 받는 실행기
|
|
42
|
+
*/
|
|
43
|
+
function defaultRunGit(repoRoot) {
|
|
44
|
+
return (args) => ({
|
|
45
|
+
stdout: execFileSync('git', args, {
|
|
46
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 256 * 1024 * 1024,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** 디렉터리의 하위 파일 절대 경로를 모은다. 디렉터리가 없으면 빈 목록이다(ENOENT만 흡수). */
|
|
51
|
+
function listFilesUnder(fsApi, dir) {
|
|
52
|
+
let names;
|
|
53
|
+
try {
|
|
54
|
+
names = fsApi.readdirSync(dir);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
// 부재(ENOENT)·디렉터리 아님(ENOTDIR)만 "없음"이다. 권한 오류는 숨기면 진행 중 상태를 놓치므로 던진다.
|
|
58
|
+
const code = error.code;
|
|
59
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
60
|
+
return [];
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const name of names) {
|
|
65
|
+
const abs = path.join(dir, name);
|
|
66
|
+
if (fsApi.statSync(abs).isDirectory())
|
|
67
|
+
out.push(...listFilesUnder(fsApi, abs));
|
|
68
|
+
else
|
|
69
|
+
out.push(abs);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 진행 중인 실행 상태를 모은다. 이관은 경로 키를 바꾸므로 pointer·승인 스냅샷·coordinator 원장·
|
|
75
|
+
* linked worktree가 옛 키를 쥔 채 남으면 안 된다. `.bouncer/runtime/plan-*`와 context-review 캐시는
|
|
76
|
+
* 경로 키를 쥐지 않는 재생성 캐시라 일부러 보지 않는다(답변 17).
|
|
77
|
+
*
|
|
78
|
+
* @param {object} opts - 입력
|
|
79
|
+
* @param {string} opts.repoRoot - 저장소 루트 절대 경로
|
|
80
|
+
* @param {(args: string[]) => { stdout: string }} opts.runGit - `git worktree list --porcelain` 실행기
|
|
81
|
+
* @param {InjectedFs} opts.fsApi - 읽기용 fs
|
|
82
|
+
* @returns {InProgressEntry[]} 거절 사유가 되는 항목(없으면 빈 목록)
|
|
83
|
+
*/
|
|
84
|
+
function defaultListInProgress({ repoRoot, runGit, fsApi }) {
|
|
85
|
+
const found = [];
|
|
86
|
+
const paths = runtimePaths({ repoRoot });
|
|
87
|
+
if (paths.unavailable)
|
|
88
|
+
return found;
|
|
89
|
+
// 1. pointer: 레거시 단일 파일과 namespace 파일(깨진 파일 포함 — 깨진 주기도 진행 중으로 본다).
|
|
90
|
+
if (fsApi.existsSync(paths.currentFile))
|
|
91
|
+
found.push({ kind: 'pointer', path: paths.currentFile });
|
|
92
|
+
for (const entry of listNamespacePointers({ repoRoot }))
|
|
93
|
+
found.push({ kind: 'pointer', path: entry.path });
|
|
94
|
+
// 2. 승인 스냅샷: 파일이 하나라도 있으면 승인 후 미완료 주기다.
|
|
95
|
+
for (const file of listFilesUnder(fsApi, paths.approvalsRoot))
|
|
96
|
+
found.push({ kind: 'approval', path: file });
|
|
97
|
+
// 3. coordinator 원장: `.worktrees/<epic>/<bp>/integration/…/coordinator.json` (coordinatorPathsFor 배치).
|
|
98
|
+
const worktreeRoot = paths.worktreeRoot;
|
|
99
|
+
for (const epicDir of fsApi.existsSync(worktreeRoot) ? fsApi.readdirSync(worktreeRoot) : []) {
|
|
100
|
+
const epicAbs = path.join(worktreeRoot, epicDir);
|
|
101
|
+
if (!fsApi.statSync(epicAbs).isDirectory())
|
|
102
|
+
continue;
|
|
103
|
+
for (const bpDir of fsApi.readdirSync(epicAbs)) {
|
|
104
|
+
const ledger = path.join(epicAbs, bpDir, 'integration', ...COORDINATOR_LEDGER_REL.split('/'));
|
|
105
|
+
if (fsApi.existsSync(ledger))
|
|
106
|
+
found.push({ kind: 'ledger', path: ledger });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// 4. linked worktree: 이관을 실행하는 checkout 자신은 제외한다.
|
|
110
|
+
const listed = runGit(['worktree', 'list', '--porcelain']).stdout;
|
|
111
|
+
for (const line of listed.split('\n')) {
|
|
112
|
+
if (!line.startsWith('worktree '))
|
|
113
|
+
continue;
|
|
114
|
+
const wt = line.slice('worktree '.length).trim();
|
|
115
|
+
if (wt !== repoRoot && wt.startsWith(`${worktreeRoot}${path.sep}`))
|
|
116
|
+
found.push({ kind: 'worktree', path: wt });
|
|
117
|
+
}
|
|
118
|
+
return found;
|
|
119
|
+
}
|
|
120
|
+
/** 원래 경로를 이동 후 경로로 바꾼다. blueprint 이동(더 긴 접두)을 epic 이동보다 먼저 본다. */
|
|
121
|
+
function makePathMapper(moves) {
|
|
122
|
+
const ordered = [...moves].sort((a, b) => b.from.length - a.from.length);
|
|
123
|
+
return (rel) => {
|
|
124
|
+
for (const m of ordered) {
|
|
125
|
+
if (rel === m.from || rel.startsWith(`${m.from}/`))
|
|
126
|
+
return m.to + rel.slice(m.from.length);
|
|
127
|
+
}
|
|
128
|
+
return rel;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* 한 문서 안의 markdown 링크 대상 하나를 이동 후 위치 기준 상대 경로로 다시 쓴다.
|
|
133
|
+
* 문서 자신이 옮겨졌거나 대상이 옮겨졌을 때만 바꾸고, 그 밖의 링크는 원문 바이트를 유지한다.
|
|
134
|
+
*/
|
|
135
|
+
function rewriteLinkTarget(target, oldRel, newRel, mapPath) {
|
|
136
|
+
// 외부 URL·앵커·절대 경로·퍼센트 인코딩 경로는 저장소 안 상대 링크가 아니라 건드리지 않는다.
|
|
137
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || /^[#/<]/.test(target) || target.includes('%'))
|
|
138
|
+
return target;
|
|
139
|
+
const split = /^([^#?]*)([#?].*)?$/.exec(target);
|
|
140
|
+
const pathPart = split[1];
|
|
141
|
+
const suffix = split[2] ?? '';
|
|
142
|
+
if (pathPart === '')
|
|
143
|
+
return target;
|
|
144
|
+
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(oldRel), pathPart));
|
|
145
|
+
if (resolved.startsWith('..'))
|
|
146
|
+
return target;
|
|
147
|
+
const mapped = mapPath(resolved);
|
|
148
|
+
if (mapped === resolved && path.posix.dirname(oldRel) === path.posix.dirname(newRel))
|
|
149
|
+
return target;
|
|
150
|
+
let rel = path.posix.relative(path.posix.dirname(newRel), mapped.replace(/\/$/, ''));
|
|
151
|
+
if (rel === '')
|
|
152
|
+
rel = '.';
|
|
153
|
+
const stripSlash = (v) => v.replace(/\/$/, '');
|
|
154
|
+
// 이동 후에도 같은 상대 표기면(예: 같은 이동 묶음 안의 링크) 원문을 그대로 둔다.
|
|
155
|
+
if (stripSlash(rel) === stripSlash(path.posix.normalize(pathPart)))
|
|
156
|
+
return target;
|
|
157
|
+
if (pathPart.endsWith('/') && !rel.endsWith('/'))
|
|
158
|
+
rel += '/';
|
|
159
|
+
if (pathPart.startsWith('./') && !rel.startsWith('.'))
|
|
160
|
+
rel = `./${rel}`;
|
|
161
|
+
return rel + suffix;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 같은 문자열 집합을 한 번의 패스로 치환한다. 순차 치환은 한 치환의 결과가 다른 키와 겹칠 때
|
|
165
|
+
* 연쇄 치환을 일으키므로 쓰지 않는다. 키는 식별자/해시이므로 앞뒤가 영숫자인 부분 일치는 제외한다.
|
|
166
|
+
*/
|
|
167
|
+
function replaceAllOnce(text, pairs) {
|
|
168
|
+
if (pairs.size === 0)
|
|
169
|
+
return text;
|
|
170
|
+
const keys = [...pairs.keys()].sort((a, b) => b.length - a.length).map(escapeRegExp);
|
|
171
|
+
const re = new RegExp(`(?<![A-Za-z0-9_-])(?:${keys.join('|')})(?![A-Za-z0-9_])`, 'g');
|
|
172
|
+
return text.replace(re, (hit) => pairs.get(hit) ?? hit);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 계획 문서(epic index·blueprint index·tasks.md)의 digest를 메모리 내용으로 계산한다.
|
|
176
|
+
* plan-snapshot의 `computePlanSnapshot`과 같은 문서 집합·순서·정규화를 쓰되 디스크 대신 주어진 내용을 읽는다 —
|
|
177
|
+
* 이관은 쓰기 전에 모든 값을 확정해야 하기 때문이다. 문서가 하나라도 없으면 null이다.
|
|
178
|
+
*/
|
|
179
|
+
function planDigestOf(epicIndex, blueprintDir, taskNumbers, textOf) {
|
|
180
|
+
const documents = [
|
|
181
|
+
epicIndex,
|
|
182
|
+
`${blueprintDir}/index.md`,
|
|
183
|
+
...taskNumbers.map((n) => `${blueprintDir}/tasks/${n}/tasks.md`),
|
|
184
|
+
];
|
|
185
|
+
const content = {};
|
|
186
|
+
for (const rel of documents) {
|
|
187
|
+
const text = textOf(rel);
|
|
188
|
+
if (text === undefined)
|
|
189
|
+
return null;
|
|
190
|
+
const parsed = parseFrontmatter(text);
|
|
191
|
+
const bouncer = isRecord(parsed.data) ? parsed.data.bouncer : undefined;
|
|
192
|
+
content[rel] = { body: parsed.body, structure: normalizeStructure(structureKindOf(rel, blueprintDir), bouncer) };
|
|
193
|
+
}
|
|
194
|
+
return digestPlanContent(documents, content);
|
|
195
|
+
}
|
|
196
|
+
/** fingerprint 공식이 읽는 네 필드만 뽑는다. 공식 자체는 findingFingerprint 한 곳에 둔다. */
|
|
197
|
+
function fingerprintFields(finding) {
|
|
198
|
+
return {
|
|
199
|
+
category: finding.category, brief_clause: finding.brief_clause, file: finding.file, symbol: finding.symbol,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* frontmatter의 식별자·경로·파생 해시를 새 형식으로 고친다. 텍스트 치환이 끝난 문서(`text`)를 받는다.
|
|
204
|
+
* 값을 다시 계산하는 규칙은 하나다: **원본이 그 값을 만든 함수의 결과와 일치했을 때만** 다시 계산한다.
|
|
205
|
+
* 이미 어긋난(변조·stale) 값을 새 값으로 덮으면 이관이 검증 실패를 숨기기 때문이다.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} original - 이관 전 문서 원문
|
|
208
|
+
* @param {string} text - 링크·경로·안정 ID 치환을 마친 문서
|
|
209
|
+
* @param {DocContext} ctx - 문서가 속한 epic·blueprint 이동
|
|
210
|
+
* @param {string} newRel - 이동 후 저장소 상대 경로
|
|
211
|
+
* @returns {{ text: string, ids: Map<string,string>, evidence: [string,string] | null }}
|
|
212
|
+
* frontmatter가 없거나 bouncer 문서가 아니면 text를 그대로 돌려준다. ids는 문서 안 finding id 치환 표다
|
|
213
|
+
*/
|
|
214
|
+
function restructure(original, text, ctx, newRel) {
|
|
215
|
+
const ids = new Map();
|
|
216
|
+
let before;
|
|
217
|
+
let after;
|
|
218
|
+
try {
|
|
219
|
+
before = parseFrontmatter(original);
|
|
220
|
+
after = parseFrontmatter(text);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
// frontmatter가 없는 자유 형식 md만 흡수한다. YAML 오류는 던져 쓰기 전에 이관을 멈춘다.
|
|
224
|
+
if (error instanceof Error && error.message === 'missing frontmatter block')
|
|
225
|
+
return { text, ids, evidence: null };
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
const doc = after.data;
|
|
229
|
+
if (!isRecord(doc) || !isRecord(doc.bouncer))
|
|
230
|
+
return { text, ids, evidence: null };
|
|
231
|
+
const oldBouncer = isRecord(before.data) && isRecord(before.data.bouncer) ? before.data.bouncer : {};
|
|
232
|
+
let bouncer = doc.bouncer;
|
|
233
|
+
const type = doc.type;
|
|
234
|
+
const epicSlug = ctx.epicSlug;
|
|
235
|
+
const bpSlug = ctx.blueprint?.slug;
|
|
236
|
+
doc.resource = newRel;
|
|
237
|
+
if (type === 'bouncer.epic')
|
|
238
|
+
bouncer.id = epicSlug;
|
|
239
|
+
if ('epic_id' in bouncer)
|
|
240
|
+
bouncer.epic_id = epicSlug;
|
|
241
|
+
if (bpSlug !== undefined && 'blueprint_id' in bouncer)
|
|
242
|
+
bouncer.blueprint_id = bpSlug;
|
|
243
|
+
if (bpSlug !== undefined && type === 'bouncer.blueprint') {
|
|
244
|
+
bouncer.id = bpSlug;
|
|
245
|
+
const order = ctx.blueprint.order;
|
|
246
|
+
if (bouncer.order !== undefined && ctx.orderMode === 'overwrite') {
|
|
247
|
+
// 병합은 대상 epic 안의 새 순번이 정본이라 기존 값을 덮는다. 키 위치는 그대로 유지된다.
|
|
248
|
+
bouncer.order = order;
|
|
249
|
+
}
|
|
250
|
+
else if (bouncer.order === undefined) {
|
|
251
|
+
// order는 status 바로 뒤에 둔다(scaffold가 쓰는 위치와 같다).
|
|
252
|
+
const withOrder = {};
|
|
253
|
+
for (const [key, value] of Object.entries(bouncer)) {
|
|
254
|
+
withOrder[key] = value;
|
|
255
|
+
if (key === 'status')
|
|
256
|
+
withOrder.order = order;
|
|
257
|
+
}
|
|
258
|
+
if (withOrder.order === undefined)
|
|
259
|
+
withOrder.order = order;
|
|
260
|
+
bouncer = withOrder;
|
|
261
|
+
doc.bouncer = bouncer;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
else if (bpSlug !== undefined && ROOT_DOC_TYPES.includes(type)
|
|
265
|
+
&& !TASK_UNIT_DIR_RE.test(`/${newRel}`)) {
|
|
266
|
+
// blueprint 루트 문서(review·explain·context-review)의 id는 접두 + blueprint key다.
|
|
267
|
+
// tasks/<NNN>/ 묶음 문서는 task 번호가 id라 그대로 둔다.
|
|
268
|
+
bouncer.id = `${ID_PREFIX[type]}${bpSlug}`;
|
|
269
|
+
}
|
|
270
|
+
// evidence_id: identity 안의 scope.key가 새 형식이 되었으므로 같은 함수로 다시 계산한다.
|
|
271
|
+
let evidence = null;
|
|
272
|
+
const verification = bouncer.verification;
|
|
273
|
+
const oldVerification = oldBouncer.verification;
|
|
274
|
+
if (isRecord(verification) && isRecord(verification.identity) && typeof verification.evidence_id === 'string'
|
|
275
|
+
&& isRecord(oldVerification) && isRecord(oldVerification.identity)
|
|
276
|
+
&& sha256Canonical(oldVerification.identity) === verification.evidence_id) {
|
|
277
|
+
const next = sha256Canonical(verification.identity);
|
|
278
|
+
if (next !== verification.evidence_id)
|
|
279
|
+
evidence = [verification.evidence_id, next];
|
|
280
|
+
verification.evidence_id = next;
|
|
281
|
+
}
|
|
282
|
+
// finding fingerprint·자동 id: file 경로가 바뀌면 fingerprint 입력이 바뀐다. 사람이 정한 id는 그대로 둔다.
|
|
283
|
+
for (const [key, namespace] of [['review', undefined], ['context_review', 'context']]) {
|
|
284
|
+
const oldSection = oldBouncer[key];
|
|
285
|
+
const newSection = bouncer[key];
|
|
286
|
+
if (!isRecord(oldSection) || !isRecord(newSection))
|
|
287
|
+
continue;
|
|
288
|
+
const oldFindings = Array.isArray(oldSection.findings) ? oldSection.findings : [];
|
|
289
|
+
const newFindings = Array.isArray(newSection.findings) ? newSection.findings : [];
|
|
290
|
+
newFindings.forEach((finding, index) => {
|
|
291
|
+
const old = oldFindings[index];
|
|
292
|
+
if (!isRecord(finding) || !isRecord(old) || typeof old.fingerprint !== 'string')
|
|
293
|
+
return;
|
|
294
|
+
const oldFp = findingFingerprint(fingerprintFields(old), namespace);
|
|
295
|
+
if (old.fingerprint !== oldFp)
|
|
296
|
+
return;
|
|
297
|
+
const newFp = findingFingerprint(fingerprintFields(finding), namespace);
|
|
298
|
+
finding.fingerprint = newFp;
|
|
299
|
+
if (old.id === findingAutoId(oldFp, namespace)) {
|
|
300
|
+
const newId = findingAutoId(newFp, namespace);
|
|
301
|
+
if (newId !== old.id) {
|
|
302
|
+
ids.set(old.id, newId);
|
|
303
|
+
finding.id = newId;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return { text: renderDoc(doc, after.body), ids, evidence };
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* 모든 문서 변경을 메모리에서 계산한다. 디스크는 읽기만 한다.
|
|
312
|
+
*
|
|
313
|
+
* @param {object} opts - 입력
|
|
314
|
+
* @param {string} opts.repoRoot - 저장소 루트 절대 경로
|
|
315
|
+
* @param {RewritePlan} opts.plan - 이동 계획(충돌 없음)
|
|
316
|
+
* @param {Deps} opts.deps - runGit·fs
|
|
317
|
+
* @returns {Rewrite[]} 문서별 이관 전/후 내용. 내용이 같으면 text === original
|
|
318
|
+
*/
|
|
319
|
+
function computeRewrites({ repoRoot, plan, runGit, fsApi }) {
|
|
320
|
+
const mapPath = makePathMapper(plan.moved);
|
|
321
|
+
const tracked = runGit(['ls-files', '-z', '--', '*.md']).stdout.split('\0').filter((p) => p !== '');
|
|
322
|
+
const originals = new Map();
|
|
323
|
+
for (const rel of tracked)
|
|
324
|
+
originals.set(rel, fsApi.readFileSync(path.join(repoRoot, rel), 'utf8'));
|
|
325
|
+
const pathRe = plan.moved.length === 0 ? null : new RegExp(`(?:${[...plan.moved].sort((a, b) => b.from.length - a.from.length)
|
|
326
|
+
.map((m) => escapeRegExp(m.from)).join('|')})(?![A-Za-z0-9_-])`, 'g');
|
|
327
|
+
const idRegistry = plan.nextRegistry;
|
|
328
|
+
// blueprint 맥락이 epic 직속 맥락보다 먼저 맞아야 하므로(더 구체적) 두 번에 나눠 찾는다.
|
|
329
|
+
const contextOf = (rel) => {
|
|
330
|
+
const inside = (dir) => rel === dir || rel.startsWith(`${dir}/`);
|
|
331
|
+
return plan.contexts.find((c) => c.blueprint !== null && inside(c.blueprint.from))
|
|
332
|
+
?? plan.contexts.find((c) => c.blueprint === null && inside(c.fromEpicDir)) ?? null;
|
|
333
|
+
};
|
|
334
|
+
// 1. 텍스트 수준 치환: 상대 링크 → 전체 경로 문자열 → (context 문서만) 번호 형식 안정 ID → moved_to 등록 slug 안정 ID.
|
|
335
|
+
const staged = new Map();
|
|
336
|
+
for (const [oldRel, original] of originals) {
|
|
337
|
+
const newRel = mapPath(oldRel);
|
|
338
|
+
let text = original
|
|
339
|
+
.replace(/(\]\()([^)\s]+)((?:\s+"[^"]*")?\))/g, (_m, open, target, close) => (`${open}${rewriteLinkTarget(target, oldRel, newRel, mapPath)}${close}`))
|
|
340
|
+
.replace(/^(\[[^\]\n]+\]:\s*)(\S+)/gm, (_m, open, target) => (`${open}${rewriteLinkTarget(target, oldRel, newRel, mapPath)}`));
|
|
341
|
+
if (pathRe !== null)
|
|
342
|
+
text = text.replace(pathRe, (hit) => mapPath(hit));
|
|
343
|
+
if (oldRel.startsWith(`${CONTEXT_ROOT}/`)) {
|
|
344
|
+
const normalize = (hit) => normalizeStableId(hit, idRegistry) ?? hit;
|
|
345
|
+
text = contextIds.replaceOldStableIds(text, normalize);
|
|
346
|
+
// 번호 형식 치환 뒤에 둔다: 등록부 moved_to에 올라 있는 slug 형식 ID(`<legacy>/<bp>[/<ddd>]`)도
|
|
347
|
+
// 도메인 epic으로 바꿔야 옮긴 Explain의 task_commits·`###` 제목이 epic_id와 일치한다.
|
|
348
|
+
text = contextIds.replaceMovedSlugStableIds(text, idRegistry, normalize);
|
|
349
|
+
}
|
|
350
|
+
staged.set(oldRel, { oldRel, newRel, original, text, ctx: contextOf(oldRel) });
|
|
351
|
+
}
|
|
352
|
+
// 2. 번들 색인: epic 행의 라벨·링크를 새 형식으로 다시 만든다(S13은 링크와 요약만 비교하지만 라벨도 맞춘다).
|
|
353
|
+
const indexEntry = staged.get(CONTEXT_INDEX_REL);
|
|
354
|
+
if (indexEntry) {
|
|
355
|
+
for (const ctx of plan.contexts.filter((c) => c.blueprint === null)) {
|
|
356
|
+
const row = new RegExp(`^\\* \\[[^\\]\\n]*\\]\\(epics/${escapeRegExp(ctx.epicSlug)}/index\\.md\\) - (.*)$`, 'm');
|
|
357
|
+
indexEntry.text = indexEntry.text.replace(row, (_m, description) => (formatEpicIndexLine({ name: ctx.epicSlug, description })));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// 3. 이관 대상 문서의 frontmatter·파생 해시. 계획 문서(epic index·blueprint index·tasks.md)를 먼저
|
|
361
|
+
// 확정해야 digest·brief hash를 계산할 수 있으므로 계획 문서와 증적 문서를 나눠 처리한다.
|
|
362
|
+
const isPlanDoc = (e) => {
|
|
363
|
+
if (e.ctx === null)
|
|
364
|
+
return false;
|
|
365
|
+
return e.newRel === `${e.ctx.toEpicDir}/index.md`
|
|
366
|
+
|| e.newRel === `${e.ctx.blueprint?.to}/index.md`
|
|
367
|
+
|| TASK_DOC_RE.test(e.newRel);
|
|
368
|
+
};
|
|
369
|
+
const evidencePairs = new Map();
|
|
370
|
+
const docIds = new Map();
|
|
371
|
+
for (const entry of staged.values()) {
|
|
372
|
+
if (entry.ctx === null)
|
|
373
|
+
continue;
|
|
374
|
+
const done = restructure(entry.original, entry.text, entry.ctx, entry.newRel);
|
|
375
|
+
entry.text = done.text;
|
|
376
|
+
docIds.set(entry.oldRel, done.ids);
|
|
377
|
+
if (done.evidence)
|
|
378
|
+
evidencePairs.set(done.evidence[0], done.evidence[1]);
|
|
379
|
+
}
|
|
380
|
+
// 4. blueprint별 해시 쌍(계획 digest, task brief hash)을 계산해 증적 문서에 반영한다.
|
|
381
|
+
const newTextByRel = new Map([...staged.values()].map((e) => [e.newRel, e.text]));
|
|
382
|
+
const oldTextByRel = originals;
|
|
383
|
+
for (const ctx of plan.contexts) {
|
|
384
|
+
const bp = ctx.blueprint;
|
|
385
|
+
if (bp === null)
|
|
386
|
+
continue;
|
|
387
|
+
const taskNumbers = [...staged.values()]
|
|
388
|
+
.map((e) => TASK_DOC_RE.exec(e.oldRel))
|
|
389
|
+
.filter((m) => m !== null && m[1] === bp.from)
|
|
390
|
+
.map((m) => m[2]).sort();
|
|
391
|
+
const pairs = new Map();
|
|
392
|
+
const oldDigest = planDigestOf(`${ctx.fromEpicDir}/index.md`, bp.from, taskNumbers, (rel) => oldTextByRel.get(rel));
|
|
393
|
+
const newDigest = planDigestOf(`${ctx.toEpicDir}/index.md`, bp.to, taskNumbers, (rel) => newTextByRel.get(rel));
|
|
394
|
+
if (oldDigest !== null && newDigest !== null && oldDigest !== newDigest)
|
|
395
|
+
pairs.set(oldDigest, newDigest);
|
|
396
|
+
for (const n of taskNumbers) {
|
|
397
|
+
const oldHash = taskBriefHash(oldTextByRel.get(`${bp.from}/tasks/${n}/tasks.md`));
|
|
398
|
+
const newHash = taskBriefHash(newTextByRel.get(`${bp.to}/tasks/${n}/tasks.md`));
|
|
399
|
+
if (oldHash !== newHash)
|
|
400
|
+
pairs.set(oldHash, newHash);
|
|
401
|
+
}
|
|
402
|
+
for (const entry of staged.values()) {
|
|
403
|
+
if (entry.ctx !== ctx || isPlanDoc(entry))
|
|
404
|
+
continue;
|
|
405
|
+
const local = new Map([...pairs, ...evidencePairs, ...(docIds.get(entry.oldRel) ?? [])]);
|
|
406
|
+
entry.text = replaceAllOnce(entry.text, local);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return [...staged.values()].map(({ oldRel, newRel, original, text }) => ({ oldRel, newRel, original, text }));
|
|
410
|
+
}
|
|
411
|
+
/** 임시 파일에 쓰고 rename해 등록부가 반쯤 써진 채 남지 않게 한다(주입한 fs로 쓴다). */
|
|
412
|
+
function writeRegistryAtomically(fsApi, repoRoot, registry) {
|
|
413
|
+
const file = path.join(repoRoot, REGISTRY_REL);
|
|
414
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
415
|
+
fsApi.writeFileSync(temporary, serializeEpicRegistry(registry));
|
|
416
|
+
fsApi.renameSync(temporary, file);
|
|
417
|
+
}
|
|
418
|
+
module.exports = {
|
|
419
|
+
defaultRunGit, defaultListInProgress, makePathMapper, rewriteLinkTarget,
|
|
420
|
+
replaceAllOnce, planDigestOf, restructure, computeRewrites, writeRegistryAtomically,
|
|
421
|
+
};
|