@kaydash9999/banker-plugins 0.23.2 → 0.24.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +56 -0
- package/README.md +14 -6
- package/codex/manifest.json +6 -2
- package/codex/skills/teamwork/SKILL.md +19 -6
- package/codex/skills/teamwork/scripts/teamwork.mjs +87 -22
- package/codex/transform-matrix.md +6 -2
- package/package.json +1 -1
- package/skills/build-testbox/SKILL.md +213 -0
- package/skills/build-testbox/scripts/args.mjs +156 -0
- package/skills/build-testbox/scripts/browser-cell.mjs +181 -0
- package/skills/build-testbox/scripts/browsers.mjs +164 -0
- package/skills/build-testbox/scripts/containers.mjs +237 -0
- package/skills/build-testbox/scripts/detect.mjs +166 -0
- package/skills/build-testbox/scripts/proc.mjs +91 -0
- package/skills/build-testbox/scripts/report.mjs +109 -0
- package/skills/build-testbox/scripts/setup.mjs +144 -0
- package/skills/build-testbox/scripts/testbox.mjs +148 -0
- package/skills/git-init/SKILL.md +152 -0
- package/skills/git-init/scripts/git-init.mjs +440 -0
- package/skills/git-init/scripts/marker-block.mjs +311 -0
- package/skills/git-init/scripts/payload-scan.mjs +186 -0
- package/skills/init-dir/SKILL.md +172 -0
- package/skills/init-dir/scripts/init-dir.mjs +308 -0
- package/skills/init-dir/scripts/marker-block.mjs +311 -0
- package/skills/init-dir/scripts/templates.mjs +100 -0
- package/skills/init-dir/templates/KB/README.md +15 -0
- package/skills/init-dir/templates/docs/README.md +14 -0
- package/skills/init-dir/templates/logs/README.md +14 -0
- package/skills/init-dir/templates/logs/gitignore +3 -0
- package/skills/init-dir/templates/mockup/README.md +7 -0
- package/skills/init-dir/templates/scripts/README.md +36 -0
- package/skills/init-dir/templates/scripts/lib/service.mjs +540 -0
- package/skills/init-dir/templates/scripts/restart.cmd +13 -0
- package/skills/init-dir/templates/scripts/restart.sh +10 -0
- package/skills/init-dir/templates/scripts/service.conf +5 -0
- package/skills/init-dir/templates/scripts/start.cmd +13 -0
- package/skills/init-dir/templates/scripts/start.sh +10 -0
- package/skills/init-dir/templates/scripts/status.cmd +13 -0
- package/skills/init-dir/templates/scripts/status.sh +10 -0
- package/skills/init-dir/templates/scripts/stop.cmd +13 -0
- package/skills/init-dir/templates/scripts/stop.sh +10 -0
- package/skills/init-dir/templates/tests/README.md +15 -0
- package/skills/init-dir/templates/tests/run.cmd +13 -0
- package/skills/init-dir/templates/tests/run.mjs +160 -0
- package/skills/init-dir/templates/tests/run.sh +10 -0
- package/skills/init-dir/templates/tests/tests.conf +7 -0
- package/skills/init-dir/templates/work/README.md +8 -0
- package/skills/progress-mon/README.md +11 -9
- package/skills/progress-mon/SKILL.md +53 -16
- package/skills/progress-mon/assets/fixture/README.md +1 -1
- package/skills/progress-mon/assets/fixture/adr.json +35 -0
- package/skills/progress-mon/assets/fixture/arch.json +1 -1
- package/skills/progress-mon/assets/fixture/flow.json +2 -2
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +1 -1
- package/skills/progress-mon/assets/fixture/variants/legacy-init/rules.json +6 -0
- package/skills/progress-mon/assets/fixture/variants/legacy-init/state.json +5 -0
- package/skills/progress-mon/references/adr.md +177 -0
- package/skills/progress-mon/references/authoring.md +13 -5
- package/skills/progress-mon/scripts/pm-adr.mjs +585 -0
- package/skills/progress-mon/scripts/pm-check.mjs +48 -20
- package/skills/progress-mon/scripts/pm-data.mjs +1 -1
- package/skills/progress-mon/scripts/pm-html.mjs +1 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +124 -29
- package/skills/progress-mon/scripts/pm-render.mjs +6 -4
- package/skills/progress-mon/scripts/pm-server.mjs +82 -30
- package/skills/progress-mon/scripts/pm-sources.mjs +1 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +3 -2
- package/skills/tune-tmux/SKILL.md +235 -0
- package/skills/tune-tmux/scripts/app-notes.mjs +38 -0
- package/skills/tune-tmux/scripts/marker-block.mjs +311 -0
- package/skills/tune-tmux/scripts/psmux-caps.json +71 -0
- package/skills/tune-tmux/scripts/tmux-config.mjs +251 -0
- package/skills/tune-tmux/scripts/tune-tmux.mjs +284 -0
- package/skills/ultra-chain/SKILL.md +52 -22
- package/skills/ultra-chain/references/chain-ledger.mjs +93 -11
- package/skills/ultra-chain/references/chain-report.mjs +13 -2
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
// pm-adr.mjs: ADR 탭. adr.json 두 층 스키마 검사, 참조 검증과 gap 정리, docs/adr 갈라짐 경고, 밀도, 렌더.
|
|
2
|
+
// 두 층: project(검증, 흐름, 증거)는 프로젝트에 하나, decisions[](최적화 로직, 확장 대응, 포기와 얻은 것)는 결정마다.
|
|
3
|
+
// 서버 화면은 참조를 글로만 보이고 파일을 읽거나 링크하지 않는다. 참조 파일은 check 에서만 크기 상한 안의 일반 파일로 읽는다.
|
|
4
|
+
// 스키마 위반은 FAIL, 근거가 없어 비운 항목({ gap: 사유 })은 밀도 WARN, 확인 못 한 참조는 FAIL 이나 WARN(references/adr.md).
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { git, noBom, readRegular, writeAtomic } from './pm-config.mjs';
|
|
8
|
+
import { esc } from './pm-html.mjs';
|
|
9
|
+
|
|
10
|
+
const ADR_FILE = 'adr.json';
|
|
11
|
+
const REF_READ_MAX = 4 * 1024 * 1024;
|
|
12
|
+
const STATUS = { accepted: '채택', proposed: '제안', superseded: '대체됨' };
|
|
13
|
+
const REF_KINDS = ['file', 'test', 'commit', 'cmd'];
|
|
14
|
+
const HASH = /^[0-9a-f]{4,40}$/i;
|
|
15
|
+
const DECISION_SLOTS = [
|
|
16
|
+
['optimization', '최적화 로직'],
|
|
17
|
+
['scaling', '확장 대응'],
|
|
18
|
+
['tradeoffs', '포기와 얻은 것'],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
22
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
|
|
23
|
+
const str = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
24
|
+
const isGap = (v) => Boolean(obj(v)) && Object.hasOwn(v, 'gap');
|
|
25
|
+
// json 경로 조각(['decisions', 0, 'scaling']) 을 글(decisions[0].scaling)로.
|
|
26
|
+
const jp = (parts) => parts.reduce((a, k) => (typeof k === 'number' ? `${a}[${k}]` : a ? `${a}.${k}` : k), '') || '$';
|
|
27
|
+
|
|
28
|
+
// ---------- 스키마 ----------
|
|
29
|
+
|
|
30
|
+
// 칸 꼴: 빈 문자열이 아닌 글, 글(빈 값 허용), 배열, 객체.
|
|
31
|
+
const KIND = {
|
|
32
|
+
str: [str, '빈 문자열이 아닌 문자열이 아님'],
|
|
33
|
+
text: [(v) => typeof v === 'string', '문자열이 아님'],
|
|
34
|
+
arr: [Array.isArray, '배열이 아님'],
|
|
35
|
+
obj: [(v) => Boolean(obj(v)), '객체가 아님'],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// 검사 결과 모음. schema: 스키마 위반, claim: 증거 없는 주장(구현됨, 검증됨), refFail, refWarn: 참조 확인 결과,
|
|
39
|
+
// refs: 확인할 참조, modRefs: 모듈 id 참조, claims: 참조 확인 뒤 판정할 주장(검증한 모듈, 구현된 기능).
|
|
40
|
+
// schema 와 claim 은 둘 다 FAIL 이지만 정리 단계는 schema 만 파일을 옮긴다.
|
|
41
|
+
function state() {
|
|
42
|
+
const S = { schema: [], claim: [], claims: [], refFail: [], refWarn: [], refs: [], modRefs: [], ids: new Set() };
|
|
43
|
+
S.bad = (parts, msg) => S.schema.push({ p: jp(parts), msg });
|
|
44
|
+
S.unproven = (parts, msg) => S.claim.push({ p: jp(parts), msg });
|
|
45
|
+
return S;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 주장에는 증거: 증거 칸에서 실제로 확인된 참조(꼴이 맞고 참조 확인에서 FAIL 도 WARN 도 없음)가 0개면 증거 없음.
|
|
49
|
+
// 확인 못 한 참조(출력 파일 없는 명령, 파일에 없는 시험 이름, git 이 꺼진 커밋)는 증거로 세지 않는다.
|
|
50
|
+
const MODULE_WAY = '증거를 지어내지 말고 그 모듈을 미검증으로 옮김: status "unverified" 와 why(사유)';
|
|
51
|
+
const FEATURE_WAY = '증거를 지어내지 말고 그 기능을 구현 못 함 목록으로 옮김: notImplemented 에 feature 와 why(사유)';
|
|
52
|
+
|
|
53
|
+
// o[key] 의 꼴 검사. 돌려줌: 맞으면 참. optional 이면 없어도 참.
|
|
54
|
+
function want(S, o, key, parts, kind, optional = false) {
|
|
55
|
+
const v = o[key];
|
|
56
|
+
if (v === undefined) {
|
|
57
|
+
if (!optional) S.bad([...parts, key], '칸 없음');
|
|
58
|
+
return optional;
|
|
59
|
+
}
|
|
60
|
+
const [ok, msg] = KIND[kind];
|
|
61
|
+
if (!ok(v)) S.bad([...parts, key], msg);
|
|
62
|
+
return ok(v);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fields = (S, o, parts, list) => list.forEach(([k, kind, optional]) => want(S, o, k, parts, kind, optional));
|
|
66
|
+
|
|
67
|
+
function gapOk(S, v, parts) {
|
|
68
|
+
if (str(v.gap)) return true;
|
|
69
|
+
S.bad([...parts, 'gap'], 'gap 에 사유 없음');
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 여섯 항목 칸 하나. 내용 꼴이나 { gap: 사유 }. 칸이 없거나 꼴이 틀리면 FAIL.
|
|
74
|
+
function slot(S, holder, key, parts, body) {
|
|
75
|
+
const v = holder[key];
|
|
76
|
+
const p = [...parts, key];
|
|
77
|
+
if (v === undefined) return S.bad(p, '칸 없음');
|
|
78
|
+
if (isGap(v)) return gapOk(S, v, p);
|
|
79
|
+
if (body.kind === 'arr' ? !Array.isArray(v) : !obj(v)) return S.bad(p, body.kind === 'arr' ? '배열이거나 { gap: 사유 } 가 아님' : '객체이거나 { gap: 사유 } 가 아님');
|
|
80
|
+
body.check(v, p);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const REF_FIELDS = {
|
|
84
|
+
file: [['path', 'str'], ['line', 'line', true]],
|
|
85
|
+
test: [['path', 'str'], ['name', 'str']],
|
|
86
|
+
commit: [['hash', 'hash']],
|
|
87
|
+
cmd: [['cmd', 'str'], ['output', 'str', true]],
|
|
88
|
+
};
|
|
89
|
+
const REF_KIND_CHECK = {
|
|
90
|
+
...KIND,
|
|
91
|
+
line: [(v) => Number.isInteger(v) && v >= 1, '1 이상 정수가 아님'],
|
|
92
|
+
hash: [(v) => typeof v === 'string' && HASH.test(v), '4~40자 16진 해시가 아님'],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// 참조 칸 하나의 꼴. 돌려줌: 맞으면 참(선택 칸이 없어도 참).
|
|
96
|
+
function refField(S, x, parts, [k, kind, optional]) {
|
|
97
|
+
const v = x[k];
|
|
98
|
+
if (v === undefined && optional) return true;
|
|
99
|
+
const [test, msg] = REF_KIND_CHECK[kind];
|
|
100
|
+
if (test(v)) return true;
|
|
101
|
+
S.bad([...parts, k], v === undefined ? '칸 없음' : msg);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 참조 하나. 꼴이 맞으면 확인할 목록(S.refs)에 넣는다.
|
|
106
|
+
function ref(S, x, parts) {
|
|
107
|
+
if (isGap(x)) return gapOk(S, x, parts);
|
|
108
|
+
if (!obj(x)) return S.bad(parts, '참조가 객체가 아님');
|
|
109
|
+
if (!REF_KINDS.includes(x.kind)) return S.bad([...parts, 'kind'], `${REF_KINDS.join(', ')} 가운데 하나가 아님: ${x.kind}`);
|
|
110
|
+
const ok = REF_FIELDS[x.kind].map((f) => refField(S, x, parts, f)).every(Boolean);
|
|
111
|
+
if (x.note !== undefined && typeof x.note !== 'string') S.bad([...parts, 'note'], '문자열이 아님');
|
|
112
|
+
if (ok) S.refs.push({ ref: x, parts });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function refList(S, o, key, parts, optional = false) {
|
|
116
|
+
if (!want(S, o, key, parts, 'arr', optional) || o[key] === undefined) return;
|
|
117
|
+
o[key].forEach((x, i) => ref(S, x, [...parts, key, i]));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function modules(S, o, parts) {
|
|
121
|
+
if (o.modules === undefined) return;
|
|
122
|
+
if (!(Array.isArray(o.modules) && o.modules.every(str))) return S.bad([...parts, 'modules'], '문자열 배열이 아님');
|
|
123
|
+
o.modules.forEach((id, i) => S.modRefs.push({ id, parts: [...parts, 'modules', i] }));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 객체 배열의 원소마다 check. 객체가 아니면 FAIL.
|
|
127
|
+
function each(S, list, parts, check) {
|
|
128
|
+
list.forEach((x, i) => (obj(x) ? check(x, [...parts, i]) : S.bad([...parts, i], '객체가 아님')));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 모듈 하나. 검증한 모듈(status 없음이나 "verified")은 evidence 에 gap 아닌 참조 1개 이상, 미검증 모듈("unverified")은 why.
|
|
132
|
+
function moduleItem(S, x, p) {
|
|
133
|
+
const unverified = x.status === 'unverified';
|
|
134
|
+
if (x.status !== undefined && !['verified', 'unverified'].includes(x.status)) S.bad([...p, 'status'], `verified, unverified 가운데 하나가 아님: ${x.status}`);
|
|
135
|
+
fields(S, x, p, [['id', 'str'], ['module', 'str'], ['result', 'str', unverified], ['why', 'str', !unverified]]);
|
|
136
|
+
if (str(x.id) && S.ids.has(x.id)) S.bad([...p, 'id'], `모듈 id 겹침: ${x.id}`);
|
|
137
|
+
if (str(x.id)) S.ids.add(x.id);
|
|
138
|
+
refList(S, x, 'evidence', p, unverified);
|
|
139
|
+
if (!unverified && Array.isArray(x.evidence)) S.claims.push({ parts: [...p, 'evidence'], n: x.evidence.length, msg: `증거 없음: 확인된 검증 증거가 없음(FAIL, WARN 없이 확인된 참조 0개). ${MODULE_WAY}` });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function verification(S, list, parts) {
|
|
143
|
+
each(S, list, parts, (x, p) => moduleItem(S, x, p));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function flow(S, f, parts) {
|
|
147
|
+
fields(S, f, parts, [['input', 'text'], ['logic', 'text'], ['output', 'text']]);
|
|
148
|
+
refList(S, f, 'refs', parts, true);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function proof(S, v, parts) {
|
|
152
|
+
if (want(S, v, 'implemented', parts, 'arr')) {
|
|
153
|
+
each(S, v.implemented, [...parts, 'implemented'], (x, p) => {
|
|
154
|
+
want(S, x, 'feature', p, 'str');
|
|
155
|
+
modules(S, x, p);
|
|
156
|
+
refList(S, x, 'proofs', p);
|
|
157
|
+
if (Array.isArray(x.proofs)) S.claims.push({ parts: [...p, 'proofs'], n: x.proofs.length, msg: `증거 없음: 확인된 구현 증명이 없음(FAIL, WARN 없이 확인된 참조 0개). ${FEATURE_WAY}` });
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (want(S, v, 'notImplemented', parts, 'arr')) each(S, v.notImplemented, [...parts, 'notImplemented'], (x, p) => fields(S, x, p, [['feature', 'str'], ['why', 'str']]));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function project(S, p) {
|
|
164
|
+
const parts = ['project'];
|
|
165
|
+
slot(S, p, 'verification', parts, { kind: 'arr', check: (v, q) => verification(S, v, q) });
|
|
166
|
+
slot(S, p, 'flow', parts, { kind: 'obj', check: (v, q) => flow(S, v, q) });
|
|
167
|
+
slot(S, p, 'proof', parts, { kind: 'obj', check: (v, q) => proof(S, v, q) });
|
|
168
|
+
// 검증과 증거 칸 전체를 gap 으로 둘 수 없음(주장에는 증거). 흐름의 gap 은 밀도 WARN
|
|
169
|
+
if (isGap(p.verification) && str(p.verification.gap)) S.unproven([...parts, 'verification'], `증거 없음: 검증 칸 전체를 gap 으로 둘 수 없음. 모듈을 적고 ${MODULE_WAY}`);
|
|
170
|
+
if (isGap(p.proof) && str(p.proof.gap)) S.unproven([...parts, 'proof'], `증거 없음: 증거 칸 전체를 gap 으로 둘 수 없음. implemented 를 비우고 ${FEATURE_WAY}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function options(S, d, parts) {
|
|
174
|
+
if (!want(S, d, 'options', parts, 'arr')) return;
|
|
175
|
+
each(S, d.options, [...parts, 'options'], (x, p) => fields(S, x, p, [['name', 'str'], ['pros', 'text'], ['cons', 'text']]));
|
|
176
|
+
if (d.options.length < 2 && !str(d.rejected)) S.bad([...parts, 'options'], `선택지 ${d.options.length}개. 2개 미만이면 rejected(다른 선택지를 버린 사유) 필요`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function tradeoffs(S, v, parts) {
|
|
180
|
+
if (want(S, v, 'gaveUp', parts, 'arr')) each(S, v.gaveUp, [...parts, 'gaveUp'], (x, p) => fields(S, x, p, [['what', 'str'], ['why', 'str']]));
|
|
181
|
+
if (want(S, v, 'gained', parts, 'arr')) each(S, v.gained, [...parts, 'gained'], (x, p) => fields(S, x, p, [['what', 'str'], ['purpose', 'str']]));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function decision(S, d, parts, seen) {
|
|
185
|
+
fields(S, d, parts, [['id', 'str'], ['title', 'str'], ['context', 'str'], ['decision', 'str'], ['why', 'str'], ['rejected', 'str', true]]);
|
|
186
|
+
if (str(d.id) && seen.has(d.id)) S.bad([...parts, 'id'], `결정 id 겹침: ${d.id}`);
|
|
187
|
+
seen.add(d.id);
|
|
188
|
+
if (!Object.hasOwn(STATUS, d.status)) S.bad([...parts, 'status'], `${Object.keys(STATUS).join(', ')} 가운데 하나가 아님: ${d.status}`);
|
|
189
|
+
if (d.date !== undefined && !(typeof d.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d.date))) S.bad([...parts, 'date'], `YYYY-MM-DD 가 아님: ${d.date}`);
|
|
190
|
+
options(S, d, parts);
|
|
191
|
+
slot(S, d, 'optimization', parts, { kind: 'obj', check: (v, q) => (want(S, v, 'process', q, 'str'), modules(S, v, q), refList(S, v, 'refs', q)) });
|
|
192
|
+
slot(S, d, 'scaling', parts, { kind: 'obj', check: (v, q) => (fields(S, v, q, [['argument', 'str'], ['limits', 'text', true]]), refList(S, v, 'refs', q)) });
|
|
193
|
+
slot(S, d, 'tradeoffs', parts, { kind: 'obj', check: (v, q) => tradeoffs(S, v, q) });
|
|
194
|
+
refList(S, d, 'sources', parts, true);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 두 층 스키마 검사. 돌려줌: 검사 결과 S(schema, refs, modRefs, claims). 주장 판정은 참조 확인 뒤(examine).
|
|
198
|
+
export function adrSchema(v) {
|
|
199
|
+
const S = state();
|
|
200
|
+
const a = obj(v);
|
|
201
|
+
if (!a) return S.bad([], '객체가 아님'), S;
|
|
202
|
+
if (want(S, a, 'project', [], 'obj')) project(S, a.project);
|
|
203
|
+
if (want(S, a, 'decisions', [], 'arr')) {
|
|
204
|
+
const seen = new Set();
|
|
205
|
+
each(S, a.decisions, ['decisions'], (d, p) => decision(S, d, p, seen));
|
|
206
|
+
}
|
|
207
|
+
for (const m of S.modRefs) if (!S.ids.has(m.id)) S.bad(m.parts, `모듈 없음: ${m.id}`);
|
|
208
|
+
return S;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------- 참조 확인 ----------
|
|
212
|
+
|
|
213
|
+
const within = (a, b) => {
|
|
214
|
+
const rel = path.relative(a, b);
|
|
215
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
216
|
+
};
|
|
217
|
+
const realOr = (p) => {
|
|
218
|
+
try {
|
|
219
|
+
return fs.realpathSync(p);
|
|
220
|
+
} catch {
|
|
221
|
+
return path.resolve(p);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// 프로젝트 루트 안 상대 경로의 일반 파일. 돌려줌: { file } | { err }. 절대 경로, .., 루트 밖 링크, 없음, 폴더와 특수 파일은 err.
|
|
226
|
+
function localFile(root, p) {
|
|
227
|
+
if (path.isAbsolute(p) || /^[\\/]/.test(p) || /^[A-Za-z]:/.test(p)) return { err: `절대 경로: ${p}` };
|
|
228
|
+
if (p.split(/[\\/]/).includes('..')) return { err: `.. 이 든 경로: ${p}` };
|
|
229
|
+
let real;
|
|
230
|
+
try {
|
|
231
|
+
real = fs.realpathSync(path.resolve(root, p));
|
|
232
|
+
} catch {
|
|
233
|
+
return { err: `파일 없음: ${p}` };
|
|
234
|
+
}
|
|
235
|
+
if (!within(root, real)) return { err: `프로젝트 루트 밖을 가리키는 링크: ${p}` };
|
|
236
|
+
if (!fs.statSync(real).isFile()) return { err: `일반 파일이 아님(폴더나 특수 파일): ${p}` };
|
|
237
|
+
return { file: real };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 크기 상한 안의 일반 파일 글. 넘으면 null.
|
|
241
|
+
function readRef(file) {
|
|
242
|
+
try {
|
|
243
|
+
return readRegular(file, 'utf8', fs, REF_READ_MAX);
|
|
244
|
+
} catch (e) {
|
|
245
|
+
if (e.code === 'EFBIG') return null;
|
|
246
|
+
throw e;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const lineCount = (t) => (t ? t.split('\n').length - (t.endsWith('\n') ? 1 : 0) : 0);
|
|
251
|
+
const TOO_BIG = `크기 상한(${REF_READ_MAX / 1024 / 1024} MB)을 넘어 내용을 확인 못 함`;
|
|
252
|
+
|
|
253
|
+
function fileRef(env, x) {
|
|
254
|
+
const got = localFile(env.root, x.path);
|
|
255
|
+
if (got.err) return { fail: got.err };
|
|
256
|
+
if (x.line === undefined) return null;
|
|
257
|
+
const text = readRef(got.file);
|
|
258
|
+
if (text === null) return { warn: `${TOO_BIG}: ${x.path}` };
|
|
259
|
+
const n = lineCount(text);
|
|
260
|
+
return x.line > n ? { fail: `줄 ${x.line} 이 줄 수 ${n} 을 넘음: ${x.path}` } : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function testRef(env, x) {
|
|
264
|
+
const got = localFile(env.root, x.path);
|
|
265
|
+
if (got.err) return { fail: got.err };
|
|
266
|
+
const text = readRef(got.file);
|
|
267
|
+
if (text === null) return { warn: `${TOO_BIG}: ${x.path}` };
|
|
268
|
+
return text.includes(x.name) ? null : { warn: `시험 이름이 파일에 없음: ${x.path} 의 ${x.name}` };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 커밋은 git 으로만 확인. sources.git 이 꺼졌거나 저장소가 아니면 WARN(확인 못 함).
|
|
272
|
+
function commitRef(env, x) {
|
|
273
|
+
if (!env.gitOn) return { warn: `sources.git 이 꺼져 있어 커밋을 확인 못 함: ${x.hash}` };
|
|
274
|
+
if (env.repo === undefined) {
|
|
275
|
+
try {
|
|
276
|
+
git(env.project, ['rev-parse', '--git-dir']);
|
|
277
|
+
env.repo = true;
|
|
278
|
+
} catch {
|
|
279
|
+
env.repo = false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (!env.repo) return { warn: `git 저장소가 아니거나 git 이 없어 커밋을 확인 못 함: ${x.hash}` };
|
|
283
|
+
try {
|
|
284
|
+
git(env.project, ['cat-file', '-e', `${x.hash}^{commit}`]);
|
|
285
|
+
return null;
|
|
286
|
+
} catch {
|
|
287
|
+
return { fail: `커밋 없음(짧은 해시가 여럿과 겹쳐도 같음): ${x.hash}` };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function cmdRef(env, x) {
|
|
292
|
+
if (x.output === undefined) return { warn: `출력 파일이 없어 명령 결과를 확인 못 함: ${x.cmd}` };
|
|
293
|
+
const got = localFile(env.root, x.output);
|
|
294
|
+
return got.err ? { fail: got.err } : null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const VERIFY = { file: fileRef, test: testRef, commit: commitRef, cmd: cmdRef };
|
|
298
|
+
|
|
299
|
+
// S.refs 를 실제로 확인해 S.refFail, S.refWarn 에 넣는다.
|
|
300
|
+
function verifyRefs(S, ctx, config) {
|
|
301
|
+
const env = { project: ctx.project, root: realOr(ctx.project), gitOn: Boolean(config.sources && config.sources.git), repo: undefined };
|
|
302
|
+
for (const { ref: x, parts } of S.refs) {
|
|
303
|
+
const r = VERIFY[x.kind](env, x);
|
|
304
|
+
if (r && r.fail) S.refFail.push({ parts, msg: r.fail });
|
|
305
|
+
else if (r) S.refWarn.push({ parts, msg: r.warn });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 참조 확인 뒤 주장 판정. 증거 칸의 참조 가운데 확인할 목록에 들었고(꼴이 맞고 gap 아님) FAIL, WARN 이 없는 것만 센다.
|
|
310
|
+
function judgeClaims(S) {
|
|
311
|
+
const bad = new Set([...S.refFail, ...S.refWarn].map((x) => jp(x.parts)));
|
|
312
|
+
const good = new Set(S.refs.map((x) => jp(x.parts)).filter((p) => !bad.has(p)));
|
|
313
|
+
S.good = good;
|
|
314
|
+
for (const c of S.claims) {
|
|
315
|
+
const ok = Array.from({ length: c.n }, (_, i) => jp([...c.parts, i])).some((p) => good.has(p));
|
|
316
|
+
if (!ok) S.unproven(c.parts, c.msg);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 스키마, 참조 확인, 주장 판정을 차례로. 돌려줌: S
|
|
321
|
+
function examine(v, ctx, config) {
|
|
322
|
+
const S = adrSchema(v);
|
|
323
|
+
verifyRefs(S, ctx, config);
|
|
324
|
+
judgeClaims(S);
|
|
325
|
+
return S;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 두 원본 갈라짐: docs/adr/*.md 가운데 어떤 결정의 sources 도 가리키지 않는 파일.
|
|
329
|
+
function divergence(ctx, v) {
|
|
330
|
+
let names;
|
|
331
|
+
try {
|
|
332
|
+
names = fs.readdirSync(path.join(ctx.project, 'docs', 'adr'), { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith('.md')).map((e) => e.name);
|
|
333
|
+
} catch {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
const norm = (p) => String(p).replace(/\\/g, '/').replace(/^(\.\/)+/, '');
|
|
337
|
+
const pointed = new Set(arr(v.decisions).flatMap((d) => arr(obj(d) && d.sources).filter((x) => obj(x) && typeof x.path === 'string').map((x) => norm(x.path))));
|
|
338
|
+
return names.sort().filter((n) => !pointed.has(`docs/adr/${n}`)).map((n) => `docs/adr/${n} 를 가리키는 결정 없음`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// adr.json 읽기. 돌려줌: { missing } | { error } | { value }
|
|
342
|
+
function readAdr(dataDir) {
|
|
343
|
+
let text;
|
|
344
|
+
try {
|
|
345
|
+
text = readRegular(path.join(dataDir, ADR_FILE));
|
|
346
|
+
} catch (e) {
|
|
347
|
+
return e.code === 'ENOENT' ? { missing: true } : { error: `읽기 실패(${e.code || 'error'})` };
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
return { value: JSON.parse(noBom(text)) };
|
|
351
|
+
} catch (e) {
|
|
352
|
+
return { error: `JSON 아님: ${e.message}` };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 검사 전체(check 와 정리가 같이 씀). 돌려줌: { missing } | { error } | { value, S, diverge }
|
|
357
|
+
function inspectAdr(ctx, config) {
|
|
358
|
+
const got = readAdr(ctx.dataDir);
|
|
359
|
+
if (!Object.hasOwn(got, 'value')) return got;
|
|
360
|
+
const S = examine(got.value, ctx, config);
|
|
361
|
+
const diverge = S.schema.length ? [] : divergence(ctx, got.value);
|
|
362
|
+
return { value: got.value, S, diverge };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// check 의 ADR 부분. r: pm-check 의 sink. 돌려줌: { value: 밀도에 쓸 값, bare: 참조 없는 서술 칸 } (없거나 깨졌으면 null).
|
|
366
|
+
export function checkAdr(ctx, config, r) {
|
|
367
|
+
const got = inspectAdr(ctx, config);
|
|
368
|
+
if (got.missing) return null;
|
|
369
|
+
if (got.error) return r.fail(ADR_FILE, '$', got.error), null;
|
|
370
|
+
for (const x of [...got.S.schema, ...got.S.claim]) r.fail(ADR_FILE, x.p, x.msg);
|
|
371
|
+
for (const x of got.S.refFail) r.fail(ADR_FILE, jp(x.parts), `참조: ${x.msg}`);
|
|
372
|
+
for (const x of got.S.refWarn) r.warn(ADR_FILE, jp(x.parts), `참조: ${x.msg}`);
|
|
373
|
+
for (const msg of got.diverge) r.warn(ADR_FILE, 'sources:', msg);
|
|
374
|
+
return { value: got.value, bare: bareSlots(got.value, (p) => got.S.good.has(p)) };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// json 경로 조각 자리에 값을 넣는다.
|
|
378
|
+
function setAt(root, parts, value) {
|
|
379
|
+
const holder = parts.slice(0, -1).reduce((o, k) => o[k], root);
|
|
380
|
+
holder[parts[parts.length - 1]] = value;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
384
|
+
// 현지 시각 YYYYMMDD-HHMMSS.
|
|
385
|
+
const stamp = (ms) => {
|
|
386
|
+
const d = new Date(ms);
|
|
387
|
+
return `${d.getFullYear()}${two(d.getMonth() + 1)}${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}${two(d.getSeconds())}`;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// 스키마 FAIL 이 남은 adr.json 을 같은 폴더의 adr.json.rejected-<시각>(있으면 -2, -3 ...)으로 옮긴다. 결정 기록을 잃지 않게
|
|
391
|
+
// 지우지 않는다. 새 이름은 COPYFILE_EXCL 로 만들어 있는 파일을 덮지 않고, 사본을 만든 뒤에만 원본을 지운다. 돌려줌: 옮긴 경로.
|
|
392
|
+
function reject(file, now) {
|
|
393
|
+
const base = `${file}.rejected-${stamp(now)}`;
|
|
394
|
+
for (let i = 1; ; i++) {
|
|
395
|
+
const dest = i === 1 ? base : `${base}-${i}`;
|
|
396
|
+
try {
|
|
397
|
+
fs.copyFileSync(file, dest, fs.constants.COPYFILE_EXCL);
|
|
398
|
+
} catch (e) {
|
|
399
|
+
if (e.code === 'EEXIST') continue;
|
|
400
|
+
throw e;
|
|
401
|
+
}
|
|
402
|
+
fs.rmSync(file, { force: true });
|
|
403
|
+
return dest;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// init 정리 단계(결정 I). 스키마 위반이 남으면 adr.json 을 adr.json.rejected-<시각> 으로 옮겨 자료 없음으로, 참조 FAIL 만
|
|
408
|
+
// 있으면 그 참조를 { gap: "참조 확인 실패: ..." } 로 바꿔 파일을 남긴다. 돌려줌: 출력 줄 목록.
|
|
409
|
+
export async function cleanAdr(ctx, config, now = Date.now()) {
|
|
410
|
+
const file = path.join(ctx.dataDir, ADR_FILE);
|
|
411
|
+
const got = inspectAdr(ctx, config);
|
|
412
|
+
if (got.missing) return ['정리: adr.json 없음'];
|
|
413
|
+
const schema = got.error ? 1 : got.S.schema.length;
|
|
414
|
+
if (schema) return [`정리: adr.json 을 ${reject(file, now)} 로 옮김(스키마 FAIL ${schema}건이 남아 자료 없음으로 둠)`];
|
|
415
|
+
const fails = got.S.refFail;
|
|
416
|
+
if (!fails.length) return ['정리: adr.json 참조 FAIL 0, 바꾼 것 없음'];
|
|
417
|
+
for (const f of fails) setAt(got.value, f.parts, { gap: `참조 확인 실패: ${f.msg}` });
|
|
418
|
+
await writeAtomic(file, JSON.stringify(got.value, null, 2) + '\n');
|
|
419
|
+
const out = [`정리: adr.json 참조 FAIL ${fails.length}건을 gap 으로 바꿈: ${fails.map((f) => jp(f.parts)).join(', ')}`];
|
|
420
|
+
// 바꾼 뒤 증거가 모두 gap 이 된 주장은 자동으로 옮기지 않는다(check 가 FAIL 로 보고, 에이전트가 판단해 옮김)
|
|
421
|
+
const left = examine(got.value, ctx, config).claim;
|
|
422
|
+
if (left.length) out.push(`정리: 증거가 남지 않은 주장 ${left.length}건(자동으로 옮기지 않음, check 의 FAIL 참고): ${left.map((x) => x.p).join(', ')}`);
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------- 밀도 ----------
|
|
427
|
+
|
|
428
|
+
const content = (v) => (isGap(v) ? null : v);
|
|
429
|
+
|
|
430
|
+
// 여섯 항목 칸 가운데 비었거나(gap, 없음) 채운 칸.
|
|
431
|
+
function slotsOf(p, ds) {
|
|
432
|
+
const list = [
|
|
433
|
+
['project.verification', p.verification],
|
|
434
|
+
['project.flow', p.flow],
|
|
435
|
+
['project.proof', p.proof],
|
|
436
|
+
];
|
|
437
|
+
ds.forEach((d, i) => DECISION_SLOTS.forEach(([k]) => list.push([`decisions[${i}].${k}`, d[k]])));
|
|
438
|
+
return list;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 서술 칸: 결정의 최적화 로직(검증한 과정)과 확장 대응. gap 이 아닌 객체만(gap 은 근거 없음 칸으로 센다). 돌려줌: [이름, 칸]
|
|
442
|
+
const NARRATIVE = ['optimization', 'scaling'];
|
|
443
|
+
function narrativeSlots(v) {
|
|
444
|
+
return arr(obj(v) && v.decisions).flatMap((d, i) => (obj(d) ? NARRATIVE.filter((k) => obj(d[k]) && !isGap(d[k])).map((k) => [`decisions[${i}].${k}`, d[k]]) : []));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 참조 없는 서술: 서술 칸 가운데 refs 에 확인된 참조가 0개인 칸 이름. 규칙은 WARN 이지만 배지와 check 마지막 줄에 늘 센다.
|
|
448
|
+
// good(json 경로 글): 확인된 참조인가. check 는 참조 확인을 마친 집합(FAIL, WARN 없음). 화면은 파일을 읽지 않으므로
|
|
449
|
+
// 기본값은 꼴이 맞고 gap 이 아닌 참조(적힌 참조)다.
|
|
450
|
+
export function bareSlots(v, good) {
|
|
451
|
+
const written = good ? null : new Set(adrSchema(v).refs.map((x) => jp(x.parts)));
|
|
452
|
+
const ok = good || ((p) => written.has(p));
|
|
453
|
+
return narrativeSlots(v)
|
|
454
|
+
.filter(([n, x]) => !arr(x.refs).some((_, j) => ok(`${n}.refs[${j}]`)))
|
|
455
|
+
.map(([n]) => n);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// 돌려줌: density 줄 [{tab: 'adr', item, value, min, miss}]. v 가 없으면(파일 없음, 깨짐) 모두 0.
|
|
459
|
+
// 증거 없는 모듈과 기능은 밀도가 아니라 FAIL(adrSchema 의 claim)이라 여기서 세지 않는다. bare: 참조 없는 서술 칸(없으면 적힌 참조로 잼).
|
|
460
|
+
export function adrRows(v, bare = bareSlots(v)) {
|
|
461
|
+
const rows = [];
|
|
462
|
+
const add = (item, value, min, miss = []) => rows.push({ tab: 'adr', item, value, min, miss });
|
|
463
|
+
const a = obj(v) || {};
|
|
464
|
+
const p = obj(a.project) || {};
|
|
465
|
+
const ds = arr(a.decisions).filter(obj);
|
|
466
|
+
const slots = slotsOf(p, ds);
|
|
467
|
+
const empty = slots.filter(([, x]) => x === undefined || isGap(x)).map(([n]) => n);
|
|
468
|
+
add('빈 항목', slots.length - empty.length, slots.length, empty);
|
|
469
|
+
const ver = arr(content(p.verification)).filter(obj);
|
|
470
|
+
add('검증', ver.length, 1);
|
|
471
|
+
const f = obj(content(p.flow)) || {};
|
|
472
|
+
const fmiss = [['input', '입력'], ['logic', '처리'], ['output', '출력']].filter(([k]) => !str(f[k])).map(([, n]) => n);
|
|
473
|
+
add('흐름', 3 - fmiss.length, 3, fmiss);
|
|
474
|
+
const impl = arr(obj(content(p.proof)) && p.proof.implemented).filter(obj);
|
|
475
|
+
add('증거', impl.length, 1);
|
|
476
|
+
add('결정', ds.length, 1);
|
|
477
|
+
const tmiss = ds.filter((d) => !(obj(content(d.tradeoffs)) && arr(d.tradeoffs.gaveUp).length && arr(d.tradeoffs.gained).length)).map((d) => d.id);
|
|
478
|
+
add('포기얻음', ds.length - tmiss.length, ds.length, tmiss);
|
|
479
|
+
const narr = narrativeSlots(a).length;
|
|
480
|
+
add('서술참조', narr - bare.length, narr, bare);
|
|
481
|
+
return rows;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// adr.json 안의 { gap } 수(여섯 항목 칸과 참조 자리).
|
|
485
|
+
function gapCount(v) {
|
|
486
|
+
if (Array.isArray(v)) return v.reduce((a, x) => a + gapCount(x), 0);
|
|
487
|
+
if (!obj(v)) return 0;
|
|
488
|
+
return isGap(v) ? 1 : Object.values(v).reduce((a, x) => a + gapCount(x), 0);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// 화면 배지와 check 끝 요약: 근거 없음(gap) 칸, 미검증 모듈, 구현 못 한 기능, 참조 없는 서술 칸 수. 모두 미검증이거나
|
|
492
|
+
// 서술 칸에 참조가 없어도 0칸만 보이는 오해를 막는다. bare: 참조 없는 서술 칸(없으면 적힌 참조로 잼).
|
|
493
|
+
export function adrTally(v, bare = bareSlots(v)) {
|
|
494
|
+
const p = obj(obj(v) && v.project) || {};
|
|
495
|
+
const unverified = arr(p.verification).filter((x) => obj(x) && x.status === 'unverified').length;
|
|
496
|
+
const notImplemented = arr(obj(p.proof) && p.proof.notImplemented).filter(obj).length;
|
|
497
|
+
const gaps = gapCount(v);
|
|
498
|
+
return { text: `근거 없음 ${gaps}칸, 미검증 ${unverified}, 구현 못 함 ${notImplemented}, 참조 없는 서술 ${bare.length}`, any: gaps + unverified + notImplemented + bare.length > 0 };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ---------- 렌더 ----------
|
|
502
|
+
|
|
503
|
+
const gapHtml = (v) => `<span class="b miss">근거 없음: ${esc(v.gap)}</span>`;
|
|
504
|
+
|
|
505
|
+
// 참조 한 줄 글. 파일을 읽거나 링크하지 않는다.
|
|
506
|
+
export function refText(x) {
|
|
507
|
+
if (!obj(x)) return '';
|
|
508
|
+
if (isGap(x)) return gapHtml(x);
|
|
509
|
+
const note = typeof x.note === 'string' && x.note ? ` <small>${esc(x.note)}</small>` : '';
|
|
510
|
+
const line = Number.isInteger(x.line) ? `:${x.line}` : '';
|
|
511
|
+
const out = {
|
|
512
|
+
file: () => `파일 <code>${esc(x.path)}${line}</code>`,
|
|
513
|
+
test: () => `시험 <code>${esc(x.path)}</code> 의 ${esc(x.name)}`,
|
|
514
|
+
commit: () => `커밋 <code>${esc(String(x.hash || '').slice(0, 7))}</code>`,
|
|
515
|
+
cmd: () => `명령 <code>${esc(x.cmd)}</code>, 출력 ${x.output ? `<code>${esc(x.output)}</code>` : '없음'}`,
|
|
516
|
+
}[x.kind];
|
|
517
|
+
return out ? out() + note : esc(JSON.stringify(x)) + note;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const refsHtml = (list) => arr(list).map(refText).filter(Boolean).join('<br>') || '<span class="n">참조 없음</span>';
|
|
521
|
+
const table = (head, rows) => `<table><thead><tr>${head.map((h) => `<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.join('') || `<tr><td colspan="${head.length}" class="n">없음</td></tr>`}</tbody></table>`;
|
|
522
|
+
const td = (h, cls = '') => `<td${cls ? ` class="${cls}"` : ''}>${h}</td>`;
|
|
523
|
+
const sec = (title, body) => `<h3>${esc(title)}</h3>${body}`;
|
|
524
|
+
// 칸이 gap 이면 근거 없음, 아니면 body(값).
|
|
525
|
+
const slotHtml = (v, body) => (isGap(v) ? `<div>${gapHtml(v)}</div>` : obj(v) || Array.isArray(v) ? body(v) : `<div>${gapHtml({ gap: '칸 없음' })}</div>`);
|
|
526
|
+
|
|
527
|
+
const evidenceHtml = (x) => (x.status === 'unverified' ? `<span class="b unv">미검증: ${esc(x.why)}</span>${arr(x.evidence).length ? `<br>${refsHtml(x.evidence)}` : ''}` : refsHtml(x.evidence));
|
|
528
|
+
|
|
529
|
+
function verificationHtml(v) {
|
|
530
|
+
return table(['id', '단위 기능', '결과', 'evidence'], arr(v).filter(obj).map((x) => `<tr>${td(esc(x.id), 'h')}${td(esc(x.module))}${td(esc(x.result || '-'))}${td(evidenceHtml(x), 'n')}</tr>`));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function flowHtml(f) {
|
|
534
|
+
const rows = [['입력', f.input], ['처리', f.logic], ['출력', f.output]].map(([k, t]) => `<tr>${td(k, 't')}${td(str(t) ? esc(t) : gapHtml({ gap: '빈 칸' }))}</tr>`);
|
|
535
|
+
return `${table(['구분', '내용'], rows)}<div class="meta">참조: ${refsHtml(f.refs)}</div>`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function proofHtml(p) {
|
|
539
|
+
const impl = arr(p.implemented).filter(obj).map((x) => `<tr>${td(esc(x.feature))}${td(esc(arr(x.modules).join(', ')), 'h')}${td(refsHtml(x.proofs), 'n')}</tr>`);
|
|
540
|
+
const not = arr(p.notImplemented).filter(obj).map((x) => `<tr>${td(esc(x.feature))}${td(esc(x.why))}</tr>`);
|
|
541
|
+
return `<div class="meta">구현된 기능과 증명 위치</div>${table(['구현된 기능', '모듈', '증명 위치'], impl)}<div class="meta">구현 못 한 기능</div>${table(['구현 못 한 기능', '사유'], not)}`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const filled = (d) => DECISION_SLOTS.filter(([k]) => obj(d[k]) && !isGap(d[k])).length;
|
|
545
|
+
const emptyNames = (d) => DECISION_SLOTS.filter(([k]) => !(obj(d[k]) && !isGap(d[k]))).map(([, n]) => n);
|
|
546
|
+
|
|
547
|
+
function decisionRow(d) {
|
|
548
|
+
const miss = emptyNames(d);
|
|
549
|
+
const st = STATUS[d.status] || d.status;
|
|
550
|
+
return `<tr>${td(esc(d.id), 'h')}${td(esc(d.title))}${td(esc(st))}${td(esc(d.date || '-'), 't')}${td(`${filled(d)}/3`, 'd')}${td(miss.length ? `<span class="b miss">${esc(miss.join(', '))}</span>` : '-', 'n')}</tr>`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function tradeoffsHtml(t) {
|
|
554
|
+
const gave = arr(t.gaveUp).filter(obj).map((x) => `<tr>${td(esc(x.what))}${td(esc(x.why))}</tr>`);
|
|
555
|
+
const got = arr(t.gained).filter(obj).map((x) => `<tr>${td(esc(x.what))}${td(esc(x.purpose))}</tr>`);
|
|
556
|
+
return `${table(['포기한 것', '이유'], gave)}${table(['얻은 것', '목적'], got)}`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function decisionHtml(d) {
|
|
560
|
+
const opts = arr(d.options).filter(obj).map((x) => `<tr>${td(esc(x.name))}${td(esc(x.pros))}${td(esc(x.cons))}</tr>`);
|
|
561
|
+
const rejected = str(d.rejected) ? `<div class="meta">버린 선택지: ${esc(d.rejected)}</div>` : '';
|
|
562
|
+
const optim = (o) => `<div>${esc(o.process)}</div><div class="meta">참조한 모듈: ${esc(arr(o.modules).join(', ') || '없음')}</div><div class="meta">참조: ${refsHtml(o.refs)}</div>`;
|
|
563
|
+
const scale = (s) => `<div>${esc(s.argument)}</div>${str(s.limits) ? `<div class="meta">한계: ${esc(s.limits)}</div>` : ''}<div class="meta">참조: ${refsHtml(s.refs)}</div>`;
|
|
564
|
+
return `<h2>${esc(d.id)} ${esc(d.title)} <small>${esc(STATUS[d.status] || d.status)}</small></h2>
|
|
565
|
+
<div class="meta">맥락: ${esc(d.context)}</div>${table(['선택지', '장점', '단점'], opts)}${rejected}
|
|
566
|
+
<div><b>결론</b>: ${esc(d.decision)}</div><div class="meta">이유: ${esc(d.why)}</div>
|
|
567
|
+
${sec('최적화 로직', slotHtml(d.optimization, optim))}${sec('확장 대응', slotHtml(d.scaling, scale))}${sec('포기와 얻은 것', slotHtml(d.tradeoffs, tradeoffsHtml))}
|
|
568
|
+
<div class="meta">근거 출처: ${refsHtml(d.sources)}</div>`;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ADR 탭 본문. 자료가 없거나 깨졌으면 다른 탭처럼 안내와 스키마 문서.
|
|
572
|
+
export function adrBody(m, emptyNotice) {
|
|
573
|
+
const v = m.adr && obj(m.adr.value);
|
|
574
|
+
if (!v) return emptyNotice('references/adr.md');
|
|
575
|
+
const p = obj(v.project) || {};
|
|
576
|
+
const ds = arr(v.decisions).filter(obj);
|
|
577
|
+
const tally = adrTally(v);
|
|
578
|
+
return `<div class="meta">의사 결정 기록. 위는 프로젝트 전체(검증, 흐름, 증거), 아래는 결정마다(최적화 로직, 확장 대응, 포기와 얻은 것). 자료: <code>adr.json</code>. 참조는 위치만 보이고 이 화면은 파일을 열지 않음. 참조 확인은 check.</div>
|
|
579
|
+
<div class="meta"><span class="b ${tally.any ? 'miss' : 'done'}">${esc(tally.text)}</span></div>
|
|
580
|
+
<h2>프로젝트 요약</h2>
|
|
581
|
+
${sec('검증', slotHtml(p.verification, verificationHtml))}${sec('흐름', slotHtml(p.flow, flowHtml))}${sec('증거', slotHtml(p.proof, proofHtml))}
|
|
582
|
+
<h2>결정 목록 <small>${ds.length}건</small></h2>
|
|
583
|
+
${table(['id', '제목', '상태', '날짜', '결정별 3항목', '빈 항목'], ds.map(decisionRow))}
|
|
584
|
+
${ds.map(decisionHtml).join('\n')}`;
|
|
585
|
+
}
|