@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,595 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateBlueprintIntent = validateBlueprintIntent;
|
|
4
|
+
exports.buildBlueprintSnapshot = buildBlueprintSnapshot;
|
|
5
|
+
exports.buildTaskSnapshots = buildTaskSnapshots;
|
|
6
|
+
exports.applyTaskTrailers = applyTaskTrailers;
|
|
7
|
+
exports.collectWorkerNotes = collectWorkerNotes;
|
|
8
|
+
exports.parseCommitSubjects = parseCommitSubjects;
|
|
9
|
+
exports.extractSymbols = extractSymbols;
|
|
10
|
+
exports.summarizeDiffStat = summarizeDiffStat;
|
|
11
|
+
exports.normalizeChangedPaths = normalizeChangedPaths;
|
|
12
|
+
exports.assessDigestSnapshot = assessDigestSnapshot;
|
|
13
|
+
exports.assembleDigestBody = assembleDigestBody;
|
|
14
|
+
exports.summarizeDrive = summarizeDrive;
|
|
15
|
+
exports.summarizeDriveIssues = summarizeDriveIssues;
|
|
16
|
+
exports.collectDigestWarnings = collectDigestWarnings;
|
|
17
|
+
const executionContracts = require("./execution-contracts");
|
|
18
|
+
const { validOutsideScope } = executionContracts;
|
|
19
|
+
const pathOverlap = require("./path-overlap");
|
|
20
|
+
const { normalizeOverlapPath, pathsOverlap } = pathOverlap;
|
|
21
|
+
const templates = require("./templates");
|
|
22
|
+
const { parseIntentBody } = templates;
|
|
23
|
+
const schema = require("./schema");
|
|
24
|
+
const { executionKindOf } = schema;
|
|
25
|
+
const commitSha = require("./commit-sha");
|
|
26
|
+
const { normalizeCommitSha, buildStableProvenance } = commitSha;
|
|
27
|
+
const posixPath = require("./posix-path");
|
|
28
|
+
const { toPosix } = posixPath;
|
|
29
|
+
const contextIds = require("./context-ids");
|
|
30
|
+
const { validateSlug, formatLegacyBlueprintId } = contextIds;
|
|
31
|
+
const SYMBOL_FN_RE = new RegExp([
|
|
32
|
+
'^(?:export\\s+(?:default\\s+)?)?(?:async\\s+)?',
|
|
33
|
+
'(?:function(?:\\s*\\*\\s*|\\s+)|class\\s+)([A-Za-z_$][\\w$]*)',
|
|
34
|
+
].join(''));
|
|
35
|
+
const SYMBOL_VAR_RE = /^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/;
|
|
36
|
+
const SHORT_SHA_NUL_RE = /([0-9a-f]{7,40})\0/gi;
|
|
37
|
+
const CONTEXT_EXCLUDED = '.bouncer/context/';
|
|
38
|
+
const PER_FILE_SYMBOL_CAP = 10;
|
|
39
|
+
const TOTAL_SYMBOL_CAP = 60;
|
|
40
|
+
const COMMIT_LIST_CAP = 50;
|
|
41
|
+
const DIFF_PER_FILE_CAP = 30;
|
|
42
|
+
/**
|
|
43
|
+
* blueprint Intent를 순수하게 검사한다. 헤딩 부재는 구형 문서와 같이 빈 배열이다.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} body - index.md 본문
|
|
46
|
+
* @returns {{ ok: true; intent: string[] } | { ok: false; message: string }} Intent 또는 거절 사유
|
|
47
|
+
*/
|
|
48
|
+
function validateBlueprintIntent(body) {
|
|
49
|
+
try {
|
|
50
|
+
return { ok: true, intent: parseIntentBody(body) };
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const message = error.message || String(error);
|
|
54
|
+
if (/blueprint Intent is missing or malformed/.test(message))
|
|
55
|
+
return { ok: true, intent: [] };
|
|
56
|
+
return { ok: false, message };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* blueprint index snapshot을 공개 blueprint 필드와 intent 검증 결과로 정규화한다.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} snapshot - 이미 읽힌 frontmatter 데이터와 본문
|
|
63
|
+
* @returns {object} digest blueprint 값, 또는 S33으로 반환할 Intent 오류
|
|
64
|
+
*/
|
|
65
|
+
function buildBlueprintSnapshot(snapshot) {
|
|
66
|
+
const data = asRecord(snapshot.data);
|
|
67
|
+
const bouncer = asRecord(data.bouncer);
|
|
68
|
+
const epicId = typeof bouncer.epic_id === 'string' ? bouncer.epic_id : null;
|
|
69
|
+
const blueprintId = typeof bouncer.blueprint_id === 'string'
|
|
70
|
+
? bouncer.blueprint_id
|
|
71
|
+
: (typeof bouncer.id === 'string' ? bouncer.id : null);
|
|
72
|
+
const intent = validateBlueprintIntent(snapshot.body);
|
|
73
|
+
if (!intent.ok)
|
|
74
|
+
return intent;
|
|
75
|
+
let stableId = '';
|
|
76
|
+
if (epicId && blueprintId) {
|
|
77
|
+
stableId = validateSlug(epicId) === null && validateSlug(blueprintId) === null
|
|
78
|
+
? `${epicId}/${blueprintId}`
|
|
79
|
+
: formatLegacyBlueprintId(epicId, blueprintId);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
title: typeof data.title === 'string' ? data.title : '',
|
|
84
|
+
commit_type: typeof bouncer.commit_type === 'string' ? bouncer.commit_type : 'feat',
|
|
85
|
+
scale: typeof bouncer.scale === 'string' ? bouncer.scale : null,
|
|
86
|
+
epicId,
|
|
87
|
+
blueprintId,
|
|
88
|
+
stable_id: stableId,
|
|
89
|
+
reviewMode: bouncer.review_scope === 'blueprint',
|
|
90
|
+
intent: intent.intent,
|
|
91
|
+
outOfScope: parseOutOfScopeSnapshot(snapshot.body),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function parseOutOfScopeSnapshot(body) {
|
|
95
|
+
const text = body.replace(/<!--[\s\S]*?-->/g, '');
|
|
96
|
+
const lines = text.split('\n');
|
|
97
|
+
const starts = lines
|
|
98
|
+
.map((line, index) => /^##\s+Out\s+of\s+scope\s*$/i.test(line.trim()) ? index : -1)
|
|
99
|
+
.filter((index) => index >= 0);
|
|
100
|
+
const start = starts[starts.length - 1] ?? -1;
|
|
101
|
+
if (start < 0)
|
|
102
|
+
return [];
|
|
103
|
+
const end = lines.findIndex((line, index) => index > start && /^##\s/.test(line));
|
|
104
|
+
return lines.slice(start + 1, end < 0 ? lines.length : end)
|
|
105
|
+
.map((line) => line.trim())
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.map((line) => line.replace(/^[-*]\s+/, '').trim())
|
|
108
|
+
.filter(Boolean);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 이미 읽힌 task snapshot을 version two task digest 행으로 정규화한다.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} snapshot - 수집된 문서 값과 blueprint·worker-note context
|
|
114
|
+
* @returns {object} task digest 행과 trailer 매칭 stable id 정보
|
|
115
|
+
*/
|
|
116
|
+
function buildTaskSnapshots(snapshot) {
|
|
117
|
+
const workerNotes = collectWorkerNotes(snapshot.ledger);
|
|
118
|
+
return snapshot.rows.map((row) => {
|
|
119
|
+
const taskData = asRecord(row.data);
|
|
120
|
+
const bouncer = asRecord(taskData.bouncer);
|
|
121
|
+
const id = typeof bouncer.id === 'string'
|
|
122
|
+
? bouncer.id
|
|
123
|
+
: `TASKS-${String(row.number).padStart(3, '0')}`;
|
|
124
|
+
const kind = executionKindOf(bouncer) || row.executionKind || 'commit';
|
|
125
|
+
let stableId = id;
|
|
126
|
+
try {
|
|
127
|
+
stableId = buildStableProvenance({
|
|
128
|
+
epicId: bouncer.epic_id || snapshot.blueprintEpicId,
|
|
129
|
+
blueprintId: bouncer.blueprint_id || snapshot.blueprintId,
|
|
130
|
+
taskId: id,
|
|
131
|
+
}).task;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
const message = error instanceof Error ? error.message : '';
|
|
135
|
+
const invalidInputs = new Set([
|
|
136
|
+
'blueprint_id must be a three-digit id',
|
|
137
|
+
'blueprint_id must be a slug',
|
|
138
|
+
'epic_id must be a three-digit id or a slug',
|
|
139
|
+
'task id must be TASKS-NNN',
|
|
140
|
+
]);
|
|
141
|
+
if (!invalidInputs.has(message)) {
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
// 문서 ID 형식 오류만 흡수해 id는 보존하고 trailer 매칭만 생략한다.
|
|
145
|
+
}
|
|
146
|
+
const affected = stringList(bouncer.affected_paths);
|
|
147
|
+
const constraints = parseTaskConstraints(row.body);
|
|
148
|
+
const commit = normalizeCommitSha(bouncer.commit_sha);
|
|
149
|
+
const digest = {
|
|
150
|
+
stable_id: stableId,
|
|
151
|
+
id,
|
|
152
|
+
title: typeof taskData.title === 'string' ? taskData.title : '',
|
|
153
|
+
execution_kind: kind,
|
|
154
|
+
status: typeof bouncer.status === 'string' ? bouncer.status : '',
|
|
155
|
+
commit: commit ? { sha: commit, sha8: commit, source: 'commit_sha', worker_sha: null } : null,
|
|
156
|
+
affected_paths: affected,
|
|
157
|
+
verification: row.verification,
|
|
158
|
+
review: row.review,
|
|
159
|
+
constraints,
|
|
160
|
+
...(workerNotes.has(id) ? { worker_notes: workerNotes.get(id) } : {}),
|
|
161
|
+
};
|
|
162
|
+
return { digest, affected, kind };
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Git trailer map을 task digest commit 필드에 적용한다. trailer가 있으면 원장·문서 SHA보다 우선한다.
|
|
167
|
+
*
|
|
168
|
+
* @param {Array<object>} built - 모델이 만든 task snapshot
|
|
169
|
+
* @param {Map<string, object>} trailerMap - collector가 읽은 commit provenance
|
|
170
|
+
* @returns {Array<object>} commit 필드가 정규화된 task snapshot 복사본
|
|
171
|
+
*/
|
|
172
|
+
function applyTaskTrailers(built, trailerMap) {
|
|
173
|
+
return built.map((item) => {
|
|
174
|
+
const hit = trailerMap.get(item.digest.stable_id);
|
|
175
|
+
if (hit) {
|
|
176
|
+
return {
|
|
177
|
+
...item,
|
|
178
|
+
digest: {
|
|
179
|
+
...item.digest,
|
|
180
|
+
commit: { sha: hit.sha, sha8: hit.sha8, source: 'trailer', worker_sha: hit.workerSha },
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (item.digest.commit?.source === 'commit_sha') {
|
|
185
|
+
const short = item.digest.commit.sha8;
|
|
186
|
+
return {
|
|
187
|
+
...item,
|
|
188
|
+
digest: {
|
|
189
|
+
...item.digest,
|
|
190
|
+
commit: { sha: short, sha8: short, source: 'commit_sha', worker_sha: null },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return { ...item, digest: { ...item.digest } };
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* ledger에서 task별 마지막 accepted 보고의 열린 선택과 약점을 수집한다.
|
|
199
|
+
*
|
|
200
|
+
* @param {unknown} ledger - 읽힌 원장. 없거나 light 모드면 null과 같다
|
|
201
|
+
* @returns {Map<string, WorkerNotes>} 내용이 있는 TASKS-NNN 항목만 포함
|
|
202
|
+
*/
|
|
203
|
+
function collectWorkerNotes(ledger) {
|
|
204
|
+
const notes = new Map();
|
|
205
|
+
const root = asRecord(ledger);
|
|
206
|
+
if (root.mode === 'light' || !Array.isArray(root.decisions))
|
|
207
|
+
return notes;
|
|
208
|
+
const latest = new Map();
|
|
209
|
+
for (const raw of root.decisions) {
|
|
210
|
+
const decision = asRecord(raw);
|
|
211
|
+
if (decision.kind !== 'report' || decision.outcome !== 'accepted')
|
|
212
|
+
continue;
|
|
213
|
+
if (typeof decision.task !== 'string' || !decision.task)
|
|
214
|
+
continue;
|
|
215
|
+
const id = decision.task.startsWith('TASKS-') ? decision.task : `TASKS-${decision.task}`;
|
|
216
|
+
latest.set(id, decision);
|
|
217
|
+
}
|
|
218
|
+
for (const [id, decision] of latest) {
|
|
219
|
+
const open_choices = stringList(decision.open_choices);
|
|
220
|
+
const known_weaknesses = stringList(decision.known_weaknesses);
|
|
221
|
+
if (open_choices.length || known_weaknesses.length)
|
|
222
|
+
notes.set(id, { open_choices, known_weaknesses });
|
|
223
|
+
}
|
|
224
|
+
return notes;
|
|
225
|
+
}
|
|
226
|
+
function stringList(value) {
|
|
227
|
+
return (Array.isArray(value) ? value : [])
|
|
228
|
+
.filter((entry) => typeof entry === 'string' && entry !== '');
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* task 본문에서 Constraints 절만 읽는다. 경량 모델은 validate-sections의 I/O 그래프에 묶이지 않는다.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} body - tasks.md 본문
|
|
234
|
+
* @returns {string | null} 주석 제거 후 비어 있지 않은 Constraints 본문
|
|
235
|
+
*/
|
|
236
|
+
function parseTaskConstraints(body) {
|
|
237
|
+
const text = body.replace(/<!--[\s\S]*?-->/g, '');
|
|
238
|
+
const lines = text.split('\n');
|
|
239
|
+
const headings = [
|
|
240
|
+
/^##\s+(Goal\s*&\s*intent|목적[·・.]?의도)\s*$/i,
|
|
241
|
+
/^##\s+(Current\s+behavior|현재\s*동작)\s*$/i,
|
|
242
|
+
/^##\s+(Target\s+behavior|목표\s*동작)\s*$/i,
|
|
243
|
+
/^##\s+(Interface|인터페이스)\s*$/i,
|
|
244
|
+
/^##\s+(Touch|수정할\s*부분)\s*$/i,
|
|
245
|
+
/^##\s+(Do\s+not\s+touch|절대\s*수정\s*금지)\s*$/i,
|
|
246
|
+
/^##\s+(Constraints|제약)\s*$/i,
|
|
247
|
+
/^##\s+(Checklist|체크리스트)\s*$/i,
|
|
248
|
+
];
|
|
249
|
+
const isHeading = (line) => headings.some((heading) => heading.test(line.trim()));
|
|
250
|
+
const starts = lines
|
|
251
|
+
.map((line, index) => /^##\s+(Constraints|제약)\s*$/i.test(line.trim()) ? index : -1)
|
|
252
|
+
.filter((index) => index >= 0);
|
|
253
|
+
const start = starts[starts.length - 1] ?? -1;
|
|
254
|
+
if (start < 0)
|
|
255
|
+
return null;
|
|
256
|
+
const next = lines.findIndex((line, index) => index > start && isHeading(line));
|
|
257
|
+
const content = lines.slice(start + 1, next < 0 ? lines.length : next).join('\n').trim();
|
|
258
|
+
return content || null;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* `git log --format=%h%x00%s` 출력을 최신순 commit 요약으로 접는다.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} stdout - git log 결과
|
|
264
|
+
* @returns {Array<{ sha8: string; subject: string }>} 제한된 commit 목록
|
|
265
|
+
*/
|
|
266
|
+
function parseCommitSubjects(stdout) {
|
|
267
|
+
const starts = [];
|
|
268
|
+
SHORT_SHA_NUL_RE.lastIndex = 0;
|
|
269
|
+
let match;
|
|
270
|
+
while ((match = SHORT_SHA_NUL_RE.exec(stdout)) !== null) {
|
|
271
|
+
starts.push({
|
|
272
|
+
sha: match[1].toLowerCase(),
|
|
273
|
+
bodyStart: match.index + match[0].length,
|
|
274
|
+
shaStart: match.index,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const result = [];
|
|
278
|
+
for (let index = 0; index < starts.length; index += 1) {
|
|
279
|
+
const end = index + 1 < starts.length ? starts[index + 1].shaStart : stdout.length;
|
|
280
|
+
const subject = stdout.slice(starts[index].bodyStart, end).replace(/^\n|\n$/g, '').trim();
|
|
281
|
+
const sha8 = normalizeCommitSha(starts[index].sha) || starts[index].sha.slice(0, 8);
|
|
282
|
+
result.push({ sha8, subject });
|
|
283
|
+
}
|
|
284
|
+
return result.slice(0, COMMIT_LIST_CAP);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* unified diff의 파일별 최상위 선언 이름을 줄 머리 규칙으로 추출한다.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} diffText - `git diff -U0` 결과
|
|
290
|
+
* @returns {Array<{ path: string; names: string[] }>} 파일당 10개, 전체 60개 이하
|
|
291
|
+
*/
|
|
292
|
+
function extractSymbols(diffText) {
|
|
293
|
+
const byPath = new Map();
|
|
294
|
+
let currentPath = null;
|
|
295
|
+
let total = 0;
|
|
296
|
+
const pushName = (relPath, name) => {
|
|
297
|
+
if (total >= TOTAL_SYMBOL_CAP)
|
|
298
|
+
return;
|
|
299
|
+
let list = byPath.get(relPath);
|
|
300
|
+
if (!list) {
|
|
301
|
+
list = [];
|
|
302
|
+
byPath.set(relPath, list);
|
|
303
|
+
}
|
|
304
|
+
if (list.length >= PER_FILE_SYMBOL_CAP || list.includes(name))
|
|
305
|
+
return;
|
|
306
|
+
list.push(name);
|
|
307
|
+
total += 1;
|
|
308
|
+
};
|
|
309
|
+
const scanLine = (relPath, text) => {
|
|
310
|
+
const fn = SYMBOL_FN_RE.exec(text);
|
|
311
|
+
if (fn?.[1]) {
|
|
312
|
+
pushName(relPath, fn[1]);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const variable = SYMBOL_VAR_RE.exec(text);
|
|
316
|
+
if (variable?.[1])
|
|
317
|
+
pushName(relPath, variable[1]);
|
|
318
|
+
};
|
|
319
|
+
for (const line of diffText.split('\n')) {
|
|
320
|
+
const gitPath = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
|
321
|
+
if (gitPath) {
|
|
322
|
+
currentPath = toPosix(gitPath[2]);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (!currentPath || currentPath.startsWith(CONTEXT_EXCLUDED))
|
|
326
|
+
continue;
|
|
327
|
+
const hunk = /^@@[^@]*@@(.*)$/.exec(line);
|
|
328
|
+
if (hunk) {
|
|
329
|
+
const context = hunk[1] || '';
|
|
330
|
+
scanLine(currentPath, context.startsWith(' ') ? context.slice(1) : context);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (line.startsWith('+') && !line.startsWith('+++'))
|
|
334
|
+
scanLine(currentPath, line.slice(1));
|
|
335
|
+
}
|
|
336
|
+
return [...byPath.entries()].map(([filePath, names]) => ({ path: filePath, names }));
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* 수집된 numstat 결과를 digest용 변경 통계로 정규화한다.
|
|
340
|
+
*
|
|
341
|
+
* @param {{ status: number; stdout: string }} result - `git diff --numstat -z` 결과
|
|
342
|
+
* @returns {DiffSummary | null} Git 실패면 null, 성공이면 안정 정렬된 요약
|
|
343
|
+
*/
|
|
344
|
+
function summarizeDiffStat(result) {
|
|
345
|
+
if (result.status !== 0)
|
|
346
|
+
return null;
|
|
347
|
+
const rows = [];
|
|
348
|
+
let insertions = 0;
|
|
349
|
+
let deletions = 0;
|
|
350
|
+
for (const record of result.stdout.split('\0')) {
|
|
351
|
+
if (!record.trim())
|
|
352
|
+
continue;
|
|
353
|
+
const parts = record.split('\t');
|
|
354
|
+
if (parts.length < 3)
|
|
355
|
+
continue;
|
|
356
|
+
const filePath = toPosix(parts.slice(2).join('\t'));
|
|
357
|
+
if (filePath.startsWith(CONTEXT_EXCLUDED))
|
|
358
|
+
continue;
|
|
359
|
+
const added = /^\d+$/.test(parts[0]) ? Number(parts[0]) : 0;
|
|
360
|
+
const deleted = /^\d+$/.test(parts[1]) ? Number(parts[1]) : 0;
|
|
361
|
+
insertions += added;
|
|
362
|
+
deletions += deleted;
|
|
363
|
+
rows.push({ path: filePath, added, deleted });
|
|
364
|
+
}
|
|
365
|
+
const perFile = rows
|
|
366
|
+
.map((row, index) => ({ row, index }))
|
|
367
|
+
.sort((a, b) => (b.row.added + b.row.deleted) - (a.row.added + a.row.deleted) || a.index - b.index)
|
|
368
|
+
.slice(0, DIFF_PER_FILE_CAP)
|
|
369
|
+
.map((entry) => entry.row);
|
|
370
|
+
return { files: rows.length, insertions, deletions, per_file: perFile };
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Git name-only 결과를 governance 경로 없이 정규화한다.
|
|
374
|
+
*
|
|
375
|
+
* @param {{ status: number; stdout: string }} result - `git diff --name-only` 결과
|
|
376
|
+
* @returns {string[]} 성공 경로의 입력 순서 목록. Git 실패면 빈 배열
|
|
377
|
+
*/
|
|
378
|
+
function normalizeChangedPaths(result) {
|
|
379
|
+
if (result.status !== 0)
|
|
380
|
+
return [];
|
|
381
|
+
return result.stdout.split('\n')
|
|
382
|
+
.map((line) => line.trim())
|
|
383
|
+
.filter(Boolean)
|
|
384
|
+
.map((filePath) => toPosix(filePath))
|
|
385
|
+
.filter((filePath) => filePath !== CONTEXT_EXCLUDED.slice(0, -1)
|
|
386
|
+
&& !filePath.startsWith(CONTEXT_EXCLUDED));
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* 수집 snapshot에 들어온 task·review·ledger 값만 검증해 unverified 결과를 만든다.
|
|
390
|
+
* 실패한 terminal을 outside_scope로 분류해도 verification 상태는 실패로 남긴다.
|
|
391
|
+
*
|
|
392
|
+
* @param {object} snapshot - collector가 읽은 task와 ledger 값
|
|
393
|
+
* @returns {object} 복제된 task digest, root review 요약, unverified 목록
|
|
394
|
+
*/
|
|
395
|
+
function assessDigestSnapshot(snapshot) {
|
|
396
|
+
const built = snapshot.built.map((item) => ({
|
|
397
|
+
...item,
|
|
398
|
+
digest: { ...item.digest },
|
|
399
|
+
}));
|
|
400
|
+
const unverified = [];
|
|
401
|
+
if (snapshot.diffSummaryUnavailable)
|
|
402
|
+
unverified.push({ kind: 'diff-summary-unavailable' });
|
|
403
|
+
let terminalOk = false;
|
|
404
|
+
let outsideScopeTerminal = null;
|
|
405
|
+
for (const item of built) {
|
|
406
|
+
const task = item.digest;
|
|
407
|
+
if (item.kind === 'verification') {
|
|
408
|
+
if (task.verification && task.verification.status === 'passed') {
|
|
409
|
+
terminalOk = true;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const accepted = validOutsideScope(snapshot.ledger, task.id);
|
|
413
|
+
if (accepted) {
|
|
414
|
+
const failure = asRecord(asRecord(snapshot.ledger).terminalFailure);
|
|
415
|
+
task.verification = {
|
|
416
|
+
status: task.verification ? task.verification.status : 'failed',
|
|
417
|
+
command: task.verification?.command ?? (typeof failure.command === 'string' ? failure.command : null),
|
|
418
|
+
exit_code: task.verification?.exit_code ?? (typeof failure.exitCode === 'number' ? failure.exitCode : null),
|
|
419
|
+
evidence_id: task.verification?.evidence_id ?? accepted.evidence_id,
|
|
420
|
+
reused: task.verification ? task.verification.reused : null,
|
|
421
|
+
outside_scope: accepted,
|
|
422
|
+
};
|
|
423
|
+
outsideScopeTerminal = { stableId: task.stable_id, accepted };
|
|
424
|
+
}
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (item.kind === 'commit' && (!task.verification || task.verification.status !== 'passed')) {
|
|
428
|
+
unverified.push({ kind: 'verification-not-passed', task: task.stable_id });
|
|
429
|
+
}
|
|
430
|
+
if (!snapshot.blueprintReviewMode && task.review && task.review.required === false) {
|
|
431
|
+
unverified.push({ kind: 'review-skipped', task: task.stable_id });
|
|
432
|
+
}
|
|
433
|
+
for (const finding of task.review?.findings || []) {
|
|
434
|
+
if (finding.status === 'deferred' || finding.status === 'accepted') {
|
|
435
|
+
unverified.push({
|
|
436
|
+
kind: `finding-${finding.status}`,
|
|
437
|
+
task: task.stable_id,
|
|
438
|
+
detail: finding.note || finding.id,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const blueprintReview = snapshot.blueprintReviewMode
|
|
444
|
+
? { findings: snapshot.rootReview ? snapshot.rootReview.findings : [] }
|
|
445
|
+
: null;
|
|
446
|
+
for (const finding of blueprintReview?.findings || []) {
|
|
447
|
+
if (finding.status === 'deferred' || finding.status === 'accepted') {
|
|
448
|
+
unverified.push({
|
|
449
|
+
kind: `finding-${finding.status}`,
|
|
450
|
+
task: null,
|
|
451
|
+
detail: finding.note || finding.id,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (!terminalOk && outsideScopeTerminal) {
|
|
456
|
+
unverified.push({
|
|
457
|
+
kind: 'terminal-outside-scope',
|
|
458
|
+
task: outsideScopeTerminal.stableId,
|
|
459
|
+
detail: `${outsideScopeTerminal.accepted.reason} (evidence ${outsideScopeTerminal.accepted.evidence_id})`,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
else if (!terminalOk) {
|
|
463
|
+
unverified.push({ kind: 'terminal-missing' });
|
|
464
|
+
}
|
|
465
|
+
const scopes = built.map((item) => item.affected);
|
|
466
|
+
for (const changed of snapshot.changedPaths) {
|
|
467
|
+
if (pathOutsideScope(changed, scopes))
|
|
468
|
+
unverified.push({ kind: 'path-outside-scope', path: changed });
|
|
469
|
+
}
|
|
470
|
+
return { tasks: built.map((item) => item.digest), blueprint_review: blueprintReview, unverified };
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* changed path가 task scope와 겹치지 않는지 순수하게 판정한다.
|
|
474
|
+
*
|
|
475
|
+
* @param {string} changedPath - 변경 경로
|
|
476
|
+
* @param {string[][]} scopes - task별 허용 scope
|
|
477
|
+
* @returns {boolean} 정당화된 scope가 없으면 true
|
|
478
|
+
*/
|
|
479
|
+
function pathOutsideScope(changedPath, scopes) {
|
|
480
|
+
const normalized = normalizeOverlapPath(changedPath);
|
|
481
|
+
return !scopes.some((scope) => scope.some((declared) => (pathsOverlap(normalized, normalizeOverlapPath(declared)))));
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* 수집·검사된 snapshot을 version 2 digest 본문으로 조립한다.
|
|
485
|
+
* 전달받은 값만 사용해 파일, process, pointer 또는 Git을 조회하지 않는다.
|
|
486
|
+
*
|
|
487
|
+
* @param {object} snapshot - digest 계약에 이미 정규화된 값
|
|
488
|
+
* @returns {object} buildPrDraft 입력에 쓰이는 v2 digest 본문
|
|
489
|
+
*/
|
|
490
|
+
function assembleDigestBody(snapshot) {
|
|
491
|
+
return {
|
|
492
|
+
ok: true,
|
|
493
|
+
version: 2,
|
|
494
|
+
blueprint: snapshot.blueprint,
|
|
495
|
+
range: snapshot.range,
|
|
496
|
+
git: snapshot.git,
|
|
497
|
+
tasks: snapshot.tasks,
|
|
498
|
+
changed_paths: snapshot.changed_paths,
|
|
499
|
+
symbols: snapshot.symbols,
|
|
500
|
+
commits: snapshot.commits,
|
|
501
|
+
diff: snapshot.diff,
|
|
502
|
+
evidence: {
|
|
503
|
+
verification: snapshot.tasks.flatMap((task) => (task.verification && task.verification.evidence_id
|
|
504
|
+
? [{ task: task.stable_id, evidence_id: task.verification.evidence_id }]
|
|
505
|
+
: [])),
|
|
506
|
+
review: snapshot.reviewEvidence,
|
|
507
|
+
},
|
|
508
|
+
unverified: snapshot.unverified,
|
|
509
|
+
out_of_scope: snapshot.out_of_scope,
|
|
510
|
+
blueprint_review: snapshot.blueprint_review,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function asRecord(value) {
|
|
514
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
515
|
+
? value
|
|
516
|
+
: {};
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* 읽힌 coordinator ledger를 공개용 drive 요약으로 정규화한다.
|
|
520
|
+
* 식별할 수 없는 task가 있으면 ready를 닫아 불완전한 목록을 완료로 오인하지 않게 한다.
|
|
521
|
+
*
|
|
522
|
+
* @param {unknown} ledger - 읽힌 원장. 없으면 null
|
|
523
|
+
* @returns {DriveSummary | null} 원장 부재면 null, 그 외에는 task 순서를 보존한 요약
|
|
524
|
+
*/
|
|
525
|
+
function summarizeDrive(ledger) {
|
|
526
|
+
if (ledger === null || ledger === undefined)
|
|
527
|
+
return null;
|
|
528
|
+
const rawTasks = ledger.tasks;
|
|
529
|
+
if (!Array.isArray(rawTasks))
|
|
530
|
+
return { ready: false, tasks: [] };
|
|
531
|
+
const tasks = [];
|
|
532
|
+
let dropped = false;
|
|
533
|
+
for (const raw of rawTasks) {
|
|
534
|
+
if (!raw || typeof raw.id !== 'string') {
|
|
535
|
+
dropped = true;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
tasks.push({
|
|
539
|
+
id: raw.id,
|
|
540
|
+
status: typeof raw.status === 'string' ? raw.status : null,
|
|
541
|
+
sha: typeof raw.sha === 'string' ? raw.sha : null,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
const ready = !dropped && tasks.length > 0
|
|
545
|
+
&& tasks.every((task) => task.status === 'integrated' || validOutsideScope(ledger, task.id) !== null);
|
|
546
|
+
return { ready, tasks };
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* 원장 event에서 Retro 판단용 통계를 계산한다. 같은 repair wave는 한 번만 센다.
|
|
550
|
+
*
|
|
551
|
+
* @param {unknown} ledger - 읽힌 원장. 없으면 모든 원장 통계는 0
|
|
552
|
+
* @param {number} deferredMustFix - 루트 리뷰 deferred must_fix 고유 id 개수
|
|
553
|
+
* @returns {DriveIssues} 여섯 실행 통계
|
|
554
|
+
*/
|
|
555
|
+
function summarizeDriveIssues(ledger, deferredMustFix) {
|
|
556
|
+
const root = asRecord(ledger);
|
|
557
|
+
const decisions = (Array.isArray(root.decisions) ? root.decisions : []).map(asRecord);
|
|
558
|
+
const count = (pred) => decisions.filter(pred).length;
|
|
559
|
+
const uniqueWaves = (rows) => new Set(rows.map(asRecord).map((r) => r.wave).filter((w) => Number.isInteger(w))).size;
|
|
560
|
+
const fromRepairWaves = Array.isArray(root.repairWaves) ? uniqueWaves(root.repairWaves) : 0;
|
|
561
|
+
return {
|
|
562
|
+
rework_reports: count((d) => d.kind === 'report' && d.outcome === 'rework'),
|
|
563
|
+
debugger_calls: count((d) => d.kind === 'worker-model' && d.role === 'debugger'),
|
|
564
|
+
repair_waves: fromRepairWaves > 0
|
|
565
|
+
? fromRepairWaves
|
|
566
|
+
: uniqueWaves(decisions.filter((d) => d.kind === 'repair')),
|
|
567
|
+
fanin_verification_failures: count((d) => d.kind === 'fanin-verification-failed'),
|
|
568
|
+
blocked: count((d) => (d.kind === 'report' || d.kind === 'critical-recovery')
|
|
569
|
+
&& d.outcome === 'blocked'),
|
|
570
|
+
deferred_must_fix: deferredMustFix,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* blueprint review finding에서 고유 deferred must_fix 경고를 계산한다.
|
|
575
|
+
*
|
|
576
|
+
* @param {Finding[]} findings - 읽힌 blueprint root findings
|
|
577
|
+
* @returns {DigestWarning[]} 처음 등장한 id 순서의 경고
|
|
578
|
+
*/
|
|
579
|
+
function collectDigestWarnings(findings) {
|
|
580
|
+
const warnings = [];
|
|
581
|
+
const seen = new Set();
|
|
582
|
+
for (const finding of findings) {
|
|
583
|
+
if (finding.actionability !== 'must_fix' || finding.status !== 'deferred' || !finding.id)
|
|
584
|
+
continue;
|
|
585
|
+
if (seen.has(finding.id))
|
|
586
|
+
continue;
|
|
587
|
+
seen.add(finding.id);
|
|
588
|
+
warnings.push({
|
|
589
|
+
kind: 'must-fix-deferred',
|
|
590
|
+
id: finding.id,
|
|
591
|
+
note: typeof finding.note === 'string' ? finding.note : '',
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
return warnings;
|
|
595
|
+
}
|