@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,936 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const guards = require("./guards");
|
|
3
|
+
const { isFsCode, isSkippableFsError } = guards;
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { createHash } = require('node:crypto');
|
|
7
|
+
const { execFileSync: realExecFileSync } = require('node:child_process');
|
|
8
|
+
const runtimeState = require("./runtime-state");
|
|
9
|
+
const { intentBundlePathFor } = runtimeState;
|
|
10
|
+
const frontmatter = require("./frontmatter");
|
|
11
|
+
const { readDoc } = frontmatter;
|
|
12
|
+
const commitSha = require("./commit-sha");
|
|
13
|
+
const { buildStableProvenance } = commitSha;
|
|
14
|
+
const paths = require("./paths");
|
|
15
|
+
const { toPosix, parsePathIds } = paths;
|
|
16
|
+
const symbolIndex = require("./symbol-index");
|
|
17
|
+
const { resolveSymbol } = symbolIndex;
|
|
18
|
+
const taskBriefHashMod = require("./task-brief-hash");
|
|
19
|
+
const { taskBriefHash } = taskBriefHashMod;
|
|
20
|
+
const intentProvenance = require("./intent-provenance");
|
|
21
|
+
const { resolveIntentProvenance: defaultResolveIntentProvenance, projectExplainSectionHashes, projectExplainSectionBodies, isCanonicalExplainPath, } = intentProvenance;
|
|
22
|
+
const contextIds = require("./context-ids");
|
|
23
|
+
const { parseContextPath, parseStableId } = contextIds;
|
|
24
|
+
// 번호·slug 두 경로 형식을 모두 받되 세그먼트 해석은 parseContextPath 한 곳에 맡긴다.
|
|
25
|
+
const TASK_SHAPE_RE = /^\.bouncer\/context\/epics\/[^/]+\/blueprints\/[^/]+\/tasks\/\d{3}\/tasks\.md$/;
|
|
26
|
+
const HASH_RE = /^[a-f0-9]{64}$/;
|
|
27
|
+
/** 번호·slug 형식의 canonical tasks.md 경로인지 판정한다. */
|
|
28
|
+
function isCanonicalTaskPath(rel) {
|
|
29
|
+
if (!TASK_SHAPE_RE.test(rel))
|
|
30
|
+
return false;
|
|
31
|
+
const ids = parseContextPath(rel);
|
|
32
|
+
return ids !== null && ids.blueprintKey !== null;
|
|
33
|
+
}
|
|
34
|
+
/** 번호·slug 형식의 task stable ID(`task` 번호까지 있는 것)인지 판정한다. */
|
|
35
|
+
function isStableTaskId(value) {
|
|
36
|
+
const parsed = parseStableId(value);
|
|
37
|
+
return parsed !== null && parsed.task !== null;
|
|
38
|
+
}
|
|
39
|
+
const COMMIT_RE = /^[0-9a-f]{40}$/;
|
|
40
|
+
const FRESHNESS = new Set(['current', 'related', 'possibly-superseded', 'historical']);
|
|
41
|
+
// 역할 집합은 CLI와 같은 상수다. Explain Background·Intuition·Code는 여기 없으므로
|
|
42
|
+
// selectSections 결과에서 자동으로 빠진다.
|
|
43
|
+
const IMPLEMENTER_SECTION_NAMES = [
|
|
44
|
+
'Goal & intent',
|
|
45
|
+
'Current behavior',
|
|
46
|
+
'Target behavior',
|
|
47
|
+
'Interface',
|
|
48
|
+
'Touch',
|
|
49
|
+
'Constraints',
|
|
50
|
+
];
|
|
51
|
+
const REVIEWER_DEBUGGER_SECTION_NAMES = [
|
|
52
|
+
'Goal & intent',
|
|
53
|
+
'Interface',
|
|
54
|
+
'Touch',
|
|
55
|
+
'Constraints',
|
|
56
|
+
];
|
|
57
|
+
const ROLE_SECTION_NAMES = {
|
|
58
|
+
implementer: IMPLEMENTER_SECTION_NAMES,
|
|
59
|
+
reviewer: REVIEWER_DEBUGGER_SECTION_NAMES,
|
|
60
|
+
debugger: REVIEWER_DEBUGGER_SECTION_NAMES,
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* 실행 Task와 함수 집합의 intent bundle을 내용 주소로 재사용하거나 새로 기록한다.
|
|
64
|
+
* function ref·blob SHA·non-historical Explain 절 hash가 모두 같으면 Git provenance
|
|
65
|
+
* resolver를 건너뛰고, 하나라도 다르거나 cache가 손상되면 원자적으로 다시 쓴다.
|
|
66
|
+
* resolver가 중간에 실패하면 이전 record를 덮어쓰지 않는다.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} input - 조회·기록 입력
|
|
69
|
+
* @param {string} input.repoRoot - 저장소 루트
|
|
70
|
+
* @param {string} input.taskFile - repo-relative canonical tasks.md
|
|
71
|
+
* @param {FunctionRequest[]} input.functions - 중복·빈 이름 없는 함수 요청
|
|
72
|
+
* @param {boolean} [input.allowEmpty] - true일 때만 빈 `functions`를 허용한다(함수 없는 task용)
|
|
73
|
+
* @param {BundleDeps} [input.deps] - fs·Git·resolver 주입(테스트용)
|
|
74
|
+
* @returns {BundleResult} created | reused 와 bundle_id·revision·function entries
|
|
75
|
+
*/
|
|
76
|
+
function resolveTaskIntentBundle(input) {
|
|
77
|
+
const deps = input.deps || {};
|
|
78
|
+
const io = { ...fs, ...(deps.fs || {}) };
|
|
79
|
+
const execFileSync = deps.execFileSync || realExecFileSync;
|
|
80
|
+
const runProvenance = deps.resolveIntentProvenance || defaultResolveIntentProvenance;
|
|
81
|
+
const runSymbol = deps.resolveSymbol || resolveSymbol;
|
|
82
|
+
// 1. 요청·경로·stable ID를 먼저 고정한다. 잘못된 입력이면 cache를 읽지도 않는다.
|
|
83
|
+
const requests = normalizeFunctionRequests(input.functions, input.allowEmpty === true);
|
|
84
|
+
const { repoReal, taskRel, stableTask, taskBriefHash } = loadExecutionTask({
|
|
85
|
+
repoRoot: input.repoRoot,
|
|
86
|
+
taskFile: input.taskFile,
|
|
87
|
+
fs: io,
|
|
88
|
+
});
|
|
89
|
+
const located = intentBundlePathFor({
|
|
90
|
+
repoRoot: repoReal,
|
|
91
|
+
taskRel,
|
|
92
|
+
deps: { execFileSync, platform: deps.platform },
|
|
93
|
+
});
|
|
94
|
+
if (located.unavailable || !located.intentFile || !located.commonGitDir) {
|
|
95
|
+
throw new Error(located.reason || 'Git common directory unavailable');
|
|
96
|
+
}
|
|
97
|
+
const intentFile = located.intentFile;
|
|
98
|
+
// cache 경로가 common dir 밖 symlink로 새면 읽기·쓰기가 저장소 밖으로 나간다.
|
|
99
|
+
assertIntentPathInCommonDir(intentFile, located.commonGitDir, io, { createDir: false });
|
|
100
|
+
// 2. 현재 함수 좌표(Git 없이)를 먼저 모은다. hit 판정과 miss 기록이 같은 ref를 쓴다.
|
|
101
|
+
const liveCoords = requests.map((req) => resolveLiveCoordinate({
|
|
102
|
+
repoRoot: repoReal,
|
|
103
|
+
request: req,
|
|
104
|
+
resolveSymbol: runSymbol,
|
|
105
|
+
}));
|
|
106
|
+
const previous = readValidBundleRecord({
|
|
107
|
+
intentFile,
|
|
108
|
+
expectedTask: stableTask,
|
|
109
|
+
fs: io,
|
|
110
|
+
});
|
|
111
|
+
if (previous && previous.task_brief_hash === taskBriefHash
|
|
112
|
+
&& isCacheHit(previous, liveCoords, repoReal, io)) {
|
|
113
|
+
return {
|
|
114
|
+
status: 'reused',
|
|
115
|
+
bundle_id: previous.bundle_id,
|
|
116
|
+
revision: previous.revision,
|
|
117
|
+
task: stableTask,
|
|
118
|
+
task_brief_hash: taskBriefHash,
|
|
119
|
+
functions: previous.functions,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// 3. miss — 전체 함수를 resolver로 채운 뒤에만 쓴다. 부분 record를 남기지 않는다.
|
|
123
|
+
const entries = [];
|
|
124
|
+
for (let i = 0; i < requests.length; i += 1) {
|
|
125
|
+
entries.push(buildFunctionEntry({
|
|
126
|
+
repoRoot: repoReal,
|
|
127
|
+
request: requests[i],
|
|
128
|
+
live: liveCoords[i],
|
|
129
|
+
resolveIntentProvenance: runProvenance,
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
entries.sort(compareEntries);
|
|
133
|
+
const bundleId = contentBundleId(entries);
|
|
134
|
+
const revision = previous && Number.isInteger(previous.revision) && previous.revision > 0
|
|
135
|
+
? previous.revision + 1
|
|
136
|
+
: 1;
|
|
137
|
+
const record = {
|
|
138
|
+
version: 1,
|
|
139
|
+
task: stableTask,
|
|
140
|
+
task_brief_hash: taskBriefHash,
|
|
141
|
+
revision,
|
|
142
|
+
bundle_id: bundleId,
|
|
143
|
+
functions: entries,
|
|
144
|
+
};
|
|
145
|
+
// write 직전에 부모를 만들고 실경로 경계를 다시 고정한다.
|
|
146
|
+
assertIntentPathInCommonDir(intentFile, located.commonGitDir, io, { createDir: true });
|
|
147
|
+
atomicWriteJson(intentFile, record, io);
|
|
148
|
+
return {
|
|
149
|
+
status: 'created',
|
|
150
|
+
bundle_id: bundleId,
|
|
151
|
+
revision,
|
|
152
|
+
task: stableTask,
|
|
153
|
+
task_brief_hash: taskBriefHash,
|
|
154
|
+
functions: entries,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 빈·중복 symbol과 빈 candidateRef를 거절하고 요청 순서를 유지한 목록을 돌려준다.
|
|
159
|
+
* 정렬은 기록 단계에서만 한다 — 호출 순서를 바꿔 중복 판정을 숨기지 않는다.
|
|
160
|
+
*
|
|
161
|
+
* @param {unknown} functions - 호출자 함수 요청
|
|
162
|
+
* @param {boolean} allowEmpty - true면 빈 배열을 허용한다. 기본 거절을 유지해
|
|
163
|
+
* 실수로 빈 요청이 가짜 "함수 없음" bundle로 기록되지 않게 한다.
|
|
164
|
+
* @returns {FunctionRequest[]} 정규화된 요청
|
|
165
|
+
*/
|
|
166
|
+
function normalizeFunctionRequests(functions, allowEmpty) {
|
|
167
|
+
if (!Array.isArray(functions) || (functions.length === 0 && !allowEmpty)) {
|
|
168
|
+
throw new Error('functions must be a non-empty array');
|
|
169
|
+
}
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const entry of functions) {
|
|
173
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
174
|
+
throw new Error('function request must be an object');
|
|
175
|
+
}
|
|
176
|
+
const rec = entry;
|
|
177
|
+
if (typeof rec.symbol !== 'string' || rec.symbol.trim() === '') {
|
|
178
|
+
throw new Error('function symbol must be a non-empty string');
|
|
179
|
+
}
|
|
180
|
+
const symbol = rec.symbol.trim();
|
|
181
|
+
if (seen.has(symbol)) {
|
|
182
|
+
throw new Error(`duplicate function symbol: ${symbol}`);
|
|
183
|
+
}
|
|
184
|
+
seen.add(symbol);
|
|
185
|
+
let candidateRef;
|
|
186
|
+
if (rec.candidateRef !== undefined && rec.candidateRef !== null) {
|
|
187
|
+
if (typeof rec.candidateRef !== 'string' || rec.candidateRef.length === 0) {
|
|
188
|
+
throw new Error('incomplete candidate ref');
|
|
189
|
+
}
|
|
190
|
+
candidateRef = rec.candidateRef;
|
|
191
|
+
}
|
|
192
|
+
out.push(candidateRef === undefined ? { symbol } : { symbol, candidateRef });
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* canonical tasks.md만 받고 stable Task ID와 brief hash를 만든다.
|
|
198
|
+
* symlink가 저장소 밖으로 나가면 거절한다 — cache 경로를 외부 파일에 묶지 않는다.
|
|
199
|
+
* brief hash는 원본 바이트가 아니라 taskBriefHash(UTF-8)다. dispatch·record와 같은
|
|
200
|
+
* 정규형이어야 intent-bundle-stale 판정이 lifecycle 키만으로 갈라지지 않는다.
|
|
201
|
+
*
|
|
202
|
+
* @param {object} input - 경로와 fs
|
|
203
|
+
* @returns {{ repoReal: string, taskRel: string, taskAbs: string, stableTask: string, taskBriefHash: string }}
|
|
204
|
+
*/
|
|
205
|
+
function loadExecutionTask(input) {
|
|
206
|
+
if (typeof input.taskFile !== 'string' || input.taskFile.trim() === '') {
|
|
207
|
+
throw new Error('taskFile must be a non-empty path');
|
|
208
|
+
}
|
|
209
|
+
const taskRel = toPosix(input.taskFile);
|
|
210
|
+
if (path.isAbsolute(taskRel) || taskRel.includes('..')) {
|
|
211
|
+
throw new Error('taskFile must be a repo-relative canonical tasks.md path');
|
|
212
|
+
}
|
|
213
|
+
if (!isCanonicalTaskPath(taskRel)) {
|
|
214
|
+
throw new Error('taskFile is not a canonical task layout path');
|
|
215
|
+
}
|
|
216
|
+
const repoReal = input.fs.realpathSync(input.repoRoot);
|
|
217
|
+
const taskAbs = path.resolve(repoReal, taskRel);
|
|
218
|
+
let taskReal;
|
|
219
|
+
try {
|
|
220
|
+
taskReal = input.fs.realpathSync(taskAbs);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
throw new Error('taskFile is not readable inside the repository', { cause: error });
|
|
224
|
+
}
|
|
225
|
+
if (!isInsideRepo(taskReal, repoReal)) {
|
|
226
|
+
throw new Error('taskFile escapes the repository boundary');
|
|
227
|
+
}
|
|
228
|
+
const doc = readDoc(taskAbs);
|
|
229
|
+
const data = doc.data;
|
|
230
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
231
|
+
throw new Error('task brief frontmatter is invalid');
|
|
232
|
+
}
|
|
233
|
+
const bouncer = data.bouncer;
|
|
234
|
+
if (!bouncer || typeof bouncer !== 'object' || Array.isArray(bouncer)) {
|
|
235
|
+
throw new Error('task brief bouncer block is invalid');
|
|
236
|
+
}
|
|
237
|
+
const block = bouncer;
|
|
238
|
+
const pathIds = parsePathIds(taskRel);
|
|
239
|
+
if (!pathIds.epicId || !pathIds.blueprintId || pathIds.kind !== 'tasks') {
|
|
240
|
+
throw new Error('taskFile is not a canonical task layout path');
|
|
241
|
+
}
|
|
242
|
+
// 경로 id와 frontmatter id가 어긋나면 다른 Task의 cache를 소유자로 삼는다.
|
|
243
|
+
if (String(block.epic_id || '') !== pathIds.epicId
|
|
244
|
+
|| String(block.blueprint_id || '') !== pathIds.blueprintId) {
|
|
245
|
+
throw new Error('task brief ids do not match the canonical path');
|
|
246
|
+
}
|
|
247
|
+
const stable = buildStableProvenance({
|
|
248
|
+
epicId: block.epic_id,
|
|
249
|
+
blueprintId: block.blueprint_id,
|
|
250
|
+
taskId: block.id,
|
|
251
|
+
});
|
|
252
|
+
const raw = input.fs.readFileSync(taskAbs);
|
|
253
|
+
const markdown = typeof raw === 'string' ? raw : raw.toString('utf8');
|
|
254
|
+
return {
|
|
255
|
+
repoReal,
|
|
256
|
+
taskRel,
|
|
257
|
+
taskAbs,
|
|
258
|
+
stableTask: stable.task,
|
|
259
|
+
taskBriefHash: taskBriefHash(markdown),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Git 없이 현재 checkout 정의만 고른다. hit 판정이 blame/log를 치면 안 되므로
|
|
264
|
+
* symbol-index만 사용한다.
|
|
265
|
+
*
|
|
266
|
+
* @param {object} input - 저장소·요청·resolver
|
|
267
|
+
* @returns {LiveCoordinate} 현재 함수 좌표 또는 비연결 상태
|
|
268
|
+
*/
|
|
269
|
+
function resolveLiveCoordinate(input) {
|
|
270
|
+
const selected = input.resolveSymbol({
|
|
271
|
+
repoRoot: input.repoRoot,
|
|
272
|
+
symbol: input.request.symbol,
|
|
273
|
+
candidateRef: input.request.candidateRef,
|
|
274
|
+
});
|
|
275
|
+
if (selected.status === 'ambiguous') {
|
|
276
|
+
// candidate 없는 동명은 incomplete selection이다. bundle에 부분 entry를 쓰지 않는다.
|
|
277
|
+
throw new Error(`ambiguous symbol requires candidate ref: ${input.request.symbol}`);
|
|
278
|
+
}
|
|
279
|
+
if (selected.status === 'unresolved') {
|
|
280
|
+
return { status: 'unresolved', symbol: input.request.symbol, payload: selected };
|
|
281
|
+
}
|
|
282
|
+
const symbolRef = selected.symbol_ref;
|
|
283
|
+
// function_ref는 symbol-index가 이미 만든 opaque candidate_ref와 같다.
|
|
284
|
+
// 호출자가 candidateRef를 넘겼으면 그 값을 그대로 쓰고, 없으면 이미 고른
|
|
285
|
+
// symbol_ref 좌표만 해시한 값으로 둔다 — makeCandidateRef 복제본을 따로
|
|
286
|
+
// 유지하지 않아 양쪽 필드 목록이 어긋날 여지를 없앤다.
|
|
287
|
+
const function_ref = typeof input.request.candidateRef === 'string'
|
|
288
|
+
&& input.request.candidateRef.length > 0
|
|
289
|
+
? input.request.candidateRef
|
|
290
|
+
: hashSelectedSymbolRef(symbolRef);
|
|
291
|
+
return {
|
|
292
|
+
status: 'resolved',
|
|
293
|
+
symbol: input.request.symbol,
|
|
294
|
+
function_ref,
|
|
295
|
+
symbol_ref: symbolRef,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* resolveSymbol이 고른 symbol_ref 좌표만으로 opaque function ref를 만든다.
|
|
300
|
+
* 호출자 candidateRef가 없을 때의 bundle 내부 identity이며, symbol-index
|
|
301
|
+
* makeCandidateRef를 별도 복제해 동기화하지 않는다.
|
|
302
|
+
*
|
|
303
|
+
* @param {SymbolRef} symbolRef - resolveSymbol이 반환한 현재 정의 좌표
|
|
304
|
+
* @returns {string} SHA-256 hex
|
|
305
|
+
*/
|
|
306
|
+
function hashSelectedSymbolRef(symbolRef) {
|
|
307
|
+
const payload = [
|
|
308
|
+
symbolRef.path,
|
|
309
|
+
symbolRef.qualified_name,
|
|
310
|
+
symbolRef.kind,
|
|
311
|
+
String(symbolRef.start_line),
|
|
312
|
+
String(symbolRef.end_line),
|
|
313
|
+
].join('\0');
|
|
314
|
+
return createHash('sha256').update(payload).digest('hex');
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* cache JSON을 읽고 shape·hash·stable ID를 검증한다. 하나라도 깨지면 null —
|
|
318
|
+
* 손상 cache를 hit로 인정하지 않고 현재 입력으로 재생성한다.
|
|
319
|
+
*
|
|
320
|
+
* @param {object} input - 파일·기대 Task·fs
|
|
321
|
+
* @returns {BundleRecord | null} 유효 record 또는 null
|
|
322
|
+
*/
|
|
323
|
+
function readValidBundleRecord(input) {
|
|
324
|
+
if (!input.fs.existsSync(input.intentFile))
|
|
325
|
+
return null;
|
|
326
|
+
let raw;
|
|
327
|
+
try {
|
|
328
|
+
const bytes = input.fs.readFileSync(input.intentFile, 'utf8');
|
|
329
|
+
raw = typeof bytes === 'string' ? bytes : bytes.toString('utf8');
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
// 읽기 실패는 miss로만 접는다. 권한 오류를 throw하면 재생성 경로가 막힌다.
|
|
333
|
+
if (isFsCode(error, 'ENOENT') || isFsCode(error, 'EACCES') || isFsCode(error, 'EPERM')) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
let parsed;
|
|
339
|
+
try {
|
|
340
|
+
parsed = JSON.parse(raw);
|
|
341
|
+
}
|
|
342
|
+
catch (_e) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
if (!isValidBundleRecord(parsed, input.expectedTask))
|
|
346
|
+
return null;
|
|
347
|
+
return parsed;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* record shape와 content hash 재계산이 맞는지 검사한다.
|
|
351
|
+
* cache body는 data이므로 여기서 통과한 값만 hit 후보가 된다.
|
|
352
|
+
*
|
|
353
|
+
* @param {unknown} value - 파싱된 JSON
|
|
354
|
+
* @param {string} expectedTask - 현재 실행 Task stable ID
|
|
355
|
+
* @returns {boolean} 유효하면 true
|
|
356
|
+
*/
|
|
357
|
+
function isValidBundleRecord(value, expectedTask) {
|
|
358
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
359
|
+
return false;
|
|
360
|
+
const rec = value;
|
|
361
|
+
if (rec.version !== 1)
|
|
362
|
+
return false;
|
|
363
|
+
if (rec.task !== expectedTask || !isStableTaskId(rec.task))
|
|
364
|
+
return false;
|
|
365
|
+
if (typeof rec.task_brief_hash !== 'string' || !HASH_RE.test(rec.task_brief_hash))
|
|
366
|
+
return false;
|
|
367
|
+
if (typeof rec.revision !== 'number' || !Number.isInteger(rec.revision) || rec.revision < 1) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
if (typeof rec.bundle_id !== 'string' || !HASH_RE.test(rec.bundle_id))
|
|
371
|
+
return false;
|
|
372
|
+
if (!Array.isArray(rec.functions))
|
|
373
|
+
return false;
|
|
374
|
+
const entries = [];
|
|
375
|
+
for (const entry of rec.functions) {
|
|
376
|
+
if (!isValidFunctionEntry(entry))
|
|
377
|
+
return false;
|
|
378
|
+
entries.push(entry);
|
|
379
|
+
}
|
|
380
|
+
// 저장 id가 현재 정규화 content와 다르면 조작·부분 write다. hit로 보지 않는다.
|
|
381
|
+
if (contentBundleId(entries) !== rec.bundle_id)
|
|
382
|
+
return false;
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
function isValidFunctionEntry(value) {
|
|
386
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
387
|
+
return false;
|
|
388
|
+
const entry = value;
|
|
389
|
+
if (typeof entry.symbol !== 'string' || entry.symbol.trim() === '')
|
|
390
|
+
return false;
|
|
391
|
+
if (entry.status === 'unresolved') {
|
|
392
|
+
return Object.keys(entry).every((key) => key === 'symbol' || key === 'status');
|
|
393
|
+
}
|
|
394
|
+
if (entry.status === 'ambiguous') {
|
|
395
|
+
return Array.isArray(entry.candidates);
|
|
396
|
+
}
|
|
397
|
+
if (entry.status !== 'resolved' && entry.status !== 'unlinked')
|
|
398
|
+
return false;
|
|
399
|
+
if (typeof entry.function_ref !== 'string' || !HASH_RE.test(entry.function_ref))
|
|
400
|
+
return false;
|
|
401
|
+
if (typeof entry.blob_sha !== 'string' || !/^[0-9a-f]{40}$/.test(entry.blob_sha))
|
|
402
|
+
return false;
|
|
403
|
+
if (!isValidSymbolRef(entry.symbol_ref))
|
|
404
|
+
return false;
|
|
405
|
+
// top-level blob과 symbol_ref.blob이 어긋나면 조작·부분 write다. hit로 보지 않는다.
|
|
406
|
+
if (entry.blob_sha !== entry.symbol_ref.blob_sha)
|
|
407
|
+
return false;
|
|
408
|
+
if (entry.status === 'unlinked') {
|
|
409
|
+
return entry.provenance === undefined;
|
|
410
|
+
}
|
|
411
|
+
return isValidProvenance(entry.provenance);
|
|
412
|
+
}
|
|
413
|
+
function isValidSymbolRef(value) {
|
|
414
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
415
|
+
return false;
|
|
416
|
+
const ref = value;
|
|
417
|
+
return typeof ref.path === 'string'
|
|
418
|
+
&& typeof ref.qualified_name === 'string'
|
|
419
|
+
&& typeof ref.kind === 'string'
|
|
420
|
+
&& typeof ref.start_line === 'number'
|
|
421
|
+
&& typeof ref.end_line === 'number'
|
|
422
|
+
&& typeof ref.blob_sha === 'string'
|
|
423
|
+
&& /^[0-9a-f]{40}$/.test(ref.blob_sha);
|
|
424
|
+
}
|
|
425
|
+
function isValidProvenance(value) {
|
|
426
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
427
|
+
return false;
|
|
428
|
+
const prov = value;
|
|
429
|
+
if (!isStableTaskId(prov.task))
|
|
430
|
+
return false;
|
|
431
|
+
if (!COMMIT_RE.test(String(prov.commit || '')))
|
|
432
|
+
return false;
|
|
433
|
+
if (typeof prov.explain !== 'string' || prov.explain.length === 0)
|
|
434
|
+
return false;
|
|
435
|
+
// cache body는 data다. 임의 경로를 Explain 읽기로 승격하지 않도록 canonical만 받는다.
|
|
436
|
+
if (!isCanonicalExplainRel(prov.explain))
|
|
437
|
+
return false;
|
|
438
|
+
if (typeof prov.freshness !== 'string' || !FRESHNESS.has(prov.freshness))
|
|
439
|
+
return false;
|
|
440
|
+
if (typeof prov.relation !== 'string' || (prov.relation !== 'blame' && prov.relation !== 'follow')) {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
if (!Array.isArray(prov.sections))
|
|
444
|
+
return false;
|
|
445
|
+
// historical은 절 hash를 저장하지 않는다. 다른 freshness는 절마다 name·hash가 필요하다.
|
|
446
|
+
if (prov.freshness === 'historical') {
|
|
447
|
+
return prov.sections.length === 0;
|
|
448
|
+
}
|
|
449
|
+
for (const section of prov.sections) {
|
|
450
|
+
if (!section || typeof section !== 'object' || Array.isArray(section))
|
|
451
|
+
return false;
|
|
452
|
+
const row = section;
|
|
453
|
+
if (typeof row.name !== 'string' || row.name.length === 0)
|
|
454
|
+
return false;
|
|
455
|
+
if (typeof row.hash !== 'string' || !HASH_RE.test(row.hash))
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* 저장된 function ref·blob·절 hash가 현재 checkout과 같은지 검사한다.
|
|
462
|
+
* 같으면 provenance Task·commit·freshness를 재사용하고 Git resolver를 건너뛴다.
|
|
463
|
+
*
|
|
464
|
+
* @param {BundleRecord} record - 검증된 cache
|
|
465
|
+
* @param {LiveCoordinate[]} liveCoords - 현재 함수 좌표
|
|
466
|
+
* @param {string} repoReal - realpath 루트
|
|
467
|
+
* @param {InjectedFs} io - realpath·읽기에 쓸 fs (테스트 주입)
|
|
468
|
+
* @returns {boolean} hit이면 true
|
|
469
|
+
*/
|
|
470
|
+
function isCacheHit(record, liveCoords, repoReal, io) {
|
|
471
|
+
if (record.functions.length !== liveCoords.length)
|
|
472
|
+
return false;
|
|
473
|
+
const bySymbol = new Map(record.functions.map((entry) => [entry.symbol, entry]));
|
|
474
|
+
// 요청 집합이 다르면 miss. 정렬 전 live 순서와 기록 순서가 달라도 symbol로 맞춘다.
|
|
475
|
+
for (const live of liveCoords) {
|
|
476
|
+
const stored = bySymbol.get(live.symbol);
|
|
477
|
+
if (!stored)
|
|
478
|
+
return false;
|
|
479
|
+
bySymbol.delete(live.symbol);
|
|
480
|
+
if (live.status === 'unresolved') {
|
|
481
|
+
if (stored.status !== 'unresolved')
|
|
482
|
+
return false;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (live.status !== 'resolved')
|
|
486
|
+
return false;
|
|
487
|
+
if (stored.status !== 'resolved' && stored.status !== 'unlinked')
|
|
488
|
+
return false;
|
|
489
|
+
if (stored.function_ref !== live.function_ref)
|
|
490
|
+
return false;
|
|
491
|
+
if (stored.blob_sha !== live.symbol_ref.blob_sha)
|
|
492
|
+
return false;
|
|
493
|
+
if (stored.status === 'unlinked')
|
|
494
|
+
continue;
|
|
495
|
+
if (!sectionHashesMatch(stored.provenance, repoReal, io))
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
return bySymbol.size === 0;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* non-historical provenance의 Explain 절 hash를 지금 다시 계산해 비교한다.
|
|
502
|
+
* historical은 절을 저장하지 않으므로 canonical 경로·repo 경계만 확인한다.
|
|
503
|
+
*
|
|
504
|
+
* @param {ProvenanceFields} provenance - 저장된 provenance
|
|
505
|
+
* @param {string} repoReal - realpath 루트
|
|
506
|
+
* @param {InjectedFs} io - Explain 경계 확인용 fs
|
|
507
|
+
* @returns {boolean} 절 hash가 일치하면 true
|
|
508
|
+
*/
|
|
509
|
+
function sectionHashesMatch(provenance, repoReal, io) {
|
|
510
|
+
// cache가 지시하는 explain 경로는 live resolve와 같은 allowlist를 통과해야 한다.
|
|
511
|
+
if (!isCanonicalExplainRel(provenance.explain))
|
|
512
|
+
return false;
|
|
513
|
+
if (provenance.freshness === 'historical') {
|
|
514
|
+
return provenance.sections.length === 0
|
|
515
|
+
&& explainInsideRepo(repoReal, provenance.explain, io);
|
|
516
|
+
}
|
|
517
|
+
const projected = projectExplainSectionHashes({
|
|
518
|
+
repoRoot: repoReal,
|
|
519
|
+
explainRel: provenance.explain,
|
|
520
|
+
task: provenance.task,
|
|
521
|
+
});
|
|
522
|
+
if (!projected)
|
|
523
|
+
return false;
|
|
524
|
+
if (projected.length !== provenance.sections.length)
|
|
525
|
+
return false;
|
|
526
|
+
for (let i = 0; i < projected.length; i += 1) {
|
|
527
|
+
if (projected[i].name !== provenance.sections[i].name)
|
|
528
|
+
return false;
|
|
529
|
+
if (projected[i].hash !== provenance.sections[i].hash)
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Explain 실경로가 저장소 안에 있는지 주입 fs로만 본다.
|
|
536
|
+
* module-level fs를 쓰면 테스트·대체 루트 주입이 historical 경계에서 깨진다.
|
|
537
|
+
*
|
|
538
|
+
* @param {string} repoReal - realpath 루트
|
|
539
|
+
* @param {string} explainRel - repo-relative Explain
|
|
540
|
+
* @param {InjectedFs} io - realpathSync 제공자
|
|
541
|
+
* @returns {boolean} 저장소 안이면 true
|
|
542
|
+
*/
|
|
543
|
+
function explainInsideRepo(repoReal, explainRel, io) {
|
|
544
|
+
try {
|
|
545
|
+
const abs = path.resolve(repoReal, explainRel);
|
|
546
|
+
const real = io.realpathSync(abs);
|
|
547
|
+
return isInsideRepo(real, repoReal);
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
if (isSkippableFsError(error))
|
|
551
|
+
return false;
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* live 좌표와 provenance resolver 결과로 한 function entry를 만든다.
|
|
557
|
+
* unresolved·unlinked에는 존재하지 않는 provenance 필드를 합성하지 않는다.
|
|
558
|
+
*
|
|
559
|
+
* @param {object} input - 요청·live·resolver
|
|
560
|
+
* @returns {BundleFunctionEntry} 기록용 entry
|
|
561
|
+
*/
|
|
562
|
+
function buildFunctionEntry(input) {
|
|
563
|
+
if (input.live.status === 'unresolved') {
|
|
564
|
+
return { symbol: input.request.symbol, status: 'unresolved' };
|
|
565
|
+
}
|
|
566
|
+
if (input.live.status !== 'resolved') {
|
|
567
|
+
throw new Error(`unsupported live coordinate status for ${input.request.symbol}`);
|
|
568
|
+
}
|
|
569
|
+
const result = input.resolveIntentProvenance({
|
|
570
|
+
repoRoot: input.repoRoot,
|
|
571
|
+
symbol: input.request.symbol,
|
|
572
|
+
candidateRef: input.request.candidateRef,
|
|
573
|
+
});
|
|
574
|
+
if (result.status === 'unresolved') {
|
|
575
|
+
return { symbol: input.request.symbol, status: 'unresolved' };
|
|
576
|
+
}
|
|
577
|
+
if (result.status === 'ambiguous') {
|
|
578
|
+
throw new Error(`ambiguous symbol requires candidate ref: ${input.request.symbol}`);
|
|
579
|
+
}
|
|
580
|
+
if (result.status === 'unlinked') {
|
|
581
|
+
return {
|
|
582
|
+
symbol: input.request.symbol,
|
|
583
|
+
status: 'unlinked',
|
|
584
|
+
function_ref: input.live.function_ref,
|
|
585
|
+
blob_sha: input.live.symbol_ref.blob_sha,
|
|
586
|
+
symbol_ref: input.live.symbol_ref,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const top = result.candidates[0];
|
|
590
|
+
if (!top) {
|
|
591
|
+
// resolved인데 candidate가 비면 계약 위반이다. 부분 성공으로 쓰지 않는다.
|
|
592
|
+
throw new Error(`resolved provenance returned no candidates for ${input.request.symbol}`);
|
|
593
|
+
}
|
|
594
|
+
// cache·live 모두 같은 allowlist. non-canonical Explain은 읽기 전에 거절한다.
|
|
595
|
+
if (!isCanonicalExplainRel(top.explain)) {
|
|
596
|
+
throw new Error(`Explain path is not canonical: ${top.explain}`);
|
|
597
|
+
}
|
|
598
|
+
let sections;
|
|
599
|
+
if (top.freshness === 'historical') {
|
|
600
|
+
sections = [];
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
const projected = projectExplainSectionHashes({
|
|
604
|
+
repoRoot: input.repoRoot,
|
|
605
|
+
explainRel: top.explain,
|
|
606
|
+
task: top.task,
|
|
607
|
+
});
|
|
608
|
+
// null 을 [] 로 바꾸면 없는 Explain이 빈 절 resolved로 저장된다 — 부분 성공 금지.
|
|
609
|
+
if (!projected || projected.length === 0) {
|
|
610
|
+
throw new Error(`unable to project Explain section hashes for ${top.explain}`);
|
|
611
|
+
}
|
|
612
|
+
sections = projected;
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
symbol: input.request.symbol,
|
|
616
|
+
status: 'resolved',
|
|
617
|
+
function_ref: input.live.function_ref,
|
|
618
|
+
blob_sha: input.live.symbol_ref.blob_sha,
|
|
619
|
+
symbol_ref: input.live.symbol_ref,
|
|
620
|
+
provenance: {
|
|
621
|
+
task: top.task,
|
|
622
|
+
commit: top.commit,
|
|
623
|
+
explain: top.explain,
|
|
624
|
+
freshness: top.freshness,
|
|
625
|
+
relation: top.relation,
|
|
626
|
+
sections,
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* function entry 배열의 결정적 SHA-256. object key와 entry 순서를 정규화해
|
|
632
|
+
* 같은 내용이면 같은 bundle_id가 나오게 한다.
|
|
633
|
+
*
|
|
634
|
+
* @param {BundleFunctionEntry[]} entries - 정렬 전·후 모두 허용
|
|
635
|
+
* @returns {string} lowercase 64자리 hex
|
|
636
|
+
*/
|
|
637
|
+
function contentBundleId(entries) {
|
|
638
|
+
const normalized = [...entries].sort(compareEntries).map(canonicalizeEntry);
|
|
639
|
+
return createHash('sha256').update(JSON.stringify(normalized), 'utf8').digest('hex');
|
|
640
|
+
}
|
|
641
|
+
function compareEntries(left, right) {
|
|
642
|
+
return left.symbol < right.symbol ? -1 : left.symbol > right.symbol ? 1 : 0;
|
|
643
|
+
}
|
|
644
|
+
function canonicalizeEntry(entry) {
|
|
645
|
+
if (entry.status === 'unresolved') {
|
|
646
|
+
return { status: 'unresolved', symbol: entry.symbol };
|
|
647
|
+
}
|
|
648
|
+
if (entry.status === 'ambiguous') {
|
|
649
|
+
return { candidates: entry.candidates, status: 'ambiguous', symbol: entry.symbol };
|
|
650
|
+
}
|
|
651
|
+
const base = {
|
|
652
|
+
blob_sha: entry.blob_sha,
|
|
653
|
+
function_ref: entry.function_ref,
|
|
654
|
+
status: entry.status,
|
|
655
|
+
symbol: entry.symbol,
|
|
656
|
+
symbol_ref: {
|
|
657
|
+
blob_sha: entry.symbol_ref.blob_sha,
|
|
658
|
+
end_line: entry.symbol_ref.end_line,
|
|
659
|
+
kind: entry.symbol_ref.kind,
|
|
660
|
+
path: entry.symbol_ref.path,
|
|
661
|
+
qualified_name: entry.symbol_ref.qualified_name,
|
|
662
|
+
start_line: entry.symbol_ref.start_line,
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
if (entry.status === 'unlinked')
|
|
666
|
+
return base;
|
|
667
|
+
return {
|
|
668
|
+
...base,
|
|
669
|
+
provenance: {
|
|
670
|
+
commit: entry.provenance.commit,
|
|
671
|
+
explain: entry.provenance.explain,
|
|
672
|
+
freshness: entry.provenance.freshness,
|
|
673
|
+
relation: entry.provenance.relation,
|
|
674
|
+
sections: entry.provenance.sections.map((section) => ({
|
|
675
|
+
hash: section.hash,
|
|
676
|
+
name: section.name,
|
|
677
|
+
})),
|
|
678
|
+
task: entry.provenance.task,
|
|
679
|
+
},
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* 같은 디렉터리 tmp + rename으로 JSON을 원자적으로 쓴다.
|
|
684
|
+
* 다른 볼륨 tmp는 쓰지 않는다 — rename이 copy+unlink가 되면 부분 record가 보인다.
|
|
685
|
+
*
|
|
686
|
+
* @param {string} target - 최종 경로
|
|
687
|
+
* @param {BundleRecord} record - 기록할 bundle
|
|
688
|
+
* @param {InjectedFs} io - fs 주입
|
|
689
|
+
*/
|
|
690
|
+
function atomicWriteJson(target, record, io) {
|
|
691
|
+
const dir = path.dirname(target);
|
|
692
|
+
io.mkdirSync(dir, { recursive: true });
|
|
693
|
+
const payload = `${JSON.stringify(record, null, 2)}\n`;
|
|
694
|
+
const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
|
|
695
|
+
io.writeFileSync(tmp, payload);
|
|
696
|
+
try {
|
|
697
|
+
io.renameSync(tmp, target);
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
try {
|
|
701
|
+
io.rmSync(tmp, { force: true });
|
|
702
|
+
}
|
|
703
|
+
catch (_cleanup) {
|
|
704
|
+
// tmp 정리는 best-effort. rename 실패 원인을 가리지 않는다.
|
|
705
|
+
}
|
|
706
|
+
throw error;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
function isInsideRepo(absPath, repoReal) {
|
|
710
|
+
if (absPath === repoReal)
|
|
711
|
+
return true;
|
|
712
|
+
const prefix = repoReal.endsWith(path.sep) ? repoReal : `${repoReal}${path.sep}`;
|
|
713
|
+
return absPath.startsWith(prefix);
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* live resolve가 읽는 canonical Explain 경로만 허용한다.
|
|
717
|
+
* cache가 `src/secret.ts` 같은 임의 상대경로를 읽기 대상으로 지시하지 못하게 한다.
|
|
718
|
+
*
|
|
719
|
+
* @param {string} explainRel - provenance.explain 후보
|
|
720
|
+
* @returns {boolean} allowlist에 맞으면 true
|
|
721
|
+
*/
|
|
722
|
+
function isCanonicalExplainRel(explainRel) {
|
|
723
|
+
if (typeof explainRel !== 'string' || explainRel.length === 0)
|
|
724
|
+
return false;
|
|
725
|
+
const rel = toPosix(explainRel);
|
|
726
|
+
if (path.isAbsolute(rel) || rel.includes('..'))
|
|
727
|
+
return false;
|
|
728
|
+
// live resolve(loadExplainDocs)와 같은 canonical Explain만 cache가 읽기 경로로 쓸 수 있다.
|
|
729
|
+
if (!isCanonicalExplainPath(rel))
|
|
730
|
+
return false;
|
|
731
|
+
const ids = parsePathIds(rel);
|
|
732
|
+
return ids.kind === 'explain' && Boolean(ids.epicId) && Boolean(ids.blueprintId);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* intent cache 파일·부모 실경로가 Git common directory 안에 있는지 고정한다.
|
|
736
|
+
* `bouncer`/`intent`/digest 파일 symlink가 common dir 밖으로 새면 읽기·쓰기를 거절한다.
|
|
737
|
+
*
|
|
738
|
+
* @param {string} intentFile - intentBundlePathFor가 준 경로
|
|
739
|
+
* @param {string} commonGitDir - Git common directory
|
|
740
|
+
* @param {InjectedFs} io - realpath·mkdir 주입
|
|
741
|
+
* @param {{ createDir?: boolean }} [opts] - write 직전에만 부모 mkdir
|
|
742
|
+
*/
|
|
743
|
+
function assertIntentPathInCommonDir(intentFile, commonGitDir, io, opts = {}) {
|
|
744
|
+
let commonReal;
|
|
745
|
+
try {
|
|
746
|
+
commonReal = io.realpathSync(commonGitDir);
|
|
747
|
+
}
|
|
748
|
+
catch (error) {
|
|
749
|
+
throw new Error('Git common directory is not readable', { cause: error });
|
|
750
|
+
}
|
|
751
|
+
// 존재하는 조상부터 파일까지 실경로가 common 안에 있어야 한다.
|
|
752
|
+
// 없는 구간은 write 시 mkdir 뒤에 다시 검사한다.
|
|
753
|
+
let cursor = intentFile;
|
|
754
|
+
for (;;) {
|
|
755
|
+
if (io.existsSync(cursor)) {
|
|
756
|
+
let real;
|
|
757
|
+
try {
|
|
758
|
+
real = io.realpathSync(cursor);
|
|
759
|
+
}
|
|
760
|
+
catch (error) {
|
|
761
|
+
if (isSkippableFsError(error)) {
|
|
762
|
+
throw new Error('intent cache path escapes the Git common directory', { cause: error });
|
|
763
|
+
}
|
|
764
|
+
throw error;
|
|
765
|
+
}
|
|
766
|
+
if (real !== commonReal && !isInsideRepo(real, commonReal)) {
|
|
767
|
+
throw new Error('intent cache path escapes the Git common directory');
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (cursor === commonGitDir || cursor === commonReal)
|
|
771
|
+
break;
|
|
772
|
+
const parent = path.dirname(cursor);
|
|
773
|
+
if (parent === cursor)
|
|
774
|
+
break;
|
|
775
|
+
cursor = parent;
|
|
776
|
+
}
|
|
777
|
+
if (!opts.createDir)
|
|
778
|
+
return;
|
|
779
|
+
const dir = path.dirname(intentFile);
|
|
780
|
+
io.mkdirSync(dir, { recursive: true });
|
|
781
|
+
let dirReal;
|
|
782
|
+
try {
|
|
783
|
+
dirReal = io.realpathSync(dir);
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
throw new Error('intent cache directory escapes the Git common directory', { cause: error });
|
|
787
|
+
}
|
|
788
|
+
if (!isInsideRepo(dirReal, commonReal)) {
|
|
789
|
+
throw new Error('intent cache directory escapes the Git common directory');
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* 역할 projection 실패를 reason·next가 붙은 Error로 올린다.
|
|
794
|
+
* CLI는 이 세 필드로 stdout JSON을 만들고, Git resolver를 열지 않는다.
|
|
795
|
+
*
|
|
796
|
+
* @param {IntentSectionsReason} reason - 기계가 읽는 실패 코드
|
|
797
|
+
* @param {string} cause - 사람이 읽는 원인
|
|
798
|
+
* @param {string} next - bundle을 다시 만드는 명령
|
|
799
|
+
* @returns {never} 항상 throw
|
|
800
|
+
*/
|
|
801
|
+
function failIntentSections(reason, cause, next) {
|
|
802
|
+
const error = new Error(cause);
|
|
803
|
+
error.reason = reason;
|
|
804
|
+
error.next = next;
|
|
805
|
+
throw error;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* 실패 JSON의 next는 호출자가 같은 task에 bundle을 다시 만들게 한다.
|
|
809
|
+
* 저장된 함수 집합이 비었으면 --no-symbols를 안내해 같은 계약으로 복구하게 한다.
|
|
810
|
+
*
|
|
811
|
+
* @param {string} taskFile - repo-relative canonical tasks.md
|
|
812
|
+
* @param {boolean} hasFunctions - bundle에 기록된 함수가 하나 이상인지
|
|
813
|
+
* @returns {string} `bouncer intent bundle` 복구 명령
|
|
814
|
+
*/
|
|
815
|
+
function intentBundleNext(taskFile, hasFunctions) {
|
|
816
|
+
// 빈 함수 bundle은 재시도에서 임의 symbol을 요구하면 계약과 어긋난다.
|
|
817
|
+
return hasFunctions
|
|
818
|
+
? `bouncer intent bundle --task ${taskFile} --symbol <name>...`
|
|
819
|
+
: `bouncer intent bundle --task ${taskFile} --no-symbols`;
|
|
820
|
+
}
|
|
821
|
+
function isIntentRole(value) {
|
|
822
|
+
return value === 'implementer' || value === 'reviewer' || value === 'debugger';
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* 캐시된 intent bundle에서 역할 allowlist에 속한 task 절 본문만 골라 JSON 객체를 만든다.
|
|
826
|
+
* Git provenance resolver와 cache write는 타지 않는다 — 판정은 record, 현재 brief
|
|
827
|
+
* 바이트, 현재 Explain 파일뿐이다. historical·unlinked·unresolved·ambiguous는
|
|
828
|
+
* Explain이 없는 historical·unlinked 함수도 구현자의 탐색 좌표에는 남긴다. 위치 목록은
|
|
829
|
+
* implementer에만 제공해 reviewer/debugger의 기존 응답 계약을 유지한다.
|
|
830
|
+
*
|
|
831
|
+
* @param {object} input - 조회 입력
|
|
832
|
+
* @param {string} input.repoRoot - 저장소 루트
|
|
833
|
+
* @param {string} input.taskFile - repo-relative canonical tasks.md
|
|
834
|
+
* @param {IntentRole} input.role - implementer | reviewer | debugger
|
|
835
|
+
* @param {BundleDeps} [input.deps] - fs·Git common-dir 주입(테스트용). resolver는 쓰지 않는다
|
|
836
|
+
* @returns {RoleIntentSectionsResult} ok: true 역할 projection
|
|
837
|
+
*/
|
|
838
|
+
function projectRoleIntentSections(input) {
|
|
839
|
+
let next = intentBundleNext(input.taskFile, true);
|
|
840
|
+
if (!isIntentRole(input.role)) {
|
|
841
|
+
throw new Error('role must be implementer, reviewer, or debugger');
|
|
842
|
+
}
|
|
843
|
+
const deps = input.deps || {};
|
|
844
|
+
const io = { ...fs, ...(deps.fs || {}) };
|
|
845
|
+
const execFileSync = deps.execFileSync || realExecFileSync;
|
|
846
|
+
// 1. 현재 brief 바이트와 stable Task ID. Git blame은 치지 않는다.
|
|
847
|
+
let loaded;
|
|
848
|
+
try {
|
|
849
|
+
loaded = loadExecutionTask({
|
|
850
|
+
repoRoot: input.repoRoot,
|
|
851
|
+
taskFile: input.taskFile,
|
|
852
|
+
fs: io,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
catch (error) {
|
|
856
|
+
const message = error.message;
|
|
857
|
+
if (typeof message !== 'string' || message.length === 0)
|
|
858
|
+
throw error;
|
|
859
|
+
failIntentSections('intent-task-invalid', message, next);
|
|
860
|
+
}
|
|
861
|
+
const located = intentBundlePathFor({
|
|
862
|
+
repoRoot: loaded.repoReal,
|
|
863
|
+
taskRel: loaded.taskRel,
|
|
864
|
+
deps: { execFileSync, platform: deps.platform },
|
|
865
|
+
});
|
|
866
|
+
if (located.unavailable || !located.intentFile || !located.commonGitDir) {
|
|
867
|
+
failIntentSections('intent-bundle-missing', located.reason || 'Git common directory unavailable', next);
|
|
868
|
+
}
|
|
869
|
+
try {
|
|
870
|
+
assertIntentPathInCommonDir(located.intentFile, located.commonGitDir, io, { createDir: false });
|
|
871
|
+
}
|
|
872
|
+
catch (error) {
|
|
873
|
+
const message = error.message;
|
|
874
|
+
if (typeof message !== 'string' || message.length === 0)
|
|
875
|
+
throw error;
|
|
876
|
+
failIntentSections('intent-bundle-missing', message, next);
|
|
877
|
+
}
|
|
878
|
+
// 2. record만 읽는다. 손상·부재는 miss로 재생성하지 않고 호출자에게 맡긴다.
|
|
879
|
+
const record = readValidBundleRecord({
|
|
880
|
+
intentFile: located.intentFile,
|
|
881
|
+
expectedTask: loaded.stableTask,
|
|
882
|
+
fs: io,
|
|
883
|
+
});
|
|
884
|
+
if (!record) {
|
|
885
|
+
failIntentSections('intent-bundle-missing', 'intent bundle cache is missing or invalid', next);
|
|
886
|
+
}
|
|
887
|
+
next = intentBundleNext(input.taskFile, record.functions.length > 0);
|
|
888
|
+
if (record.task_brief_hash !== loaded.taskBriefHash) {
|
|
889
|
+
failIntentSections('intent-bundle-stale', 'task brief hash does not match the cached intent bundle', next);
|
|
890
|
+
}
|
|
891
|
+
const allow = new Set(ROLE_SECTION_NAMES[input.role]);
|
|
892
|
+
const functions = [];
|
|
893
|
+
for (const entry of record.functions) {
|
|
894
|
+
if (entry.status !== 'resolved')
|
|
895
|
+
continue;
|
|
896
|
+
if (entry.provenance.freshness === 'historical')
|
|
897
|
+
continue;
|
|
898
|
+
if (!sectionHashesMatch(entry.provenance, loaded.repoReal, io)) {
|
|
899
|
+
failIntentSections('intent-sections-drift', `Explain section hashes drifted for ${entry.symbol}`, next);
|
|
900
|
+
}
|
|
901
|
+
const bodies = projectExplainSectionBodies({
|
|
902
|
+
repoRoot: loaded.repoReal,
|
|
903
|
+
explainRel: entry.provenance.explain,
|
|
904
|
+
task: entry.provenance.task,
|
|
905
|
+
});
|
|
906
|
+
if (!bodies) {
|
|
907
|
+
failIntentSections('intent-sections-drift', `Explain sections are unreadable for ${entry.symbol}`, next);
|
|
908
|
+
}
|
|
909
|
+
functions.push({
|
|
910
|
+
symbol: entry.symbol,
|
|
911
|
+
function_ref: entry.function_ref,
|
|
912
|
+
explain: entry.provenance.explain,
|
|
913
|
+
freshness: entry.provenance.freshness,
|
|
914
|
+
sections: bodies
|
|
915
|
+
.filter((part) => allow.has(part.name))
|
|
916
|
+
.map((part) => ({ name: part.name, body: part.body })),
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const result = {
|
|
920
|
+
ok: true,
|
|
921
|
+
role: input.role,
|
|
922
|
+
bundle_id: record.bundle_id,
|
|
923
|
+
revision: record.revision,
|
|
924
|
+
task_brief_hash: loaded.taskBriefHash,
|
|
925
|
+
functions,
|
|
926
|
+
};
|
|
927
|
+
if (input.role === 'implementer') {
|
|
928
|
+
// 좌표는 생성 시점 bundle의 원본만 복사한다. unresolved에는 좌표가 없고,
|
|
929
|
+
// ambiguous는 bundle 생성 단계에서 선택을 거절하므로 후보를 합성하지 않는다.
|
|
930
|
+
result.symbol_locations = record.functions
|
|
931
|
+
.filter((entry) => (entry.status === 'resolved' || entry.status === 'unlinked'))
|
|
932
|
+
.map(({ symbol, function_ref, symbol_ref }) => ({ symbol, function_ref, symbol_ref }));
|
|
933
|
+
}
|
|
934
|
+
return result;
|
|
935
|
+
}
|
|
936
|
+
module.exports = { resolveTaskIntentBundle, projectRoleIntentSections };
|