@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,654 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { createHash } = require('node:crypto');
|
|
3
|
+
const guards = require("./guards");
|
|
4
|
+
const { isRecord } = guards;
|
|
5
|
+
const paths = require("./paths");
|
|
6
|
+
const reviewPolicy = require("./review-policy");
|
|
7
|
+
// pathsOverlap 정본은 path-overlap.ts다. scope 등 하위 모듈이 검증 계층 없이 쓸 수
|
|
8
|
+
// 있도록 옮겼고, 기존 소비자는 이 모듈의 export로 같은 이름을 계속 받는다.
|
|
9
|
+
const pathOverlapMod = require("./path-overlap");
|
|
10
|
+
const { pathsOverlap } = pathOverlapMod;
|
|
11
|
+
const { toPosix } = paths;
|
|
12
|
+
const SECTION_DEFS = [
|
|
13
|
+
{ key: 'goal', re: /^##\s+(Goal\s*&\s*intent|목적[·・.]?의도)\s*$/i },
|
|
14
|
+
// 선택 절: 필수 목록에는 없지만 경계로 등록한다. def가 없으면
|
|
15
|
+
// Goal & intent에 흡수되어 placeholder 검사가 goal로만 보고된다.
|
|
16
|
+
{ key: 'currentBehavior', re: /^##\s+(Current\s+behavior|현재\s*동작)\s*$/i },
|
|
17
|
+
{ key: 'targetBehavior', re: /^##\s+(Target\s+behavior|목표\s*동작)\s*$/i },
|
|
18
|
+
{ key: 'interface', re: /^##\s+(Interface|인터페이스)\s*$/i },
|
|
19
|
+
{ key: 'touch', re: /^##\s+(Touch|수정할\s*부분)\s*$/i },
|
|
20
|
+
{ key: 'doNotTouch', re: /^##\s+(Do\s+not\s+touch|절대\s*수정\s*금지)\s*$/i },
|
|
21
|
+
// 경계로 파싱하지만 필수 목록에는 없음: 여기에 def가 없으면 해당 본문이
|
|
22
|
+
// Do not touch에 흡수되어 path overlap을 잘못 만들어 냄.
|
|
23
|
+
{ key: 'constraints', re: /^##\s+(Constraints|제약)\s*$/i },
|
|
24
|
+
{ key: 'checklist', re: /^##\s+(Checklist|체크리스트)\s*$/i },
|
|
25
|
+
];
|
|
26
|
+
const VERIFY_SECTION_DEFS = [
|
|
27
|
+
{ key: 'command', re: /^##\s+(Command|명령(?:어)?)\s*$/i },
|
|
28
|
+
{ key: 'evidence', re: /^##\s+(Evidence|증적|증거)\s*$/i },
|
|
29
|
+
];
|
|
30
|
+
const REVIEW_SECTION_DEFS = [
|
|
31
|
+
{ key: 'findings', re: /^##\s+(Findings|발견사항|리뷰\s*결과)\s*$/i },
|
|
32
|
+
];
|
|
33
|
+
const REVIEW_SEVERITY = ['blocker', 'major', 'minor', 'nit'];
|
|
34
|
+
// context review는 기존 두 값만 유지한다. execute만 deferred를 추가한다 —
|
|
35
|
+
// 한 배열을 공유하면 context review 기록이 후속 이연을 계획 문서에 허용하게 된다.
|
|
36
|
+
// `open`(해결 전)도 execute 전용이다. repair wave에서 coordinator가 해결 전 must_fix를
|
|
37
|
+
// 기록할 값이며 note를 요구하지 않는다(NOTE_REQUIRED_STATUS에 넣지 않는다).
|
|
38
|
+
const CONTEXT_REVIEW_STATUS = ['resolved', 'accepted'];
|
|
39
|
+
const EXECUTE_REVIEW_STATUS = ['resolved', 'accepted', 'deferred', 'open'];
|
|
40
|
+
const REVIEW_STATUS = CONTEXT_REVIEW_STATUS;
|
|
41
|
+
const NOTE_REQUIRED_STATUS = ['accepted', 'deferred'];
|
|
42
|
+
const FINDING_ACTIONABILITY = ['must_fix', 'advisory'];
|
|
43
|
+
const FINDING_ORIGIN = ['discovery', 'introduced_by_revision', 'missed_critical'];
|
|
44
|
+
// `context-review record` 입력(--round)의 finding status. 진행 중 보고는 해결 전 `open`을
|
|
45
|
+
// 허용하지만 context-review.md에 남는 canonical 값(CONTEXT_REVIEW_STATUS)에는 `open`이 없다.
|
|
46
|
+
// 도움말과 record 검증이 같은 배열을 보게 해 두 곳이 어긋나는 일을 막는다.
|
|
47
|
+
const REPORT_FINDING_STATUS = ['open', ...CONTEXT_REVIEW_STATUS];
|
|
48
|
+
// canonical context round 계약(discovery 또는 discovery→delta)의 mode 순서.
|
|
49
|
+
// ROUND_MODE(critical_recovery 포함)와 달리 record가 받는 순서 있는 두 값만 담는다.
|
|
50
|
+
const CONTEXT_ROUND_MODES = ['discovery', 'delta'];
|
|
51
|
+
// `context-review record --status`의 허용 값.
|
|
52
|
+
const CONTEXT_REPORT_STATUS = ['requested', 'addressed', 'accepted'];
|
|
53
|
+
const ROUND_MODE = ['discovery', 'delta', 'critical_recovery'];
|
|
54
|
+
// adaptive Execute는 combined를 추가하고, legacy 네 이름은 기존 round를 계속
|
|
55
|
+
// 통과시킨다. security는 위험 flag가 있을 때만 별도 call로 붙는다.
|
|
56
|
+
// 목록 본체는 review-policy 정본이며 코디네이터의 worker-model 검사도 같은 값을 쓴다.
|
|
57
|
+
const { REVIEW_PERSPECTIVE } = reviewPolicy;
|
|
58
|
+
// adaptive Plan은 combined·local·global을 쓰되, legacy 네 관점 round도 읽는다.
|
|
59
|
+
// execute 관점과 이름을 섞지 않아야 context review 기록 검사가 diff 리뷰 원장을 계획 원장으로 오인하지 않는다.
|
|
60
|
+
const CONTEXT_REVIEW_PERSPECTIVE = [
|
|
61
|
+
'combined',
|
|
62
|
+
'local',
|
|
63
|
+
'global',
|
|
64
|
+
'cross_document',
|
|
65
|
+
'scope',
|
|
66
|
+
'korean_quality',
|
|
67
|
+
'success_criteria',
|
|
68
|
+
// 표식(review_contract: brief) blueprint의 plan 리뷰 관점. 기존 이름의 읽기는 그대로다.
|
|
69
|
+
'consistency',
|
|
70
|
+
'scope_minimality',
|
|
71
|
+
'verifiability',
|
|
72
|
+
'risk_security',
|
|
73
|
+
];
|
|
74
|
+
// execute fingerprint는 접두가 없고 context fingerprint만 이 접두를 갖는다. 두 원장이
|
|
75
|
+
// 같은 문자열을 만들 수 없게 해 finding을 다른 리뷰로 옮겨 적는 실수를 게이트가 잡는다.
|
|
76
|
+
const CONTEXT_FINDING_NAMESPACE = 'context';
|
|
77
|
+
// review-snapshot의 id 형식과 같다(64자 소문자 hex). 모듈을 import하면 검증기가 git 수집 코드에 묶인다.
|
|
78
|
+
const SNAPSHOT_ID_RE = /^[0-9a-f]{64}$/;
|
|
79
|
+
const EXECUTE_ROUND_CONTRACT = {
|
|
80
|
+
modes: ROUND_MODE,
|
|
81
|
+
sequences: ['discovery', 'discovery,delta', 'discovery,delta,critical_recovery,delta'],
|
|
82
|
+
perspectives: REVIEW_PERSPECTIVE,
|
|
83
|
+
targetKeys: ['base', 'head'],
|
|
84
|
+
targetKey: 'head',
|
|
85
|
+
perspectiveTargetKey: 'target_head',
|
|
86
|
+
counters: true,
|
|
87
|
+
};
|
|
88
|
+
// repair wave 상한의 단일 출처. coordinator ledger 검사(runtime-state)와 blueprint
|
|
89
|
+
// 최종 리뷰 라운드 순서(BLUEPRINT_ROUND_CONTRACT)가 같은 값을 보게 해, 한쪽만 바뀌어
|
|
90
|
+
// "wave는 허용하는데 그 뒤 리뷰는 기록할 수 없는" 불일치가 재발하지 않게 한다.
|
|
91
|
+
const REPAIR_WAVE_LIMIT = 2;
|
|
92
|
+
// blueprint 최종 리뷰 전용 계약. repair wave마다 delta 라운드 하나를 기록해야 하므로
|
|
93
|
+
// task 계약의 순서에 `discovery` 뒤 delta를 wave 상한만큼 붙인 순서를 더한다.
|
|
94
|
+
// critical_recovery를 섞은 새 조합은 일부러 열지 않는다. task 범위(G14)는 계속
|
|
95
|
+
// EXECUTE_ROUND_CONTRACT를 써서 라운드 상한이 늘지 않는다.
|
|
96
|
+
const BLUEPRINT_ROUND_CONTRACT = {
|
|
97
|
+
...EXECUTE_ROUND_CONTRACT,
|
|
98
|
+
sequences: [
|
|
99
|
+
...EXECUTE_ROUND_CONTRACT.sequences,
|
|
100
|
+
['discovery', ...Array(REPAIR_WAVE_LIMIT).fill('delta')].join(','),
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
// `review_contract: brief` blueprint 전용 계약. 브리프 위반을 고친 뒤 delta 한 번으로 인증할 수
|
|
104
|
+
// 있어야 하므로 discovery 뒤 delta 하나까지만 연다. critical_recovery와 두 번째 delta는
|
|
105
|
+
// 일부러 열지 않는다 — 남은 위반은 blocked로 보고한다. 저장된 legacy 이력(discovery,delta,delta)은
|
|
106
|
+
// 표식 없는 blueprint가 BLUEPRINT_ROUND_CONTRACT로 계속 읽는다.
|
|
107
|
+
const BRIEF_ROUND_CONTRACT = {
|
|
108
|
+
...EXECUTE_ROUND_CONTRACT,
|
|
109
|
+
sequences: ['discovery', 'discovery,delta'],
|
|
110
|
+
};
|
|
111
|
+
// 브리프 조항 참조: `tasks/<NNN> <브리프 8섹션>` 또는 `contract`로 시작하고 뒤는 끝이거나 공백.
|
|
112
|
+
// 접두 일치(`contractual`)와 섹션 아닌 이름(`tasks/001 Notes`)을 거르려고 경계를 둔다.
|
|
113
|
+
const BRIEF_CLAUSE_PATTERN = new RegExp('^(?:tasks/\\d{3} (?:goal & intent|current behavior|target behavior|interface|touch|do not touch'
|
|
114
|
+
+ '|constraints|checklist)|contract)(?:\\s|$)', 'i');
|
|
115
|
+
const CONTEXT_ROUND_CONTRACT = {
|
|
116
|
+
modes: ['discovery', 'delta'],
|
|
117
|
+
sequences: ['discovery', 'discovery,delta'],
|
|
118
|
+
perspectives: CONTEXT_REVIEW_PERSPECTIVE,
|
|
119
|
+
targetKeys: ['digest'],
|
|
120
|
+
targetKey: 'digest',
|
|
121
|
+
perspectiveTargetKey: 'target_digest',
|
|
122
|
+
counters: false,
|
|
123
|
+
};
|
|
124
|
+
// 필수 절 파서와 동일한 비어 있음 계약: 제목은 있고, comment-strip 후 본문이 있어야 함.
|
|
125
|
+
// comprehension module이 어떤 section이 있는지 SSOT가 되도록 key는
|
|
126
|
+
// EXPLAIN_SECTION_DEFS를 반영; regex는 parseSections 옆에 둠.
|
|
127
|
+
const EXPLAIN_SECTION_HEADINGS = [
|
|
128
|
+
{ key: 'background', re: /^##\s+Background\s*$/i },
|
|
129
|
+
// 선택적 legacy 절: 새 Explain은 만들지 않고 G16도 요구하지 않는다. 정규식을 지우면
|
|
130
|
+
// 기존 문서의 Intuition 본문이 앞 절(background)에 흡수되어 provenance body가 바뀐다.
|
|
131
|
+
{ key: 'intuition', re: /^##\s+Intuition\s*$/i },
|
|
132
|
+
{ key: 'code', re: /^##\s+Code\s*$/i },
|
|
133
|
+
{ key: 'quiz', re: /^##\s+Quiz\s*$/i },
|
|
134
|
+
// 옛 explain의 ## 이해 상태를 따로 떼는 비필수 키. 정규식을 지우면 본문이 quiz에 흡수된다.
|
|
135
|
+
{ key: 'understanding', re: /^##\s+이해\s*상태\s*$/i },
|
|
136
|
+
// task 맥락은 finalize가 선택적으로 채우며 G16 필수 목록과 분리한다.
|
|
137
|
+
{ key: 'tasks', re: /^##\s+Tasks\s*$/i },
|
|
138
|
+
];
|
|
139
|
+
// 작성 가이드는 HTML comment로 제공되므로, 가이드만 있는 section은 미작성으로
|
|
140
|
+
// 본다. 비어 있음 검사 전에 comment를 제거하면 "section은 있으나 비어 있음"이
|
|
141
|
+
// template에 본문이 실리기 전과 같은 의미를 유지. tasks뿐 아니라 section을
|
|
142
|
+
// 파싱하는 모든 문서에 적용.
|
|
143
|
+
function stripComments(text) {
|
|
144
|
+
return text.replace(/<!--[\s\S]*?-->/g, '');
|
|
145
|
+
}
|
|
146
|
+
// template이 쓰는 유일한 placeholder 형식. 실제 본문과 구분되어 걸리지 않음 —
|
|
147
|
+
// Interface의 `<T>` generic은 그대로 허용.
|
|
148
|
+
const TODO_RE = /<TODO:[^>\n]*>/;
|
|
149
|
+
function parseSections(body, defs) {
|
|
150
|
+
const text = typeof body === 'string' ? stripComments(body) : '';
|
|
151
|
+
const lines = text.split('\n');
|
|
152
|
+
const starts = [];
|
|
153
|
+
for (let i = 0; i < lines.length; i++) {
|
|
154
|
+
for (const def of defs) {
|
|
155
|
+
if (def.re.test(lines[i].trim()))
|
|
156
|
+
starts.push({ key: def.key, line: i });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const out = {};
|
|
160
|
+
for (const def of defs)
|
|
161
|
+
out[def.key] = null;
|
|
162
|
+
for (let s = 0; s < starts.length; s++) {
|
|
163
|
+
const { key, line } = starts[s];
|
|
164
|
+
const end = s + 1 < starts.length ? starts[s + 1].line : lines.length;
|
|
165
|
+
out[key] = lines.slice(line + 1, end).join('\n').trim() || null;
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
function parseTasksSections(body) {
|
|
170
|
+
return parseSections(body, SECTION_DEFS);
|
|
171
|
+
}
|
|
172
|
+
function parseExplainSections(body) {
|
|
173
|
+
return parseSections(body, EXPLAIN_SECTION_HEADINGS);
|
|
174
|
+
}
|
|
175
|
+
// Touch/Do-not-touch는 백틱 경로와 민줄 경로를 섞어 쓴다. 한쪽만 모으면
|
|
176
|
+
// Touch 근거·금지 경로 교차 판정이 표기 차이로 빗나간다.
|
|
177
|
+
function extractPathCandidates(text) {
|
|
178
|
+
const raw = typeof text === 'string' ? text : '';
|
|
179
|
+
const found = new Set();
|
|
180
|
+
for (const m of raw.matchAll(/`([^`]+)`/g)) {
|
|
181
|
+
const p = toPosix(m[1].trim()).replace(/^\.\//, '');
|
|
182
|
+
if (p)
|
|
183
|
+
found.add(p);
|
|
184
|
+
}
|
|
185
|
+
for (const tok of raw.split(/[\s,;]+/)) {
|
|
186
|
+
const p = toPosix(tok.trim()).replace(/^\.\//, '');
|
|
187
|
+
if (!p || p.includes('`'))
|
|
188
|
+
continue;
|
|
189
|
+
if (!/^[A-Za-z0-9_./-]+$/.test(p))
|
|
190
|
+
continue;
|
|
191
|
+
if (!p.includes('/') && !/\.[A-Za-z0-9]+$/.test(p))
|
|
192
|
+
continue;
|
|
193
|
+
found.add(p);
|
|
194
|
+
}
|
|
195
|
+
return [...found];
|
|
196
|
+
}
|
|
197
|
+
function isPositiveInteger(value) {
|
|
198
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
|
199
|
+
}
|
|
200
|
+
function isNonNegativeInteger(value) {
|
|
201
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* finding의 file 경로 정규화(posix·trim·앞 `./` 제거). fingerprint와 병합 후보 계산이
|
|
205
|
+
* 같은 규칙을 써야 두 경로가 같은 파일을 다르게 판단하지 않는다. 대소문자는 접지 않는다.
|
|
206
|
+
*
|
|
207
|
+
* @param {unknown} file - finding.file 원본
|
|
208
|
+
* @returns {string} 정규화한 경로
|
|
209
|
+
*/
|
|
210
|
+
function normalizeFindingFile(file) {
|
|
211
|
+
return toPosix(String(file ?? '').trim()).replace(/^\.\//, '');
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* finding 식별자를 한 곳에서 정규화한다. review 작성자마다 `./`·대소문자가 달라
|
|
215
|
+
* 같은 문제를 새 finding으로 기록하는 일을 막기 위해, namespace는 후속 계약이
|
|
216
|
+
* 필요할 때만 정규형 앞에 붙인다.
|
|
217
|
+
*/
|
|
218
|
+
function findingFingerprint({ category, brief_clause, file, symbol, }, namespace) {
|
|
219
|
+
const normalized = [
|
|
220
|
+
String(category ?? '').trim().toLowerCase(),
|
|
221
|
+
String(brief_clause ?? '').trim().toLowerCase(),
|
|
222
|
+
normalizeFindingFile(file),
|
|
223
|
+
].join(':');
|
|
224
|
+
const fingerprint = `${normalized}#${String(symbol ?? '').trim()}`;
|
|
225
|
+
return namespace ? `${namespace}:${fingerprint}` : fingerprint;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* 정규 fingerprint에서 결정적 finding id를 만든다. 같은 fingerprint는 round를 넘어 같은 id가
|
|
229
|
+
* 되어야 이전 round finding을 교체하므로 sha256 앞 8hex를 쓴다. 32bit라 충돌이 드물게
|
|
230
|
+
* 가능하며, 충돌 판정은 normalizeRoundFindings가 거절로 처리한다(조용히 merge하지 않는다).
|
|
231
|
+
*
|
|
232
|
+
* @param {string} fingerprint - findingFingerprint가 돌려준 정규 문자열
|
|
233
|
+
* @param {'context'} [namespace] - context면 `CR-`, 생략하면 execute `F-` 접두
|
|
234
|
+
* @returns {string} `F-<8hex>` 또는 `CR-<8hex>`
|
|
235
|
+
*/
|
|
236
|
+
function findingAutoId(fingerprint, namespace) {
|
|
237
|
+
const prefix = namespace === CONTEXT_FINDING_NAMESPACE ? 'CR-' : 'F-';
|
|
238
|
+
return `${prefix}${createHash('sha256').update(fingerprint).digest('hex').slice(0, 8)}`;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* record/close 경계에서 finding 하나를 정규화한 사본을 만든다. fingerprint는 입력값과 무관하게
|
|
242
|
+
* 공식으로 덮어쓰고, `id` 키가 없을 때만 자동 id를 채운다. severity·status·note·accepted_risk·
|
|
243
|
+
* 명시 id 등 다른 필드는 건드리지 않는다. 객체가 아닌 항목은 그대로 돌려줘 형식 거절을
|
|
244
|
+
* 기존 검사에 맡긴다. 명시 id의 유효성은 보지 않는다 — 과거 저장 보고(fold·projection)를
|
|
245
|
+
* 다시 읽을 때도 쓰이므로 거절은 normalizeRoundFindings가 새 입력에만 건다.
|
|
246
|
+
*
|
|
247
|
+
* @param {unknown} finding - 원본 finding(변형하지 않는다)
|
|
248
|
+
* @param {'context'} [namespace] - context review면 fingerprint 접두·`CR-` id
|
|
249
|
+
* @returns {unknown} 정규화한 사본, 또는 객체가 아니면 원본
|
|
250
|
+
*/
|
|
251
|
+
function normalizeFinding(finding, namespace) {
|
|
252
|
+
if (!isRecord(finding))
|
|
253
|
+
return finding;
|
|
254
|
+
const fingerprint = findingFingerprint({
|
|
255
|
+
category: finding.category, brief_clause: finding.brief_clause, file: finding.file, symbol: finding.symbol,
|
|
256
|
+
}, namespace);
|
|
257
|
+
// 생략은 key 부재만 뜻한다. `id: null`·빈 문자열을 자동 id로 바꾸면 작성 실수가 조용히 새 finding이 된다.
|
|
258
|
+
const id = Object.prototype.hasOwnProperty.call(finding, 'id') ? finding.id : findingAutoId(fingerprint, namespace);
|
|
259
|
+
return { ...finding, id, fingerprint };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* 이번 round 입력 finding을 정규화하고, 기존 finding과 합치기 전에 id 오류를 거절한다.
|
|
263
|
+
* - 명시 id가 비어 있지 않은 문자열이 아니면(null·빈 문자열·공백·비문자열) 거절한다.
|
|
264
|
+
* - 자동 id가 입력 안 다른 항목이나 기존 finding의 id와 같은데 정규 fingerprint가 다르면
|
|
265
|
+
* 해시 충돌이나 id 선점이다. id로 merge하면 다른 문제를 덮어쓰므로 거절한다.
|
|
266
|
+
* - 자동 id가 같은 입력 안에서 같은 fingerprint로 두 번 나오면 중복 fingerprint로 거절한다
|
|
267
|
+
* (id merge로 조용히 하나가 사라지지 않게). 명시 id끼리의 같은 id는 기존 교체 동작을 유지한다.
|
|
268
|
+
* 기존 finding의 fingerprint는 저장 값이 아니라 공식으로 다시 계산해 비교한다 — 과거에 틀린
|
|
269
|
+
* fingerprint로 저장된 같은 finding을 충돌로 오판하지 않기 위해서다.
|
|
270
|
+
*
|
|
271
|
+
* @param {unknown[]} incoming - 이번 round 입력 findings
|
|
272
|
+
* @param {unknown[]} existing - 이미 기록된(또는 fold한) findings
|
|
273
|
+
* @param {'context'} [namespace] - context review면 fingerprint 접두·`CR-` id·메시지 접두
|
|
274
|
+
* @returns {{ok: true, findings: unknown[], ids: string[]} | {ok: false, messages: string[]}}
|
|
275
|
+
* 성공이면 정규화 사본과 입력 순서·중복 제거한 id 목록, 실패면 사유 메시지
|
|
276
|
+
*/
|
|
277
|
+
function normalizeRoundFindings(incoming, existing, namespace) {
|
|
278
|
+
const label = namespace === CONTEXT_FINDING_NAMESPACE ? 'context-review' : 'review';
|
|
279
|
+
const messages = [];
|
|
280
|
+
// id → 정규 fingerprint. 기존 finding을 먼저 넣어 입력이 기존 id를 노리는지 본다.
|
|
281
|
+
const owners = new Map();
|
|
282
|
+
for (const item of existing) {
|
|
283
|
+
const norm = normalizeFinding(item, namespace);
|
|
284
|
+
if (isRecord(norm) && typeof norm.id === 'string')
|
|
285
|
+
owners.set(norm.id, norm.fingerprint);
|
|
286
|
+
}
|
|
287
|
+
const seenInInput = new Map();
|
|
288
|
+
const findings = [];
|
|
289
|
+
const ids = [];
|
|
290
|
+
for (const raw of incoming) {
|
|
291
|
+
const auto = isRecord(raw) && !Object.prototype.hasOwnProperty.call(raw, 'id');
|
|
292
|
+
if (isRecord(raw) && !auto && (typeof raw.id !== 'string' || raw.id.trim() === '')) {
|
|
293
|
+
messages.push(`${label} finding id invalid: ${JSON.stringify(raw.id) ?? 'undefined'}`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const norm = normalizeFinding(raw, namespace);
|
|
297
|
+
findings.push(norm);
|
|
298
|
+
if (!isRecord(norm))
|
|
299
|
+
continue;
|
|
300
|
+
const id = norm.id;
|
|
301
|
+
const fingerprint = norm.fingerprint;
|
|
302
|
+
const prior = seenInInput.get(id);
|
|
303
|
+
if (prior && (auto || prior.auto)) {
|
|
304
|
+
messages.push(prior.fingerprint === fingerprint
|
|
305
|
+
? `${label} duplicate fingerprint ${fingerprint}`
|
|
306
|
+
: `${label} finding id collision ${id}`);
|
|
307
|
+
}
|
|
308
|
+
else if (auto && owners.has(id) && owners.get(id) !== fingerprint) {
|
|
309
|
+
messages.push(`${label} finding id collision ${id}`);
|
|
310
|
+
}
|
|
311
|
+
if (!prior)
|
|
312
|
+
seenInInput.set(id, { fingerprint, auto });
|
|
313
|
+
if (!ids.includes(id))
|
|
314
|
+
ids.push(id);
|
|
315
|
+
}
|
|
316
|
+
return messages.length > 0 ? { ok: false, messages } : { ok: true, findings, ids };
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* 한 round의 finding 중 정규화 file·trim symbol이 같고 category가 둘 이상인 묶음을
|
|
320
|
+
* 병합 후보로 돌려준다. 관점이 다른 리뷰어가 같은 문제를 다른 fingerprint로 보고해도
|
|
321
|
+
* controller가 병합 여부를 판단할 수 있게 알리는 용도이며, 기록을 막지 않는다.
|
|
322
|
+
* 던지지 않는다: 배열이 아니거나, 객체가 아니거나, file·symbol이 비었거나 문자열이 아닌
|
|
323
|
+
* 항목은 조용히 건너뛴다(형식 거절은 collectFindingFailures의 책임).
|
|
324
|
+
*
|
|
325
|
+
* @param {unknown[]} findings - 기록 중인 round의 finding 목록
|
|
326
|
+
* @returns {Array<{code: 'finding-merge-candidate', file: string, symbol: string, ids: string[]}>}
|
|
327
|
+
* 후보 묶음(첫 등장 순서). ids는 입력 순서이며 id가 없는 항목은 ids에서 빠진다.
|
|
328
|
+
*/
|
|
329
|
+
function findingMergeCandidates(findings) {
|
|
330
|
+
if (!Array.isArray(findings))
|
|
331
|
+
return [];
|
|
332
|
+
const groups = new Map();
|
|
333
|
+
for (const item of findings) {
|
|
334
|
+
if (typeof item !== 'object' || item === null)
|
|
335
|
+
continue;
|
|
336
|
+
const rec = item;
|
|
337
|
+
if (typeof rec.file !== 'string' || typeof rec.symbol !== 'string')
|
|
338
|
+
continue;
|
|
339
|
+
const file = normalizeFindingFile(rec.file);
|
|
340
|
+
const symbol = rec.symbol.trim();
|
|
341
|
+
if (file === '' || symbol === '')
|
|
342
|
+
continue;
|
|
343
|
+
// 구분자 충돌을 피하려고 JSON 배열을 키로 쓴다.
|
|
344
|
+
const key = JSON.stringify([file, symbol]);
|
|
345
|
+
let group = groups.get(key);
|
|
346
|
+
if (!group) {
|
|
347
|
+
group = { file, symbol, ids: [], categories: new Set() };
|
|
348
|
+
groups.set(key, group);
|
|
349
|
+
}
|
|
350
|
+
if (typeof rec.id === 'string')
|
|
351
|
+
group.ids.push(rec.id);
|
|
352
|
+
group.categories.add(String(rec.category ?? '').trim().toLowerCase());
|
|
353
|
+
}
|
|
354
|
+
return [...groups.values()]
|
|
355
|
+
.filter((g) => g.categories.size >= 2)
|
|
356
|
+
.map((g) => ({ code: 'finding-merge-candidate', file: g.file, symbol: g.symbol, ids: g.ids }));
|
|
357
|
+
}
|
|
358
|
+
function missingFindingField(record, field) {
|
|
359
|
+
const value = record[field];
|
|
360
|
+
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* 리뷰 finding 형식 실패를 모은다. 본문 판정 문장은 읽지 않는다 —
|
|
364
|
+
* heading 존재와 id/severity/status/note, 그리고 선택적 rounds[]만 본다.
|
|
365
|
+
*
|
|
366
|
+
* execute review(G14)와 context review 기록이 헬퍼를 공유하되 allowedStatuses로 계약을 가른다. 한 배열을
|
|
367
|
+
* 쓰면 deferred가 계획 문서에 새거나, execute가 기존 resolved/accepted를
|
|
368
|
+
* 잃는다. findings가 있는데 배열이 아니면 []로 떨어뜨리지 않는다. 빈 배열과
|
|
369
|
+
* 같게 취급하면 형식 위반이 통과한다. 부재(undefined/null)만 빈 목록으로 본다.
|
|
370
|
+
* rounds는 키가 없을 때만 건너뛴다 — 구문서는 원장 없이 읽고, 새 컨트롤러는
|
|
371
|
+
* 실행한 round마다 적는다.
|
|
372
|
+
*
|
|
373
|
+
* @param {unknown} body - 리뷰 문서 본문
|
|
374
|
+
* @param {unknown} findings - `bouncer.*.findings`
|
|
375
|
+
* @param {string} sectionLabel - ## Findings 부재 메시지에 쓰는 문서 이름
|
|
376
|
+
* @param {string} findingLabel - finding/round 메시지 접두
|
|
377
|
+
* @param {readonly string[]} allowedStatuses - execute 또는 context 허용 status
|
|
378
|
+
* @param {unknown} [rounds] - execute `bouncer.review.rounds` 또는 context
|
|
379
|
+
* `bouncer.context_review.rounds`. 부재면 검사 생략
|
|
380
|
+
* @param {'context'} [namespace] - context review면 fingerprint 접두와 context round 계약.
|
|
381
|
+
* 생략하면 execute 계약(접두 없음, commit target)
|
|
382
|
+
* @param {'blueprint' | 'blueprint_brief'} [roundScope] - blueprint 최종 리뷰면 execute namespace에서
|
|
383
|
+
* BLUEPRINT_ROUND_CONTRACT를 쓴다. `blueprint_brief`(review_contract: brief)는 BRIEF_ROUND_CONTRACT를 쓰고
|
|
384
|
+
* deferred must_fix 예외 없이 must_fix brief_clause가 브리프 조항 참조인지도 검사한다.
|
|
385
|
+
* context namespace에서는 무시한다
|
|
386
|
+
* @returns {string[]} 게이트 메시지. 없으면 빈 배열
|
|
387
|
+
*/
|
|
388
|
+
function collectFindingFailures({ body, findings, sectionLabel, findingLabel, allowedStatuses, rounds, reviewStatus, namespace, roundScope, }) {
|
|
389
|
+
const messages = [];
|
|
390
|
+
const rs = parseSections(typeof body === 'string' ? body : '', REVIEW_SECTION_DEFS);
|
|
391
|
+
if (!rs.findings) {
|
|
392
|
+
messages.push(`${sectionLabel} missing ## Findings body section`);
|
|
393
|
+
}
|
|
394
|
+
if (findings != null && !Array.isArray(findings)) {
|
|
395
|
+
messages.push(`${findingLabel} findings must be an array`);
|
|
396
|
+
return messages;
|
|
397
|
+
}
|
|
398
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
399
|
+
const modeContract = Array.isArray(rounds) && rounds.some((entry) => isRecord(entry) && entry.mode !== undefined);
|
|
400
|
+
const fingerprints = new Set();
|
|
401
|
+
for (const fnd of list) {
|
|
402
|
+
const rec = isRecord(fnd) ? fnd : fnd;
|
|
403
|
+
const id = rec && rec.id ? rec.id : '(no id)';
|
|
404
|
+
if (!REVIEW_SEVERITY.includes(rec && rec.severity)) {
|
|
405
|
+
messages.push(`${findingLabel} finding ${id} severity invalid: ${rec && rec.severity}`);
|
|
406
|
+
}
|
|
407
|
+
if (!allowedStatuses.includes(rec && rec.status)) {
|
|
408
|
+
messages.push(`${findingLabel} finding ${id} status invalid: ${rec && rec.status}`);
|
|
409
|
+
}
|
|
410
|
+
else if (NOTE_REQUIRED_STATUS.includes(rec && rec.status)
|
|
411
|
+
&& (!rec.note || String(rec.note).trim() === '')) {
|
|
412
|
+
messages.push(`${findingLabel} finding ${id} ${String(rec.status)} without note`);
|
|
413
|
+
}
|
|
414
|
+
// open은 accepted 문서에 남을 수 없다. actionability·mode 유무와 무관해야 하므로
|
|
415
|
+
// modeContract 블록 밖에서 본다(mode 없는 구문서와 advisory도 같은 답을 낸다).
|
|
416
|
+
// context review 기록 검사는 reviewStatus를 넘기지 않고 open을 status invalid로 먼저 거절한다.
|
|
417
|
+
if (reviewStatus === 'accepted' && rec && rec.status === 'open') {
|
|
418
|
+
messages.push(`${findingLabel} finding ${id} open in accepted review`);
|
|
419
|
+
}
|
|
420
|
+
if (modeContract && rec) {
|
|
421
|
+
for (const field of [
|
|
422
|
+
'category', 'brief_clause', 'file', 'symbol', 'fingerprint', 'actionability',
|
|
423
|
+
'origin', 'first_seen_round', 'last_seen_round',
|
|
424
|
+
]) {
|
|
425
|
+
if (missingFindingField(rec, field))
|
|
426
|
+
messages.push(`${findingLabel} finding ${id} ${field} missing`);
|
|
427
|
+
}
|
|
428
|
+
// context category는 finding이 나온 관점 이름이다. fingerprint 일치만 보면 execute
|
|
429
|
+
// 관점(spec_scope 등)을 category와 fingerprint에 함께 적은 finding이 통과한다.
|
|
430
|
+
// execute(G14) category는 자유 분류라 이 검사를 context namespace에만 건다.
|
|
431
|
+
if (namespace === CONTEXT_FINDING_NAMESPACE && !missingFindingField(rec, 'category')
|
|
432
|
+
&& !CONTEXT_REVIEW_PERSPECTIVE.includes(rec.category)) {
|
|
433
|
+
messages.push(`${findingLabel} finding ${id} category invalid: ${String(rec.category)}`);
|
|
434
|
+
}
|
|
435
|
+
if (!missingFindingField(rec, 'fingerprint')) {
|
|
436
|
+
const fingerprint = String(rec.fingerprint);
|
|
437
|
+
const hasContextPrefix = fingerprint.startsWith(`${CONTEXT_FINDING_NAMESPACE}:`);
|
|
438
|
+
// namespace 판정을 mismatch보다 먼저 한다. 접두만 틀린 경우를 mismatch로
|
|
439
|
+
// 보고하면 작성자가 구성 요소를 고치려 들고, 원장 혼동이라는 원인이 가려진다.
|
|
440
|
+
if (namespace ? !hasContextPrefix : hasContextPrefix) {
|
|
441
|
+
messages.push(`${findingLabel} finding ${id} fingerprint namespace invalid`);
|
|
442
|
+
}
|
|
443
|
+
else if (fingerprint !== findingFingerprint({
|
|
444
|
+
category: rec.category, brief_clause: rec.brief_clause, file: rec.file, symbol: rec.symbol,
|
|
445
|
+
}, namespace)) {
|
|
446
|
+
messages.push(`${findingLabel} finding ${id} fingerprint mismatch`);
|
|
447
|
+
}
|
|
448
|
+
if (fingerprints.has(fingerprint))
|
|
449
|
+
messages.push(`${findingLabel} duplicate fingerprint ${fingerprint}`);
|
|
450
|
+
fingerprints.add(fingerprint);
|
|
451
|
+
}
|
|
452
|
+
if (!FINDING_ACTIONABILITY.includes(rec.actionability)) {
|
|
453
|
+
messages.push(`${findingLabel} finding ${id} actionability invalid`);
|
|
454
|
+
}
|
|
455
|
+
if (!FINDING_ORIGIN.includes(rec.origin)) {
|
|
456
|
+
messages.push(`${findingLabel} finding ${id} origin invalid`);
|
|
457
|
+
}
|
|
458
|
+
// context namespace는 reviewStatus 인자가 생략되더라도 게이트 진입 조건에서 이미 status=accepted를
|
|
459
|
+
// 확인하므로 accepted 검사를 적용한다. execute(G14)는 reviewStatus === 'accepted'일 때 검사한다.
|
|
460
|
+
// context namespace에서만 명시적 사용자 동의가 반영된 accepted_risk: true(비어 있지 않은 note 필수)를
|
|
461
|
+
// 예외로 인정하며, execute namespace에서는 accepted_risk를 인정하지 않는다.
|
|
462
|
+
const isReviewAccepted = reviewStatus === 'accepted' || namespace === CONTEXT_FINDING_NAMESPACE;
|
|
463
|
+
const isContextAcceptedRisk = namespace === CONTEXT_FINDING_NAMESPACE
|
|
464
|
+
&& rec.status === 'accepted'
|
|
465
|
+
&& rec.accepted_risk === true
|
|
466
|
+
&& typeof rec.note === 'string'
|
|
467
|
+
&& rec.note.trim() !== '';
|
|
468
|
+
// blueprint 최종 리뷰는 discovery 한 라운드로 끝나므로, 고치지 않고 넘기는 must_fix는
|
|
469
|
+
// deferred(+note, 위 NOTE_REQUIRED_STATUS 검사)로 기록해 accepted를 허용한다.
|
|
470
|
+
// task 범위(G14)는 roundScope가 없어 이 예외를 받지 않고 기존처럼 거절한다.
|
|
471
|
+
// blueprint_brief는 delta로 인증할 라운드가 있으므로 이 예외를 받지 않는다.
|
|
472
|
+
const isBlueprintDeferred = roundScope === 'blueprint' && rec.status === 'deferred';
|
|
473
|
+
// blueprint 최종 리뷰의 blocked finding은 round를 더할 수 없어 open으로 남는다. 사용자가
|
|
474
|
+
// 명시적으로 위험을 수용하면 `review accept-risk`가 accepted + accepted_risk: true + note로
|
|
475
|
+
// 닫는다. task 범위(G14)는 roundScope가 없어 이 예외를 받지 않는다.
|
|
476
|
+
const isBlueprintAcceptedRisk = (roundScope === 'blueprint' || roundScope === 'blueprint_brief')
|
|
477
|
+
&& rec.status === 'accepted'
|
|
478
|
+
&& rec.accepted_risk === true
|
|
479
|
+
&& typeof rec.note === 'string'
|
|
480
|
+
&& rec.note.trim() !== '';
|
|
481
|
+
if (isReviewAccepted && rec.actionability === 'must_fix' && rec.status !== 'resolved'
|
|
482
|
+
&& !isContextAcceptedRisk && !isBlueprintDeferred && !isBlueprintAcceptedRisk) {
|
|
483
|
+
messages.push(`${findingLabel} accepted with open must_fix ${id}`);
|
|
484
|
+
}
|
|
485
|
+
// 조항을 인용하지 못하는 finding이 must_fix로 올라오면 직접 실행이 코디네이터로 넘어간다.
|
|
486
|
+
// advisory와 context namespace에는 걸지 않는다(브리프 이행 판정은 execute must_fix만의 계약).
|
|
487
|
+
if (roundScope === 'blueprint_brief' && namespace !== CONTEXT_FINDING_NAMESPACE
|
|
488
|
+
&& rec.actionability === 'must_fix' && !missingFindingField(rec, 'brief_clause')
|
|
489
|
+
&& !BRIEF_CLAUSE_PATTERN.test(String(rec.brief_clause).trim())) {
|
|
490
|
+
messages.push(`${findingLabel} finding ${id} must_fix brief_clause is not a brief section`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
messages.push(...collectRoundFailures(rounds, findingLabel, list, modeContract, namespace === CONTEXT_FINDING_NAMESPACE
|
|
495
|
+
? CONTEXT_ROUND_CONTRACT
|
|
496
|
+
: roundScopeContract(roundScope)));
|
|
497
|
+
return messages;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* roundScope에 맞는 execute 라운드 계약을 고른다.
|
|
501
|
+
*
|
|
502
|
+
* @param {'blueprint' | 'blueprint_brief'} [roundScope] - 생략하면 task 범위
|
|
503
|
+
* @returns {RoundContract} 범위별 계약. 모르는 값은 타입이 막는다
|
|
504
|
+
*/
|
|
505
|
+
function roundScopeContract(roundScope) {
|
|
506
|
+
if (roundScope === 'blueprint_brief')
|
|
507
|
+
return BRIEF_ROUND_CONTRACT;
|
|
508
|
+
return roundScope === 'blueprint' ? BLUEPRINT_ROUND_CONTRACT : EXECUTE_ROUND_CONTRACT;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* 선택적 round ledger 형식 실패를 모은다. 집계는 문자열·소수가 아니라
|
|
512
|
+
* 정수여야 하고, round 번호는 중복·역순이면 이전 finding 관계를 믿을 수 없다.
|
|
513
|
+
* execute 계약(`contract.counters`)의 delta round는 perspectives가 2개 이상이면
|
|
514
|
+
* 거절한다 — review record·G14·G21이 같은 메시지를 공유하도록 여기서만 건다.
|
|
515
|
+
*
|
|
516
|
+
* @param {unknown} rounds - `bouncer.review.rounds` 또는 `bouncer.context_review.rounds`.
|
|
517
|
+
* undefined면 구문서 호환으로 통과
|
|
518
|
+
* @param {string} findingLabel - 메시지 접두
|
|
519
|
+
* @param {unknown[]} findings - finding 목록. delta origin 검사에만 쓴다
|
|
520
|
+
* @param {boolean} modeContract - mode·target·perspective 검사를 켤지
|
|
521
|
+
* @param {RoundContract} contract - execute 또는 context round 계약
|
|
522
|
+
* @returns {string[]} 형식 실패 메시지
|
|
523
|
+
*/
|
|
524
|
+
function collectRoundFailures(rounds, findingLabel, findings = [], modeContract = false, contract = EXECUTE_ROUND_CONTRACT) {
|
|
525
|
+
if (rounds === undefined)
|
|
526
|
+
return [];
|
|
527
|
+
if (!Array.isArray(rounds)) {
|
|
528
|
+
return [`${findingLabel} rounds must be an array`];
|
|
529
|
+
}
|
|
530
|
+
const messages = [];
|
|
531
|
+
const seen = new Set();
|
|
532
|
+
let lastRound = 0;
|
|
533
|
+
const modes = [];
|
|
534
|
+
const modeByRound = new Map();
|
|
535
|
+
for (const entry of rounds) {
|
|
536
|
+
if (!isRecord(entry)) {
|
|
537
|
+
messages.push(`${findingLabel} rounds entry invalid`);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (!isPositiveInteger(entry.round)) {
|
|
541
|
+
messages.push(`${findingLabel} rounds round invalid: ${entry.round}`);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const round = entry.round;
|
|
545
|
+
if (seen.has(round)) {
|
|
546
|
+
messages.push(`${findingLabel} rounds duplicate round: ${round}`);
|
|
547
|
+
}
|
|
548
|
+
else if (lastRound !== 0 && round < lastRound) {
|
|
549
|
+
messages.push(`${findingLabel} rounds out of order`);
|
|
550
|
+
}
|
|
551
|
+
seen.add(round);
|
|
552
|
+
lastRound = round;
|
|
553
|
+
if (modeContract) {
|
|
554
|
+
if (!contract.modes.includes(entry.mode)) {
|
|
555
|
+
messages.push(`${findingLabel} round ${round} mode invalid`);
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
modes.push(entry.mode);
|
|
559
|
+
modeByRound.set(round, entry.mode);
|
|
560
|
+
}
|
|
561
|
+
const target = isRecord(entry.target) ? entry.target : undefined;
|
|
562
|
+
if (!target || contract.targetKeys.some((key) => !target[key])) {
|
|
563
|
+
messages.push(`${findingLabel} round ${round} target invalid`);
|
|
564
|
+
}
|
|
565
|
+
// snapshot_id는 새 라운드에만 있고 과거 저장 기록에는 없으므로 부재는 허용하고 있을 때만
|
|
566
|
+
// 형식을 본다. counters로 execute 계약에만 적용해 context digest 계약을 건드리지 않는다.
|
|
567
|
+
// 파일 존재·현재 freshness는 쓰기 시점의 recorder가 판정한다.
|
|
568
|
+
if (contract.counters && target && target.snapshot_id !== undefined
|
|
569
|
+
&& !(typeof target.snapshot_id === 'string' && SNAPSHOT_ID_RE.test(target.snapshot_id))) {
|
|
570
|
+
messages.push(`${findingLabel} round ${round} target snapshot_id invalid`);
|
|
571
|
+
}
|
|
572
|
+
if (entry.perspectives !== undefined) {
|
|
573
|
+
const perspectives = Array.isArray(entry.perspectives) ? entry.perspectives : [entry.perspectives];
|
|
574
|
+
for (const perspective of perspectives) {
|
|
575
|
+
const item = isRecord(perspective) ? perspective : {};
|
|
576
|
+
const name = typeof item.name === 'string' ? item.name : String(item.name ?? '(no name)');
|
|
577
|
+
if (!contract.perspectives.includes(item.name)) {
|
|
578
|
+
messages.push(`${findingLabel} round ${round} perspective invalid ${name}`);
|
|
579
|
+
}
|
|
580
|
+
else if (target && item[contract.perspectiveTargetKey] !== target[contract.targetKey]) {
|
|
581
|
+
messages.push(`${findingLabel} round ${round} target mismatch ${name}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// execute 계약 delta만 reviewer 하나를 강제한다. mode 값만 보면 context
|
|
585
|
+
// delta(counters:false)도 걸리므로 counters로 구분한다. 이름 검사와
|
|
586
|
+
// 독립 — 잘못된 이름도 count에 넣어 개수 위반이 가려지지 않게 한다.
|
|
587
|
+
if (contract.counters && entry.mode === 'delta' && perspectives.length >= 2) {
|
|
588
|
+
messages.push(`${findingLabel} round ${round} delta perspectives invalid: ${perspectives.length}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (!contract.counters)
|
|
593
|
+
continue;
|
|
594
|
+
if (!Array.isArray(entry.previous_finding_ids)
|
|
595
|
+
|| entry.previous_finding_ids.some((id) => typeof id !== 'string')) {
|
|
596
|
+
messages.push(`${findingLabel} round ${round} previous_finding_ids invalid`);
|
|
597
|
+
}
|
|
598
|
+
for (const key of ['new', 'resolved', 'regressed']) {
|
|
599
|
+
if (!isNonNegativeInteger(entry[key])) {
|
|
600
|
+
messages.push(`${findingLabel} round ${round} ${key} invalid: ${entry[key]}`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (modeContract) {
|
|
605
|
+
if (!contract.sequences.includes(modes.join(',')))
|
|
606
|
+
messages.push(`${findingLabel} rounds sequence invalid`);
|
|
607
|
+
for (const entry of findings) {
|
|
608
|
+
if (!isRecord(entry) || !isPositiveInteger(entry.first_seen_round))
|
|
609
|
+
continue;
|
|
610
|
+
if (modeByRound.get(entry.first_seen_round) !== 'delta')
|
|
611
|
+
continue;
|
|
612
|
+
const allowed = entry.origin === 'introduced_by_revision'
|
|
613
|
+
|| (entry.origin === 'missed_critical' && (entry.severity === 'blocker' || entry.severity === 'major'));
|
|
614
|
+
if (!allowed) {
|
|
615
|
+
const id = entry.id ? entry.id : '(no id)';
|
|
616
|
+
messages.push(`${findingLabel} finding ${id} delta origin not allowed`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return messages;
|
|
621
|
+
}
|
|
622
|
+
module.exports = {
|
|
623
|
+
SECTION_DEFS,
|
|
624
|
+
VERIFY_SECTION_DEFS,
|
|
625
|
+
REVIEW_SECTION_DEFS,
|
|
626
|
+
REVIEW_SEVERITY,
|
|
627
|
+
REVIEW_STATUS,
|
|
628
|
+
CONTEXT_REVIEW_STATUS,
|
|
629
|
+
CONTEXT_REVIEW_PERSPECTIVE,
|
|
630
|
+
FINDING_ACTIONABILITY,
|
|
631
|
+
FINDING_ORIGIN,
|
|
632
|
+
REPORT_FINDING_STATUS,
|
|
633
|
+
CONTEXT_ROUND_MODES,
|
|
634
|
+
CONTEXT_REPORT_STATUS,
|
|
635
|
+
EXECUTE_REVIEW_STATUS,
|
|
636
|
+
EXPLAIN_SECTION_HEADINGS,
|
|
637
|
+
TODO_RE,
|
|
638
|
+
stripComments,
|
|
639
|
+
parseSections,
|
|
640
|
+
parseTasksSections,
|
|
641
|
+
parseExplainSections,
|
|
642
|
+
extractPathCandidates,
|
|
643
|
+
pathsOverlap,
|
|
644
|
+
findingFingerprint,
|
|
645
|
+
findingAutoId,
|
|
646
|
+
normalizeFinding,
|
|
647
|
+
normalizeRoundFindings,
|
|
648
|
+
findingMergeCandidates,
|
|
649
|
+
collectFindingFailures,
|
|
650
|
+
EXECUTE_ROUND_CONTRACT,
|
|
651
|
+
BRIEF_ROUND_CONTRACT,
|
|
652
|
+
BLUEPRINT_ROUND_CONTRACT,
|
|
653
|
+
REPAIR_WAVE_LIMIT,
|
|
654
|
+
};
|