@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,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// 리뷰 정책 값의 단일 정본. 위험도 enum과 execute 관점 목록은 검증 층(validate-*)과
|
|
3
|
+
// 분류기(review-dispatch), 코디네이터가 모두 읽는다. 예전에는 파일마다 사본을 두고
|
|
4
|
+
// 주석으로 "함께 고쳐라"만 적었는데, 한쪽만 고치면 컴파일러가 잡지 못해 S30과
|
|
5
|
+
// Execute 경로의 허용 목록이 어긋날 수 있었다. 값만 담는 순수 모듈이라 다른 lib을
|
|
6
|
+
// 가져오지 않는다 — 소비자 → 정본 방향의 간선만 허용해 순환을 만들지 않는다.
|
|
7
|
+
// S30(validate-structural)과 review-dispatch Execute 경로가 같은 목록으로 거절한다.
|
|
8
|
+
// 값과 순서를 바꾸면 기존 tasks 문서의 review_risk 판정이 바뀐다.
|
|
9
|
+
const REVIEW_RISK_ENUM = [
|
|
10
|
+
'public_interface',
|
|
11
|
+
'authentication',
|
|
12
|
+
'authorization',
|
|
13
|
+
'credential',
|
|
14
|
+
];
|
|
15
|
+
// adaptive Execute는 combined를 추가하고, legacy 네 이름은 기존 round를 계속
|
|
16
|
+
// 통과시킨다. security는 위험 flag가 있을 때만 별도 call로 붙는다.
|
|
17
|
+
// Plan 쪽 CONTEXT_REVIEW_PERSPECTIVE와는 이름을 섞지 않아야 하므로 병합하지 않는다.
|
|
18
|
+
const REVIEW_PERSPECTIVE = [
|
|
19
|
+
'combined',
|
|
20
|
+
'spec_scope',
|
|
21
|
+
'correctness_tests',
|
|
22
|
+
'minimality_maintainability',
|
|
23
|
+
'security',
|
|
24
|
+
];
|
|
25
|
+
module.exports = {
|
|
26
|
+
REVIEW_RISK_ENUM,
|
|
27
|
+
REVIEW_PERSPECTIVE,
|
|
28
|
+
};
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const guards = require("./guards");
|
|
3
|
+
const { isRecord } = guards;
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const frontmatter = require("./frontmatter");
|
|
7
|
+
const { parseFrontmatter } = frontmatter;
|
|
8
|
+
const render = require("./render");
|
|
9
|
+
const { renderDoc } = render;
|
|
10
|
+
const paths = require("./paths");
|
|
11
|
+
const { toPosix } = paths;
|
|
12
|
+
const validateSections = require("./validate-sections");
|
|
13
|
+
const { normalizeRoundFindings, collectFindingFailures, EXECUTE_REVIEW_STATUS, EXECUTE_ROUND_CONTRACT, BLUEPRINT_ROUND_CONTRACT, BRIEF_ROUND_CONTRACT, } = validateSections;
|
|
14
|
+
const schema = require("./schema");
|
|
15
|
+
const { reviewContractOf } = schema;
|
|
16
|
+
const reviewSnapshot = require("./review-snapshot");
|
|
17
|
+
const { loadReviewSnapshot, checkReviewSnapshotCurrent } = reviewSnapshot;
|
|
18
|
+
/** review_contract가 허용 밖 값일 때의 공통 거절. record와 accept-risk가 같은 답을 낸다. */
|
|
19
|
+
function contractInvalid() {
|
|
20
|
+
return fail(schema.REVIEW_CONTRACT_INVALID_REASON, schema.REVIEW_CONTRACT_INVALID_CAUSE, 'fix review_contract in the blueprint index.md (brief or omit it), then retry');
|
|
21
|
+
}
|
|
22
|
+
function fail(reason, cause, next) {
|
|
23
|
+
return { ok: false, reason, cause, next };
|
|
24
|
+
}
|
|
25
|
+
function cloneJson(value) {
|
|
26
|
+
return JSON.parse(JSON.stringify(value));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* blueprint index.md의 review_scope·review_contract를 읽어 리뷰 범위를 정한다.
|
|
30
|
+
* scaffold.isBlueprintReviewScope와 같은 범위 판정이지만 그 헬퍼는 scaffold 공개 표면에 없어
|
|
31
|
+
* 여기 읽기만 복제한다 — scaffold.ts는 이 task의 Touch가 아니다. 계약 해석은 schema의
|
|
32
|
+
* reviewContractOf를 그대로 써서 S38·review-dispatch·G21과 같은 답을 낸다.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} repoRoot - 저장소 루트
|
|
35
|
+
* @param {string} blueprintDirRel - blueprint 상대 경로
|
|
36
|
+
* @returns {ReviewScope} `review_scope !== 'blueprint'`면 'task'. blueprint 범위에서 review_contract가
|
|
37
|
+
* brief면 'blueprint_brief', 키 부재면 'blueprint', 허용 밖 값이면 'contract_invalid'
|
|
38
|
+
*/
|
|
39
|
+
function reviewScopeAt(repoRoot, blueprintDirRel) {
|
|
40
|
+
const abs = path.join(repoRoot, blueprintDirRel, 'index.md');
|
|
41
|
+
let data;
|
|
42
|
+
try {
|
|
43
|
+
({ data } = parseFrontmatter(fs.readFileSync(abs, 'utf8')));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// 파일 없음·프론트매터 파싱 실패는 구형(task) 계약으로 본다. blueprint로 접으면
|
|
47
|
+
// --task를 거절해 대상 파일을 영원히 못 고른다.
|
|
48
|
+
return 'task';
|
|
49
|
+
}
|
|
50
|
+
if (!isRecord(data) || !isRecord(data.bouncer))
|
|
51
|
+
return 'task';
|
|
52
|
+
if (data.bouncer.review_scope !== 'blueprint')
|
|
53
|
+
return 'task';
|
|
54
|
+
const contract = reviewContractOf(data.bouncer);
|
|
55
|
+
if (contract === null)
|
|
56
|
+
return 'contract_invalid';
|
|
57
|
+
return contract === 'brief' ? 'blueprint_brief' : 'blueprint';
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 기록 뒤 mode 순서가 허용 완결 순서 중 하나의 앞부분인지 본다.
|
|
61
|
+
* collectFindingFailures는 완결 순서만 통과시켜 진행 중 원장을 막으므로,
|
|
62
|
+
* 여기만 접두를 허용한다 — 검사 규칙 복제가 아니라 예외 판정이다.
|
|
63
|
+
*
|
|
64
|
+
* @param {unknown[]} rounds - 병합 뒤 rounds
|
|
65
|
+
* @param {'task' | 'blueprint' | 'blueprint_brief'} scope - 범위에 맞는 계약의 sequences로 판정
|
|
66
|
+
* @returns {boolean} 허용 sequences 원소를 `,`로 나눈 배열의 길이 n 접두면 true
|
|
67
|
+
*/
|
|
68
|
+
function isAllowedSequencePrefix(rounds, scope) {
|
|
69
|
+
const modes = [];
|
|
70
|
+
for (const entry of rounds) {
|
|
71
|
+
if (isRecord(entry) && typeof entry.mode === 'string')
|
|
72
|
+
modes.push(entry.mode);
|
|
73
|
+
}
|
|
74
|
+
const joined = modes.join(',');
|
|
75
|
+
if (joined === '')
|
|
76
|
+
return false;
|
|
77
|
+
let contract = EXECUTE_ROUND_CONTRACT;
|
|
78
|
+
if (scope === 'blueprint')
|
|
79
|
+
contract = BLUEPRINT_ROUND_CONTRACT;
|
|
80
|
+
if (scope === 'blueprint_brief')
|
|
81
|
+
contract = BRIEF_ROUND_CONTRACT;
|
|
82
|
+
for (const sequence of contract.sequences) {
|
|
83
|
+
const parts = sequence.split(',');
|
|
84
|
+
for (let n = 1; n <= parts.length; n += 1) {
|
|
85
|
+
if (parts.slice(0, n).join(',') === joined)
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 새 라운드의 target.snapshot_id가 실제 동결 상태에 묶였는지, 그리고 acceptance라면 그 상태가
|
|
93
|
+
* 지금도 현재 작업 트리와 같은지 검사한다. 저장 문서 검증기(G14/G21)는 과거 기록을 위해
|
|
94
|
+
* id가 없는 기록도 받으므로, "새 입력에는 id 필수"와 "지금도 신선한가"는 쓰기 시점의
|
|
95
|
+
* 이 함수만 판정한다. 호출부가 쓰기 전에 부르므로 거절은 항상 파일을 건드리지 않는다.
|
|
96
|
+
*
|
|
97
|
+
* 이 라운드의 snapshot만 본다. delta 뒤에는 앞 discovery snapshot이 정상적으로 stale이므로
|
|
98
|
+
* 이전 라운드의 id로 acceptance를 막으면 수정 후 재승인이 영원히 불가능해진다.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} args - 검사 입력
|
|
101
|
+
* @param {string} args.repoRoot - 저장소 루트
|
|
102
|
+
* @param {string} args.blueprintDir - blueprint 상대 경로
|
|
103
|
+
* @param {string | null} args.task - task 리뷰면 세 자리 id, blueprint 모드면 null
|
|
104
|
+
* @param {Record<string, unknown>} args.round - 이번에 추가할 round 객체
|
|
105
|
+
* @param {boolean} args.accepted - 기록 뒤 effective status가 accepted인지(상속 포함)
|
|
106
|
+
* @returns {RecordReviewFail | null} 거절 사유, 통과하면 null
|
|
107
|
+
*/
|
|
108
|
+
function checkSnapshotBinding(args) {
|
|
109
|
+
const { repoRoot, blueprintDir, task, round, accepted } = args;
|
|
110
|
+
const target = isRecord(round.target) ? round.target : {};
|
|
111
|
+
const snapshotId = target.snapshot_id;
|
|
112
|
+
if (snapshotId === undefined || snapshotId === null || snapshotId === '') {
|
|
113
|
+
return fail('review-snapshot-required', 'round.target.snapshot_id is required for a new round', 'run review-dispatch execute for this scope and put its snapshot_id in round.target');
|
|
114
|
+
}
|
|
115
|
+
// 형식·존재·손상은 loader가 한 번에 가린다. 형식 검사 없이 경로에 쓰면 입력이 runtime 밖을 가리킨다.
|
|
116
|
+
const loaded = loadReviewSnapshot({ repoRoot, snapshotId: snapshotId });
|
|
117
|
+
if (!loaded.ok) {
|
|
118
|
+
return fail('review-snapshot-invalid', `${loaded.reason}: ${loaded.cause}`, loaded.next);
|
|
119
|
+
}
|
|
120
|
+
const stored = loaded.snapshot;
|
|
121
|
+
const scopeTask = task || null;
|
|
122
|
+
if (stored.scope.blueprint !== blueprintDir || stored.scope.task !== scopeTask) {
|
|
123
|
+
return fail('review-snapshot-mismatch', `snapshot scope ${JSON.stringify(stored.scope)} differs from this review`, 'run review-dispatch execute for this blueprint/task and use its snapshot_id');
|
|
124
|
+
}
|
|
125
|
+
if (target.base !== stored.target.base || target.head !== stored.target.head) {
|
|
126
|
+
return fail('review-snapshot-mismatch', 'round.target base/head differ from the snapshot target', 'copy target.base and target.head from the same review-dispatch execute output as snapshot_id');
|
|
127
|
+
}
|
|
128
|
+
if (!accepted)
|
|
129
|
+
return null;
|
|
130
|
+
const current = checkReviewSnapshotCurrent({
|
|
131
|
+
repoRoot,
|
|
132
|
+
blueprintDir,
|
|
133
|
+
taskId: scopeTask,
|
|
134
|
+
base: stored.target.base,
|
|
135
|
+
head: stored.target.head,
|
|
136
|
+
snapshotId: snapshotId,
|
|
137
|
+
});
|
|
138
|
+
if (current.ok)
|
|
139
|
+
return null;
|
|
140
|
+
// HEAD가 움직인 것도 "리뷰한 상태와 지금이 다르다"이므로 stale로 안내한다.
|
|
141
|
+
// 범위·target 차이는 위에서 이미 걸렀으므로 여기 남는 건 stale 계열과 수집 실패다.
|
|
142
|
+
if (current.reason === 'snapshot-stale' || current.reason === 'head-mismatch') {
|
|
143
|
+
return fail('review-snapshot-stale', `${current.reason}: ${current.cause}`, 'the worktree changed after the review snapshot; dispatch a new review on the current state '
|
|
144
|
+
+ 'and re-review before accepting');
|
|
145
|
+
}
|
|
146
|
+
return fail('review-snapshot-invalid', `${current.reason}: ${current.cause}`, current.next);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* 입력 finding을 정규화한 뒤 같은 id는 통째로 교체하고 새 id는 끝에 붙인다. fingerprint는
|
|
150
|
+
* 입력값과 무관하게 게이트와 같은 findingFingerprint로 덮어쓰고, `id` 키가 없으면
|
|
151
|
+
* `F-<8hex>` 자동 id를 채운다 — 작성자가 validator 공식을 몰라도 기록되게 한다.
|
|
152
|
+
* 기존 원장 finding은 고치지 않는다: 수기로 틀린 저장 기록은 G14/G21이 계속 거절해야 한다.
|
|
153
|
+
*
|
|
154
|
+
* @param {unknown[]} existing - 기존 findings
|
|
155
|
+
* @param {unknown[]} incoming - 입력 JSON findings
|
|
156
|
+
* @returns {{ok: true, merged: unknown[], incoming: unknown[], ids: string[]} | RecordReviewFail}
|
|
157
|
+
* 병합 결과, 정규화한 이번 입력, 이번 입력의 정규화 id(순서 유지·중복 제거), 또는 id 오류·충돌 거절
|
|
158
|
+
*/
|
|
159
|
+
function mergeFindings(existing, incoming) {
|
|
160
|
+
const normalized = normalizeRoundFindings(incoming, existing);
|
|
161
|
+
if (!normalized.ok) {
|
|
162
|
+
return fail('review-input-invalid', normalized.messages, 'give each finding a non-empty string id or omit the id key; resolve id collisions, then retry');
|
|
163
|
+
}
|
|
164
|
+
const merged = existing.map((item) => cloneJson(item));
|
|
165
|
+
for (const finding of normalized.findings) {
|
|
166
|
+
const id = isRecord(finding) ? finding.id : undefined;
|
|
167
|
+
const index = merged.findIndex((item) => isRecord(item) && item.id === id);
|
|
168
|
+
if (index >= 0)
|
|
169
|
+
merged[index] = finding;
|
|
170
|
+
else
|
|
171
|
+
merged.push(finding);
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, merged, incoming: normalized.findings, ids: normalized.ids };
|
|
174
|
+
}
|
|
175
|
+
function readRoundInput(roundFile) {
|
|
176
|
+
let raw;
|
|
177
|
+
try {
|
|
178
|
+
raw = fs.readFileSync(roundFile, 'utf8');
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
// ENOENT·EACCES를 파싱 실패와 같은 reason으로 묶는다. 입력 파일이 없으면
|
|
182
|
+
// 원장을 손대지 않아야 하고, 권한 오류를 target-missing으로 위장하면
|
|
183
|
+
// 없는 review.md를 만들라는 안내가 된다.
|
|
184
|
+
const cause = error && typeof error === 'object' && 'code' in error
|
|
185
|
+
? String(error.code)
|
|
186
|
+
: String(error.message || error);
|
|
187
|
+
return fail('review-input-invalid', cause, 'fix --round JSON: object with round and findings array');
|
|
188
|
+
}
|
|
189
|
+
let parsed;
|
|
190
|
+
try {
|
|
191
|
+
parsed = JSON.parse(raw);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
// SyntaxError만 흡수. 그 외 throw는 JSON 파서가 아닌 호출부 버그다.
|
|
195
|
+
if (!(error instanceof SyntaxError))
|
|
196
|
+
throw error;
|
|
197
|
+
return fail('review-input-invalid', error.message, 'fix --round JSON: object with round and findings array');
|
|
198
|
+
}
|
|
199
|
+
if (!isRecord(parsed) || !isRecord(parsed.round)) {
|
|
200
|
+
return fail('review-input-invalid', 'round object missing', 'fix --round JSON: object with round and findings array');
|
|
201
|
+
}
|
|
202
|
+
if (!Array.isArray(parsed.findings)) {
|
|
203
|
+
return fail('review-input-invalid', 'findings must be an array', 'fix --round JSON: object with round and findings array');
|
|
204
|
+
}
|
|
205
|
+
return { round: parsed.round, findings: parsed.findings };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* review.md를 읽어 쓰기용 사본과 bouncer·review 블록을 돌려준다. recordReview와
|
|
209
|
+
* acceptReviewRisk가 같은 방식으로 문서를 열도록 한 곳에 둔다.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} abs - review.md 절대 경로
|
|
212
|
+
* @returns {{ nextData: Record<string, unknown>, body: string, bouncer: Record<string, unknown>,
|
|
213
|
+
* review: Record<string, unknown> }} 편집 가능한 front matter 사본과 본문
|
|
214
|
+
*/
|
|
215
|
+
function loadReviewDoc(abs) {
|
|
216
|
+
const { data, body } = parseFrontmatter(fs.readFileSync(abs, 'utf8'));
|
|
217
|
+
const nextData = isRecord(data) ? cloneJson(data) : {};
|
|
218
|
+
if (!isRecord(nextData.bouncer))
|
|
219
|
+
nextData.bouncer = {};
|
|
220
|
+
const bouncer = nextData.bouncer;
|
|
221
|
+
if (!isRecord(bouncer.review))
|
|
222
|
+
bouncer.review = {};
|
|
223
|
+
const review = bouncer.review;
|
|
224
|
+
return { nextData, body, bouncer, review };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* 검증을 통과한 review.md 본문을 임시 파일로 쓴 뒤 rename으로 교체한다. renameSync는
|
|
228
|
+
* 같은 디렉터리라 원자적이고, 실패하면 tmp를 지워 재시도가 깨진 바이트를 읽지 않게 한다.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} abs - review.md 절대 경로
|
|
231
|
+
* @param {string} text - 렌더링된 문서 전체
|
|
232
|
+
* @returns {void}
|
|
233
|
+
*/
|
|
234
|
+
function writeReviewDocAtomically(abs, text) {
|
|
235
|
+
const tmp = path.join(path.dirname(abs), `.review.md.${process.pid}.tmp`);
|
|
236
|
+
try {
|
|
237
|
+
fs.writeFileSync(tmp, text);
|
|
238
|
+
fs.renameSync(tmp, abs);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
try {
|
|
242
|
+
fs.unlinkSync(tmp);
|
|
243
|
+
}
|
|
244
|
+
catch (unlinkError) {
|
|
245
|
+
// ENOENT만 흡수: rename이 이미 옮겼거나 write가 실패해 파일이 없다.
|
|
246
|
+
if (!isRecord(unlinkError) || unlinkError.code !== 'ENOENT')
|
|
247
|
+
throw unlinkError;
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* 라운드 하나와 finding 갱신을 review.md에 기록한다. 쓰기는 collectFindingFailures가
|
|
254
|
+
* G21·G14와 같은 인자로 통과한 뒤에만 같은 디렉터리 임시 파일 + rename이다.
|
|
255
|
+
*
|
|
256
|
+
* @param {RecordReviewInput} input - repoRoot, blueprintDir, optional task/status, roundFile
|
|
257
|
+
* @param {string} input.repoRoot - 저장소 루트 절대 경로
|
|
258
|
+
* @param {string} input.blueprintDir - blueprint 상대 경로
|
|
259
|
+
* @param {string|null} [input.task] - task 리뷰일 때 세 자리 id. blueprint 모드면 없어야 함
|
|
260
|
+
* @param {string} input.roundFile - round·findings JSON 경로
|
|
261
|
+
* @param {string|null} [input.status] - 문서 bouncer.status. 없으면 기존 값을 유지
|
|
262
|
+
* @returns {RecordReviewResult} 성공 시 path·round·status·findings 수, 실패 시 reason·cause·next
|
|
263
|
+
*/
|
|
264
|
+
function recordReview(input) {
|
|
265
|
+
const repoRoot = input.repoRoot;
|
|
266
|
+
const blueprintDir = toPosix(input.blueprintDir);
|
|
267
|
+
const task = input.task == null || input.task === '' ? null : String(input.task);
|
|
268
|
+
const status = input.status == null || input.status === '' ? null : String(input.status);
|
|
269
|
+
// 1. JSON을 먼저 거절한다. 대상 파일 판정보다 입력이 틀리면 경로 안내가 섞인다.
|
|
270
|
+
const parsedInput = readRoundInput(input.roundFile);
|
|
271
|
+
if ('reason' in parsedInput)
|
|
272
|
+
return parsedInput;
|
|
273
|
+
const scope = reviewScopeAt(repoRoot, blueprintDir);
|
|
274
|
+
// 허용 밖 review_contract는 legacy로 접지 않고 거절한다. 파일을 읽기 전이라 바이트가 그대로다.
|
|
275
|
+
if (scope === 'contract_invalid')
|
|
276
|
+
return contractInvalid();
|
|
277
|
+
const blueprintScope = scope !== 'task';
|
|
278
|
+
const briefScope = scope === 'blueprint_brief';
|
|
279
|
+
if (blueprintScope && task) {
|
|
280
|
+
return fail('review-task-not-allowed', '--task is not allowed when review_scope is blueprint', 'omit --task and record the blueprint-root review.md');
|
|
281
|
+
}
|
|
282
|
+
if (!blueprintScope && !task) {
|
|
283
|
+
return fail('review-task-required', '--task is required when review_scope is not blueprint', 'pass --task <ddd> for tasks/<ddd>/review.md');
|
|
284
|
+
}
|
|
285
|
+
// 표식 없는 최종 리뷰는 discovery 한 라운드로 끝난다. 새 root delta는 파일을 읽기 전에 거절해
|
|
286
|
+
// 저장 문서가 바뀌지 않게 한다. 이미 저장된 discovery,delta,delta 이력은 G21이 계속 읽는다.
|
|
287
|
+
// 표식(brief) blueprint는 저장된 delta 유무를 알아야 하므로 아래에서 파일을 읽은 뒤 판정한다.
|
|
288
|
+
if (scope === 'blueprint' && parsedInput.round.mode === 'delta') {
|
|
289
|
+
return fail('final-review-single-round', 'blueprint final review is a single discovery round; a new delta round is not accepted', 'record the discovery round only; defer must_fix findings with a note or report them as blocked');
|
|
290
|
+
}
|
|
291
|
+
const rel = blueprintScope
|
|
292
|
+
? `${blueprintDir}/review.md`
|
|
293
|
+
: `${blueprintDir}/tasks/${task}/review.md`;
|
|
294
|
+
const abs = path.join(repoRoot, rel);
|
|
295
|
+
if (!fs.existsSync(abs)) {
|
|
296
|
+
return fail('review-target-missing', rel, 'scaffold the review.md for this blueprint or task, then retry');
|
|
297
|
+
}
|
|
298
|
+
const { nextData, body, bouncer, review } = loadReviewDoc(abs);
|
|
299
|
+
const existingRounds = Array.isArray(review.rounds) ? cloneJson(review.rounds) : [];
|
|
300
|
+
// 표식 blueprint는 위반을 고친 뒤 delta 한 번만 인증에 쓴다. 이미 delta가 저장돼 있으면 두 번째를
|
|
301
|
+
// 쓰기 전에 거절한다 — 남은 위반은 blocked로 보고하고 accept-risk가 닫는다.
|
|
302
|
+
if (briefScope && parsedInput.round.mode === 'delta'
|
|
303
|
+
&& existingRounds.some((entry) => isRecord(entry) && entry.mode === 'delta')) {
|
|
304
|
+
return fail('final-review-single-round', 'brief final review allows discovery and one delta round; a second delta round is not accepted', 'discovery and one delta are used; report remaining violations as blocked');
|
|
305
|
+
}
|
|
306
|
+
const expectedRound = existingRounds.length + 1;
|
|
307
|
+
if (parsedInput.round.round !== expectedRound) {
|
|
308
|
+
return fail('review-round-out-of-sequence', `expected ${expectedRound}, got ${String(parsedInput.round.round)}`, `set round.round to ${expectedRound}`);
|
|
309
|
+
}
|
|
310
|
+
// 2. 라운드는 끝에만 붙인다. 중간 삽입은 번호 계약을 깨 G21과 어긋난다.
|
|
311
|
+
const mergedRounds = [...existingRounds, cloneJson(parsedInput.round)];
|
|
312
|
+
const existingFindings = Array.isArray(review.findings) ? review.findings : [];
|
|
313
|
+
const mergeResult = mergeFindings(existingFindings, parsedInput.findings);
|
|
314
|
+
if (!mergeResult.ok)
|
|
315
|
+
return mergeResult;
|
|
316
|
+
const mergedFindings = mergeResult.merged;
|
|
317
|
+
review.rounds = mergedRounds;
|
|
318
|
+
review.findings = mergedFindings;
|
|
319
|
+
if (status)
|
|
320
|
+
bouncer.status = status;
|
|
321
|
+
// 2-1. 스냅샷 바인딩은 원장 검증 앞에서 본다. 뒤에 두면 잘못된 id가 저장 검증기의
|
|
322
|
+
// 형식 메시지(review-ledger-invalid)로 먼저 나가 reason이 뭉개진다. 어느 쪽이든
|
|
323
|
+
// 쓰기 전이라 거절은 파일을 건드리지 않는다. accepted는 이번 --status뿐 아니라
|
|
324
|
+
// 상속한 상태도 포함한다.
|
|
325
|
+
const snapshotFailure = checkSnapshotBinding({
|
|
326
|
+
repoRoot,
|
|
327
|
+
blueprintDir,
|
|
328
|
+
task,
|
|
329
|
+
round: parsedInput.round,
|
|
330
|
+
accepted: bouncer.status === 'accepted',
|
|
331
|
+
});
|
|
332
|
+
if (snapshotFailure)
|
|
333
|
+
return snapshotFailure;
|
|
334
|
+
const messages = collectFindingFailures({
|
|
335
|
+
body,
|
|
336
|
+
findings: mergedFindings,
|
|
337
|
+
rounds: mergedRounds,
|
|
338
|
+
sectionLabel: 'review.md',
|
|
339
|
+
findingLabel: 'review',
|
|
340
|
+
allowedStatuses: EXECUTE_REVIEW_STATUS,
|
|
341
|
+
reviewStatus: bouncer.status,
|
|
342
|
+
// G21과 같은 계약으로 판정해야 기록과 마감 게이트가 서로 다른 답을 내지 않는다.
|
|
343
|
+
roundScope: briefScope ? 'blueprint_brief' : (blueprintScope ? 'blueprint' : undefined),
|
|
344
|
+
});
|
|
345
|
+
const sequenceOnly = messages.length === 1 && messages[0] === 'review rounds sequence invalid';
|
|
346
|
+
const inProgressOk = sequenceOnly
|
|
347
|
+
&& isAllowedSequencePrefix(mergedRounds, scope)
|
|
348
|
+
&& bouncer.status !== 'accepted';
|
|
349
|
+
if (messages.length > 0 && !inProgressOk) {
|
|
350
|
+
return fail('review-ledger-invalid', messages, 'fix rounds and findings until collectFindingFailures is empty, then retry');
|
|
351
|
+
}
|
|
352
|
+
// 3. 검증 통과 뒤에만 임시 파일을 만든다. 실패 경로에 tmp를 남기면 재시도가
|
|
353
|
+
// 깨진 바이트를 읽을 수 있다. renameSync는 같은 디렉터리라 원자적이다.
|
|
354
|
+
writeReviewDocAtomically(abs, renderDoc(nextData, body));
|
|
355
|
+
// 경고는 이번 round 입력만 대상으로 하고(기존 원장은 제외) 기록에는 영향을 주지 않는다.
|
|
356
|
+
// 자동 id가 경고 ids에도 보이도록 정규화 뒤 입력을 쓴다.
|
|
357
|
+
const warnings = validateSections.findingMergeCandidates(mergeResult.incoming);
|
|
358
|
+
return {
|
|
359
|
+
ok: true,
|
|
360
|
+
path: rel,
|
|
361
|
+
round: expectedRound,
|
|
362
|
+
status: bouncer.status,
|
|
363
|
+
findings: mergedFindings.length,
|
|
364
|
+
finding_ids: mergeResult.ids,
|
|
365
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* blueprint 최종 리뷰의 open finding 하나를 사용자가 수용한 위험으로 닫는다. 최종 리뷰는
|
|
370
|
+
* discovery 한 라운드로 끝나 blocked finding이 open으로 남는데, round를 더할 수 없으므로
|
|
371
|
+
* (final-review-single-round) 이 경로가 round 없이 finding만 accepted + accepted_risk로 바꾼다.
|
|
372
|
+
* 남은 open이 0이면 문서도 accepted로 올리고, 아니면 기존 status를 유지한다.
|
|
373
|
+
* `yes`가 없으면 파일을 읽기 전에 거절한다 — 사용자 승인 경계다.
|
|
374
|
+
* 쓰기는 recordReview와 같은 temp+rename이며 collectFindingFailures 통과 뒤에만 한다.
|
|
375
|
+
*
|
|
376
|
+
* @param {AcceptRiskInput} input - 수용 입력
|
|
377
|
+
* @param {string} input.repoRoot - 저장소 루트 절대 경로
|
|
378
|
+
* @param {string} input.blueprintDir - blueprint 상대 경로
|
|
379
|
+
* @param {string} input.finding - 닫을 finding id
|
|
380
|
+
* @param {string} input.note - 수용 사유. 공백뿐이면 거절
|
|
381
|
+
* @param {boolean} input.yes - 사용자 명시 승인 여부
|
|
382
|
+
* @returns {AcceptRiskOk | RecordReviewFail} 성공 시 path·finding·status·open_remaining, 실패 시 reason·cause·next
|
|
383
|
+
*/
|
|
384
|
+
function acceptReviewRisk(input) {
|
|
385
|
+
const { repoRoot } = input;
|
|
386
|
+
const blueprintDir = toPosix(input.blueprintDir);
|
|
387
|
+
// 1. 승인 경계는 어떤 I/O보다 먼저 본다.
|
|
388
|
+
if (input.yes !== true) {
|
|
389
|
+
return fail('review-accept-risk-confirmation-required', '--yes is required to record an accepted risk', 'ask the user to explicitly approve accepting this risk, then rerun with --yes');
|
|
390
|
+
}
|
|
391
|
+
const scope = reviewScopeAt(repoRoot, blueprintDir);
|
|
392
|
+
if (scope === 'contract_invalid')
|
|
393
|
+
return contractInvalid();
|
|
394
|
+
if (scope === 'task') {
|
|
395
|
+
return fail('review-accept-risk-task-scope', 'accept-risk is only available when review_scope is blueprint', 'task reviews cannot accept risk; fix the finding or record it through review record');
|
|
396
|
+
}
|
|
397
|
+
const rel = `${blueprintDir}/review.md`;
|
|
398
|
+
const abs = path.join(repoRoot, rel);
|
|
399
|
+
if (!fs.existsSync(abs)) {
|
|
400
|
+
return fail('review-target-missing', rel, 'scaffold the review.md for this blueprint, then retry');
|
|
401
|
+
}
|
|
402
|
+
const { nextData, body, bouncer, review } = loadReviewDoc(abs);
|
|
403
|
+
const findings = Array.isArray(review.findings) ? review.findings : [];
|
|
404
|
+
const target = findings.find((item) => isRecord(item) && item.id === input.finding);
|
|
405
|
+
if (!isRecord(target)) {
|
|
406
|
+
return fail('review-finding-missing', `finding ${input.finding} not found in ${rel}`, 'pass an existing --finding id');
|
|
407
|
+
}
|
|
408
|
+
if (target.status !== 'open') {
|
|
409
|
+
return fail('review-finding-not-open', `finding ${input.finding} status is ${String(target.status)}`, 'only an open finding can be accepted as a risk');
|
|
410
|
+
}
|
|
411
|
+
const note = typeof input.note === 'string' ? input.note.trim() : '';
|
|
412
|
+
if (note === '') {
|
|
413
|
+
return fail('review-note-required', '--note is empty', 'pass the reason the user accepted this risk');
|
|
414
|
+
}
|
|
415
|
+
// 2. 대상 finding만 바꾼다. rounds와 다른 finding은 손대지 않는다.
|
|
416
|
+
target.status = 'accepted';
|
|
417
|
+
target.accepted_risk = true;
|
|
418
|
+
target.note = note;
|
|
419
|
+
const openRemaining = findings.filter((item) => isRecord(item) && item.status === 'open').length;
|
|
420
|
+
if (openRemaining === 0)
|
|
421
|
+
bouncer.status = 'accepted';
|
|
422
|
+
const messages = collectFindingFailures({
|
|
423
|
+
body,
|
|
424
|
+
findings,
|
|
425
|
+
rounds: review.rounds,
|
|
426
|
+
sectionLabel: 'review.md',
|
|
427
|
+
findingLabel: 'review',
|
|
428
|
+
allowedStatuses: EXECUTE_REVIEW_STATUS,
|
|
429
|
+
reviewStatus: bouncer.status,
|
|
430
|
+
roundScope: scope === 'blueprint_brief' ? 'blueprint_brief' : 'blueprint',
|
|
431
|
+
});
|
|
432
|
+
// 남은 open이 있는 부분 수용은 문서가 requested라 정상이다. 완결 순서 검사만 접두 예외를 건다.
|
|
433
|
+
const sequenceOnly = messages.length === 1 && messages[0] === 'review rounds sequence invalid';
|
|
434
|
+
const inProgressOk = sequenceOnly
|
|
435
|
+
&& Array.isArray(review.rounds)
|
|
436
|
+
&& isAllowedSequencePrefix(review.rounds, scope)
|
|
437
|
+
&& bouncer.status !== 'accepted';
|
|
438
|
+
if (messages.length > 0 && !inProgressOk) {
|
|
439
|
+
return fail('review-ledger-invalid', messages, 'fix rounds and findings until collectFindingFailures is empty, then retry');
|
|
440
|
+
}
|
|
441
|
+
// 3. 검증 통과 뒤에만 쓴다. recordReview와 같은 temp+rename.
|
|
442
|
+
writeReviewDocAtomically(abs, renderDoc(nextData, body));
|
|
443
|
+
return {
|
|
444
|
+
ok: true, path: rel, finding: input.finding, status: bouncer.status, open_remaining: openRemaining,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
module.exports = { recordReview, acceptReviewRisk };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const frontmatter = require("./frontmatter");
|
|
3
|
+
const { readDoc } = frontmatter;
|
|
4
|
+
const coordinatorWorker = require("./coordinator-worker");
|
|
5
|
+
const { readBouncerBlock } = coordinatorWorker;
|
|
6
|
+
/**
|
|
7
|
+
* 원장 decisions에 리뷰 원인 repair(`kind: 'repair'`, `failure.command: 'review'`)가 이미 있는지
|
|
8
|
+
* 판정한다. 입력 배열만 읽고 바꾸지 않는 순수 predicate라 호출자가 같은 원장에서 같은 답을 얻는다.
|
|
9
|
+
*
|
|
10
|
+
* @param {readonly unknown[] | undefined} decisions - 원장 decisions(부재 허용)
|
|
11
|
+
* @returns {boolean} 리뷰 원인 repair decision이 있으면 true
|
|
12
|
+
*/
|
|
13
|
+
function hasReviewRepairDecision(decisions) {
|
|
14
|
+
return (decisions || []).some((entry) => {
|
|
15
|
+
const item = entry;
|
|
16
|
+
return item?.kind === 'repair' && item.failure?.command === 'review';
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* review.md의 `bouncer.review.rounds[]`에 주어진 mode의 라운드가 있는지 읽는다.
|
|
21
|
+
* 오류 의미는 호출자 정책으로 갈린다. 이 차이는 의도된 유지이며 통일하지 않는다.
|
|
22
|
+
* - strict: 문서 부재(ENOENT)만 false로 흡수한다. YAML 파손 같은 다른 실패를 없음으로 접으면
|
|
23
|
+
* 이미 끝난 리뷰에 repair·supplement가 다시 열리므로 그대로 던진다(repair 접수 경계용).
|
|
24
|
+
* - tolerant: 문서 부재·frontmatter 없음·깨진 YAML을 없음으로 본다. 판독 불가를 예외로 올리면
|
|
25
|
+
* 다음 작업 선택이 막히기 때문이다. 그 밖의 I/O 오류는 readBouncerBlock이 그대로 던진다.
|
|
26
|
+
*
|
|
27
|
+
* @param {{ file: string; error_policy: RoundErrorPolicy }} input - review.md 절대 경로와 오류 정책
|
|
28
|
+
* @param {'discovery' | 'delta'} mode - 찾을 라운드 mode
|
|
29
|
+
* @returns {boolean} 해당 mode 라운드가 있으면 true
|
|
30
|
+
*/
|
|
31
|
+
function reviewRoundExists(input, mode) {
|
|
32
|
+
let review;
|
|
33
|
+
if (input.error_policy === 'strict') {
|
|
34
|
+
let data;
|
|
35
|
+
try {
|
|
36
|
+
data = readDoc(input.file).data;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === 'ENOENT')
|
|
40
|
+
return false;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
review = data?.bouncer?.review;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
review = readBouncerBlock(input.file)?.review;
|
|
47
|
+
}
|
|
48
|
+
const rounds = review?.rounds;
|
|
49
|
+
return Array.isArray(rounds)
|
|
50
|
+
&& rounds.some((round) => round?.mode === mode);
|
|
51
|
+
}
|
|
52
|
+
module.exports = { hasReviewRepairDecision, reviewRoundExists };
|