leerness 1.18.0 → 1.20.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/CHANGELOG.md +136 -0
- package/README.ko.md +187 -0
- package/README.md +50 -134
- package/SECURITY.md +66 -56
- package/bin/leerness.js +351 -52
- package/docs/clean-room-evaluations.md +67 -0
- package/lib/audit.js +336 -322
- package/package.json +1 -1
- package/scripts/e2e.js +11 -11
package/lib/audit.js
CHANGED
|
@@ -1,322 +1,336 @@
|
|
|
1
|
-
// lib/audit.js — audit 핸들러 (UR-0025/UR-0125 큰 핸들러 모듈화 6번째, 1.9.421)
|
|
2
|
-
// bin/leerness.js 에서 audit(310줄) 분리. DI: harness 고유 의존(VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills) 주입.
|
|
3
|
-
// io 프리미티브는 ./io, SECRET_PATTERNS 는 ./catalogs, cp/path 빌트인. 동작/출력 무변경.
|
|
4
|
-
'use strict';
|
|
5
|
-
const cp = require('child_process');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
|
|
8
|
-
const { SECRET_PATTERNS } = require('./catalogs');
|
|
9
|
-
|
|
10
|
-
function audit(root, opts = {}, deps = {}) {
|
|
11
|
-
const { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills } = deps;
|
|
12
|
-
root = absRoot(root);
|
|
13
|
-
let warnings = 0, failures = 0;
|
|
14
|
-
// 1.9.35 개선 #5: --fix 옵션 — 자동 수정 가능한 항목 적용
|
|
15
|
-
const fix = has('--fix');
|
|
16
|
-
let fixed = 0;
|
|
17
|
-
// 1.9.102: --json 모드 — stdout 억제 후 구조화 출력
|
|
18
|
-
const jsonMode = !!opts.json || has('--json');
|
|
19
|
-
const findings = [];
|
|
20
|
-
const _finding = (kind, severity, message, details = {}) => findings.push({ kind, severity, message, ...details });
|
|
21
|
-
const _origWrite = process.stdout.write.bind(process.stdout);
|
|
22
|
-
if (jsonMode) process.stdout.write = () => true;
|
|
23
|
-
try {
|
|
24
|
-
// 외부리뷰 CV-3/UR-0078: 미초기화/존재하지 않는 경로를 healthy 로 오판하던 것 수정 — 필수 마커 부재 시 failure 승격(verify 와 일관).
|
|
25
|
-
if (!exists(root) || !exists(path.join(root, '.harness')) || !exists(path.join(root, 'AGENTS.md'))) {
|
|
26
|
-
failures++;
|
|
27
|
-
fail(`미초기화 또는 존재하지 않는 경로: ${root} (.harness/AGENTS.md 없음 — leerness init 필요)`);
|
|
28
|
-
_finding('not_initialized', 'fail', 'uninitialized or missing path (.harness or AGENTS.md absent)', { root });
|
|
29
|
-
}
|
|
30
|
-
const designCands = ['designguide.md','design-guide.md','docs/designguide.md','docs/design-guide.md','.harness/designguide.md'];
|
|
31
|
-
const dups = designCands.filter(f => exists(path.join(root,f)));
|
|
32
|
-
if (dups.length) { warnings++; warn(`design guide duplicates outside canonical: ${dups.join(', ')} (run: leerness consistency merge-design-guide)`); _finding('design_dup', 'warn', 'design guide duplicates outside canonical', { duplicates: dups }); }
|
|
33
|
-
else ok('no duplicate design guide candidates');
|
|
34
|
-
// 1.9.1 P4: <!-- leerness:na --> 마커가 있는 파일은 placeholder 경고 스킵.
|
|
35
|
-
const naMarker = '<!-- leerness:na';
|
|
36
|
-
const ds = exists(path.join(root,'.harness/design-system.md')) ? read(path.join(root,'.harness/design-system.md')) : '';
|
|
37
|
-
if (ds.includes(naMarker)) ok('design-system.md marked NA (skipped)');
|
|
38
|
-
else if (!/\| color\.primary \|/.test(ds) || /\(실제 값으로 업데이트\)/.test(ds)) { warnings++; warn('design-system.md tokens not customized'); _finding('design_system_default', 'warn', 'design-system.md tokens not customized'); }
|
|
39
|
-
else ok('design-system tokens populated');
|
|
40
|
-
const reuse = exists(path.join(root,'.harness/reuse-map.md')) ? read(path.join(root,'.harness/reuse-map.md')) : '';
|
|
41
|
-
const reuseLines = reuse.split('\n').filter(l => l.startsWith('|') && !/Capability|---/.test(l)).length;
|
|
42
|
-
if (reuse.includes(naMarker)) ok('reuse-map.md marked NA (skipped)');
|
|
43
|
-
else if (reuseLines === 0) { warnings++; warn('reuse-map.md is empty (consider populating known reusable elements)'); _finding('reuse_map_empty', 'warn', 'reuse-map.md is empty'); }
|
|
44
|
-
else ok(`reuse-map.md has ${reuseLines} entries`);
|
|
45
|
-
const planText = exists(planPath(root)) ? read(planPath(root)) : '';
|
|
46
|
-
const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4})\./gm)).map(m => m[1]);
|
|
47
|
-
const rows = readProgressRows(root);
|
|
48
|
-
// 1.9.6 수정: 한 row에 여러 plan:M-XXXX 링크가 있어도 모두 인식 (matchAll로 전부 추출)
|
|
49
|
-
const linkedMs = new Set(
|
|
50
|
-
rows.flatMap(r => Array.from(String(r.evidence || '').matchAll(/M-\d{4}/g), m => m[0]))
|
|
51
|
-
);
|
|
52
|
-
const missingFromProgress = milestoneIds.filter(m => !linkedMs.has(m));
|
|
53
|
-
if (missingFromProgress.length) {
|
|
54
|
-
warnings++;
|
|
55
|
-
warn(`milestones without progress entry: ${missingFromProgress.join(', ')}`);
|
|
56
|
-
_finding('milestone_unlinked', 'warn', 'milestones without progress entry', { milestones: missingFromProgress });
|
|
57
|
-
log(` → 자동 매칭 제안: leerness task relink`);
|
|
58
|
-
log(` → 자동 적용: leerness task relink --apply`);
|
|
59
|
-
}
|
|
60
|
-
else if (milestoneIds.length) ok('all milestones linked in progress-tracker');
|
|
61
|
-
const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
|
|
62
|
-
if (handoff.includes('Last generated: (자동)')) {
|
|
63
|
-
warnings++; warn('session-handoff.md never auto-generated (run: leerness session close .)');
|
|
64
|
-
_finding('handoff_not_generated', 'warn', 'session-handoff.md never auto-generated');
|
|
65
|
-
// 1.9.35 #5: --fix → session-handoff.md 자동 생성 마커 갱신
|
|
66
|
-
if (fix) {
|
|
67
|
-
const stamped = handoff.replace('Last generated: (자동)', `Last generated: ${today()} (leerness audit --fix)`);
|
|
68
|
-
writeUtf8(handoffPath(root), stamped);
|
|
69
|
-
ok(' ↳ fixed: session-handoff.md timestamp 갱신');
|
|
70
|
-
fixed++;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
else if (handoff.includes('Last generated:')) ok('session-handoff.md auto-generated previously');
|
|
74
|
-
const cur = exists(currentStatePath(root)) ? read(currentStatePath(root)) : '';
|
|
75
|
-
const updMatch = cur.match(/Updated: (\d{4}-\d{2}-\d{2})/);
|
|
76
|
-
if (updMatch) {
|
|
77
|
-
const dDays = (Date.now() - new Date(updMatch[1]).getTime()) / 86400000;
|
|
78
|
-
if (dDays > 7) {
|
|
79
|
-
warnings++; warn(`current-state.md stale (${Math.round(dDays)} days)`);
|
|
80
|
-
_finding('current_state_stale', 'warn', 'current-state.md stale', { days: Math.round(dDays) });
|
|
81
|
-
// 1.9.35 #5: --fix → current-state.md Updated 라인 갱신
|
|
82
|
-
if (fix) {
|
|
83
|
-
const stamped = cur.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
|
|
84
|
-
writeUtf8(currentStatePath(root), stamped);
|
|
85
|
-
ok(' ↳ fixed: current-state.md Updated 갱신');
|
|
86
|
-
fixed++;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
else ok('current-state.md fresh');
|
|
90
|
-
}
|
|
91
|
-
// 1.9.40: README의 version 배지 ↔ package.json#version mismatch 감지 (도구 만드는 자가 자기 도구 stale하는 dogfooding gap 차단)
|
|
92
|
-
try {
|
|
93
|
-
const readmePath = path.join(root, 'README.md');
|
|
94
|
-
const pkgPath = path.join(root, 'package.json');
|
|
95
|
-
if (exists(readmePath) && exists(pkgPath)) {
|
|
96
|
-
const readmeText = read(readmePath);
|
|
97
|
-
const pkg = JSON.parse(read(pkgPath));
|
|
98
|
-
const m = readmeText.match(/badge\/version-(\d+\.\d+\.\d+)/);
|
|
99
|
-
if (pkg.version && m && m[1] !== pkg.version) {
|
|
100
|
-
warnings++;
|
|
101
|
-
warn(`README.md version badge mismatch: README=${m[1]} vs package.json=${pkg.version} (run: leerness readme sync)`);
|
|
102
|
-
_finding('readme_version_mismatch', 'warn', 'README.md version badge mismatch', { readme: m[1], pkg: pkg.version });
|
|
103
|
-
if (fix) {
|
|
104
|
-
const updated = readmeText.replace(/badge\/version-[\d.]+-(green|blue|red)/g, `badge/version-${pkg.version}-green`);
|
|
105
|
-
writeUtf8(readmePath, updated);
|
|
106
|
-
ok(' ↳ fixed: README.md version 배지 갱신');
|
|
107
|
-
fixed++;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
// 1.9.
|
|
303
|
-
if (
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
warnings
|
|
308
|
-
failures,
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
1
|
+
// lib/audit.js — audit 핸들러 (UR-0025/UR-0125 큰 핸들러 모듈화 6번째, 1.9.421)
|
|
2
|
+
// bin/leerness.js 에서 audit(310줄) 분리. DI: harness 고유 의존(VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills) 주입.
|
|
3
|
+
// io 프리미티브는 ./io, SECRET_PATTERNS 는 ./catalogs, cp/path 빌트인. 동작/출력 무변경.
|
|
4
|
+
'use strict';
|
|
5
|
+
const cp = require('child_process');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
|
|
8
|
+
const { SECRET_PATTERNS } = require('./catalogs');
|
|
9
|
+
|
|
10
|
+
function audit(root, opts = {}, deps = {}) {
|
|
11
|
+
const { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills } = deps;
|
|
12
|
+
root = absRoot(root);
|
|
13
|
+
let warnings = 0, failures = 0;
|
|
14
|
+
// 1.9.35 개선 #5: --fix 옵션 — 자동 수정 가능한 항목 적용
|
|
15
|
+
const fix = has('--fix');
|
|
16
|
+
let fixed = 0;
|
|
17
|
+
// 1.9.102: --json 모드 — stdout 억제 후 구조화 출력
|
|
18
|
+
const jsonMode = !!opts.json || has('--json');
|
|
19
|
+
const findings = [];
|
|
20
|
+
const _finding = (kind, severity, message, details = {}) => findings.push({ kind, severity, message, ...details });
|
|
21
|
+
const _origWrite = process.stdout.write.bind(process.stdout);
|
|
22
|
+
if (jsonMode) process.stdout.write = () => true;
|
|
23
|
+
try {
|
|
24
|
+
// 외부리뷰 CV-3/UR-0078: 미초기화/존재하지 않는 경로를 healthy 로 오판하던 것 수정 — 필수 마커 부재 시 failure 승격(verify 와 일관).
|
|
25
|
+
if (!exists(root) || !exists(path.join(root, '.harness')) || !exists(path.join(root, 'AGENTS.md'))) {
|
|
26
|
+
failures++;
|
|
27
|
+
fail(`미초기화 또는 존재하지 않는 경로: ${root} (.harness/AGENTS.md 없음 — leerness init 필요)`);
|
|
28
|
+
_finding('not_initialized', 'fail', 'uninitialized or missing path (.harness or AGENTS.md absent)', { root });
|
|
29
|
+
}
|
|
30
|
+
const designCands = ['designguide.md','design-guide.md','docs/designguide.md','docs/design-guide.md','.harness/designguide.md'];
|
|
31
|
+
const dups = designCands.filter(f => exists(path.join(root,f)));
|
|
32
|
+
if (dups.length) { warnings++; warn(`design guide duplicates outside canonical: ${dups.join(', ')} (run: leerness consistency merge-design-guide)`); _finding('design_dup', 'warn', 'design guide duplicates outside canonical', { duplicates: dups }); }
|
|
33
|
+
else ok('no duplicate design guide candidates');
|
|
34
|
+
// 1.9.1 P4: <!-- leerness:na --> 마커가 있는 파일은 placeholder 경고 스킵.
|
|
35
|
+
const naMarker = '<!-- leerness:na';
|
|
36
|
+
const ds = exists(path.join(root,'.harness/design-system.md')) ? read(path.join(root,'.harness/design-system.md')) : '';
|
|
37
|
+
if (ds.includes(naMarker)) ok('design-system.md marked NA (skipped)');
|
|
38
|
+
else if (!/\| color\.primary \|/.test(ds) || /\(실제 값으로 업데이트\)/.test(ds)) { warnings++; warn('design-system.md tokens not customized'); _finding('design_system_default', 'warn', 'design-system.md tokens not customized'); }
|
|
39
|
+
else ok('design-system tokens populated');
|
|
40
|
+
const reuse = exists(path.join(root,'.harness/reuse-map.md')) ? read(path.join(root,'.harness/reuse-map.md')) : '';
|
|
41
|
+
const reuseLines = reuse.split('\n').filter(l => l.startsWith('|') && !/Capability|---/.test(l)).length;
|
|
42
|
+
if (reuse.includes(naMarker)) ok('reuse-map.md marked NA (skipped)');
|
|
43
|
+
else if (reuseLines === 0) { warnings++; warn('reuse-map.md is empty (consider populating known reusable elements)'); _finding('reuse_map_empty', 'warn', 'reuse-map.md is empty'); }
|
|
44
|
+
else ok(`reuse-map.md has ${reuseLines} entries`);
|
|
45
|
+
const planText = exists(planPath(root)) ? read(planPath(root)) : '';
|
|
46
|
+
const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4})\./gm)).map(m => m[1]);
|
|
47
|
+
const rows = readProgressRows(root);
|
|
48
|
+
// 1.9.6 수정: 한 row에 여러 plan:M-XXXX 링크가 있어도 모두 인식 (matchAll로 전부 추출)
|
|
49
|
+
const linkedMs = new Set(
|
|
50
|
+
rows.flatMap(r => Array.from(String(r.evidence || '').matchAll(/M-\d{4}/g), m => m[0]))
|
|
51
|
+
);
|
|
52
|
+
const missingFromProgress = milestoneIds.filter(m => !linkedMs.has(m));
|
|
53
|
+
if (missingFromProgress.length) {
|
|
54
|
+
warnings++;
|
|
55
|
+
warn(`milestones without progress entry: ${missingFromProgress.join(', ')}`);
|
|
56
|
+
_finding('milestone_unlinked', 'warn', 'milestones without progress entry', { milestones: missingFromProgress });
|
|
57
|
+
log(` → 자동 매칭 제안: leerness task relink`);
|
|
58
|
+
log(` → 자동 적용: leerness task relink --apply`);
|
|
59
|
+
}
|
|
60
|
+
else if (milestoneIds.length) ok('all milestones linked in progress-tracker');
|
|
61
|
+
const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
|
|
62
|
+
if (handoff.includes('Last generated: (자동)')) {
|
|
63
|
+
warnings++; warn('session-handoff.md never auto-generated (run: leerness session close .)');
|
|
64
|
+
_finding('handoff_not_generated', 'warn', 'session-handoff.md never auto-generated');
|
|
65
|
+
// 1.9.35 #5: --fix → session-handoff.md 자동 생성 마커 갱신
|
|
66
|
+
if (fix) {
|
|
67
|
+
const stamped = handoff.replace('Last generated: (자동)', `Last generated: ${today()} (leerness audit --fix)`);
|
|
68
|
+
writeUtf8(handoffPath(root), stamped);
|
|
69
|
+
ok(' ↳ fixed: session-handoff.md timestamp 갱신');
|
|
70
|
+
fixed++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (handoff.includes('Last generated:')) ok('session-handoff.md auto-generated previously');
|
|
74
|
+
const cur = exists(currentStatePath(root)) ? read(currentStatePath(root)) : '';
|
|
75
|
+
const updMatch = cur.match(/Updated: (\d{4}-\d{2}-\d{2})/);
|
|
76
|
+
if (updMatch) {
|
|
77
|
+
const dDays = (Date.now() - new Date(updMatch[1]).getTime()) / 86400000;
|
|
78
|
+
if (dDays > 7) {
|
|
79
|
+
warnings++; warn(`current-state.md stale (${Math.round(dDays)} days)`);
|
|
80
|
+
_finding('current_state_stale', 'warn', 'current-state.md stale', { days: Math.round(dDays) });
|
|
81
|
+
// 1.9.35 #5: --fix → current-state.md Updated 라인 갱신
|
|
82
|
+
if (fix) {
|
|
83
|
+
const stamped = cur.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
|
|
84
|
+
writeUtf8(currentStatePath(root), stamped);
|
|
85
|
+
ok(' ↳ fixed: current-state.md Updated 갱신');
|
|
86
|
+
fixed++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else ok('current-state.md fresh');
|
|
90
|
+
}
|
|
91
|
+
// 1.9.40: README의 version 배지 ↔ package.json#version mismatch 감지 (도구 만드는 자가 자기 도구 stale하는 dogfooding gap 차단)
|
|
92
|
+
try {
|
|
93
|
+
const readmePath = path.join(root, 'README.md');
|
|
94
|
+
const pkgPath = path.join(root, 'package.json');
|
|
95
|
+
if (exists(readmePath) && exists(pkgPath)) {
|
|
96
|
+
const readmeText = read(readmePath);
|
|
97
|
+
const pkg = JSON.parse(read(pkgPath));
|
|
98
|
+
const m = readmeText.match(/badge\/version-(\d+\.\d+\.\d+)/);
|
|
99
|
+
if (pkg.version && m && m[1] !== pkg.version) {
|
|
100
|
+
warnings++;
|
|
101
|
+
warn(`README.md version badge mismatch: README=${m[1]} vs package.json=${pkg.version} (run: leerness readme sync)`);
|
|
102
|
+
_finding('readme_version_mismatch', 'warn', 'README.md version badge mismatch', { readme: m[1], pkg: pkg.version });
|
|
103
|
+
if (fix) {
|
|
104
|
+
const updated = readmeText.replace(/badge\/version-[\d.]+-(green|blue|red)/g, `badge/version-${pkg.version}-green`);
|
|
105
|
+
writeUtf8(readmePath, updated);
|
|
106
|
+
ok(' ↳ fixed: README.md version 배지 갱신');
|
|
107
|
+
fixed++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// 1.18.4 (GPT-5.5 평가 #7, UR-0006): 배지뿐 아니라 관리블록의 "Last synced by Leerness vX" 도 검사.
|
|
111
|
+
// 동적 npm 배지를 쓰는 README 는 badge 패턴이 없어 위 검사가 못 잡던 자기정합 갭(README=1.18.0 vs pkg=1.18.3) 차단.
|
|
112
|
+
const sm = readmeText.match(/Last synced by Leerness v(\d+\.\d+\.\d+)/);
|
|
113
|
+
if (pkg.version && sm && sm[1] !== pkg.version) {
|
|
114
|
+
warnings++;
|
|
115
|
+
warn(`README.md managed-block version stale: README=${sm[1]} vs package.json=${pkg.version} (run: leerness readme sync)`);
|
|
116
|
+
_finding('readme_synced_version_stale', 'warn', 'README.md managed-block synced version stale', { readme: sm[1], pkg: pkg.version });
|
|
117
|
+
if (fix) {
|
|
118
|
+
const updated2 = read(readmePath).replace(/Last synced by Leerness v\d+\.\d+\.\d+/g, `Last synced by Leerness v${pkg.version}`);
|
|
119
|
+
writeUtf8(readmePath, updated2);
|
|
120
|
+
ok(' ↳ fixed: README.md 관리블록 synced 버전 갱신');
|
|
121
|
+
fixed++;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
// 1.9.62: package.json 있으면 npm audit --json 자동 호출 → CVE 보고 (opt-out: --no-npm-audit)
|
|
127
|
+
// 정책: leerness가 외부 호출하지만 사용자 컨텍스트에 이미 npm 설치되어 있음을 가정 (offline 시 자동 스킵)
|
|
128
|
+
if (exists(path.join(root, 'package.json')) && !has('--no-npm-audit') && process.env.LEERNESS_OFFLINE !== '1') {
|
|
129
|
+
try {
|
|
130
|
+
const r = cp.spawnSync('npm', ['audit', '--json'], {
|
|
131
|
+
cwd: root, encoding: 'utf8', shell: true, timeout: 30000
|
|
132
|
+
});
|
|
133
|
+
if (r.stdout) {
|
|
134
|
+
let j = null;
|
|
135
|
+
try { j = JSON.parse(r.stdout); } catch {}
|
|
136
|
+
if (j && j.metadata && j.metadata.vulnerabilities) {
|
|
137
|
+
const v = j.metadata.vulnerabilities;
|
|
138
|
+
const total = (v.critical || 0) + (v.high || 0) + (v.moderate || 0) + (v.low || 0);
|
|
139
|
+
if (total > 0) {
|
|
140
|
+
warnings++;
|
|
141
|
+
warn(`npm CVE: ${total}건 (critical=${v.critical||0}, high=${v.high||0}, moderate=${v.moderate||0}, low=${v.low||0})`);
|
|
142
|
+
_finding('npm_cve', 'warn', `npm CVE: ${total}건`, { vulnerabilities: v });
|
|
143
|
+
log(` → 수정: npm audit fix · 상세: npm audit`);
|
|
144
|
+
if (v.critical || v.high) {
|
|
145
|
+
warnings++; // critical/high는 추가 가중
|
|
146
|
+
warn(` ⚠ critical/high CVE 즉시 대응 권장`);
|
|
147
|
+
_finding('npm_cve_critical', 'warn', 'critical/high CVE 즉시 대응 권장', { critical: v.critical, high: v.high });
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
ok('npm CVE: 0건');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
// 1.9.75: .gitignore 보안 검증 — .env / 시크릿 파일이 .gitignore에 포함되는지 (--no-gitignore-check로 끄기)
|
|
157
|
+
if (!has('--no-gitignore-check')) {
|
|
158
|
+
try {
|
|
159
|
+
const gi = path.join(root, '.gitignore');
|
|
160
|
+
const envPath = path.join(root, '.env');
|
|
161
|
+
if (exists(envPath)) {
|
|
162
|
+
// .env가 존재하면 .gitignore가 반드시 있어야 하고, .env가 포함되어야 함
|
|
163
|
+
const giText = exists(gi) ? read(gi) : '';
|
|
164
|
+
const giLines = giText.split('\n').map(l => l.trim());
|
|
165
|
+
// 필수 보안 패턴 (글로벌 룰 .gitignore 보안 체크리스트)
|
|
166
|
+
const SECRET_PATTERNS = ['.env', '.env.local', '.env.production', '.env.*.local', '*.pem', 'credentials.json'];
|
|
167
|
+
const missing = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
|
|
168
|
+
if (missing.length) {
|
|
169
|
+
warnings++;
|
|
170
|
+
warn(`.gitignore에 시크릿 패턴 ${missing.length}건 누락: ${missing.slice(0, 4).join(', ')}${missing.length > 4 ? ' …' : ''}`);
|
|
171
|
+
_finding('gitignore_missing_secrets', 'warn', '.gitignore에 시크릿 패턴 누락', { missing });
|
|
172
|
+
if (fix) {
|
|
173
|
+
// 자동 추가
|
|
174
|
+
let newGi = giText;
|
|
175
|
+
if (newGi && !newGi.endsWith('\n')) newGi += '\n';
|
|
176
|
+
newGi += `\n# 1.9.75 audit --fix: 시크릿 파일 보안 패턴 자동 추가 (사용자 글로벌 룰)\n`;
|
|
177
|
+
for (const p of missing) newGi += `${p}\n`;
|
|
178
|
+
writeUtf8(gi, newGi);
|
|
179
|
+
ok(` ↳ fixed: .gitignore에 ${missing.length}건 자동 추가 (시크릿 보안 1.9.75)`);
|
|
180
|
+
fixed++;
|
|
181
|
+
} else {
|
|
182
|
+
log(` → 자동 추가: leerness audit --fix`);
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
ok('.gitignore 시크릿 패턴 OK (1.9.75)');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
// 1.9.71: .env / .env.example 동기화 감사 (--no-env-check로 끄기)
|
|
191
|
+
if (!has('--no-env-check')) {
|
|
192
|
+
try {
|
|
193
|
+
const d = envDiff(root);
|
|
194
|
+
if (exists(d.envPath) && exists(d.examplePath)) {
|
|
195
|
+
if (d.inEnvOnly.length) {
|
|
196
|
+
warnings++;
|
|
197
|
+
warn(`.env에 있는 키 ${d.inEnvOnly.length}건이 .env.example에 누락: ${d.inEnvOnly.slice(0, 4).join(', ')}${d.inEnvOnly.length > 4 ? ' …' : ''}`);
|
|
198
|
+
_finding('env_keys_missing', 'warn', '.env 키가 .env.example에 누락', { keys: d.inEnvOnly });
|
|
199
|
+
if (fix) {
|
|
200
|
+
// 자동 동기화: 누락 키만 .env.example 끝에 append (값 비움)
|
|
201
|
+
let example = read(d.examplePath);
|
|
202
|
+
if (!example.endsWith('\n')) example += '\n';
|
|
203
|
+
example += `\n# 1.9.71 audit --fix: 누락 키 자동 추가 (값은 빈 문자열, 보안 정책)\n`;
|
|
204
|
+
for (const k of d.inEnvOnly) example += `${k}=\n`;
|
|
205
|
+
writeUtf8(d.examplePath, example);
|
|
206
|
+
ok(` ↳ fixed: .env.example에 ${d.inEnvOnly.length}건 자동 추가 (값은 빈 문자열, 1.9.71)`);
|
|
207
|
+
fixed++;
|
|
208
|
+
} else {
|
|
209
|
+
log(` → 자동 동기화: leerness env sync 또는 leerness audit --fix`);
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
ok('.env ↔ .env.example 동기화됨 (1.9.71)');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch {}
|
|
216
|
+
}
|
|
217
|
+
// 1.9.142: Feature Graph 무결성 검증 — orphan/cycle 자동 감지 (--no-feature-check로 끄기)
|
|
218
|
+
if (!has('--no-feature-check')) {
|
|
219
|
+
try {
|
|
220
|
+
const { nodes: fNodes } = _readFeatureGraph(root);
|
|
221
|
+
if (fNodes.length > 0) {
|
|
222
|
+
const ids = new Set(fNodes.map(n => n.id));
|
|
223
|
+
// (1) orphan: 다른 노드가 참조하는데 정의가 없는 ID
|
|
224
|
+
const orphans = [];
|
|
225
|
+
for (const n of fNodes) {
|
|
226
|
+
for (const ref of [...(n.dependsOn || []), ...(n.affects || []), ...(n.coChangesWith || [])]) {
|
|
227
|
+
if (!ids.has(ref)) orphans.push({ from: n.id, missingRef: ref });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (orphans.length) {
|
|
231
|
+
warnings++;
|
|
232
|
+
warn(`Feature Graph: orphan 참조 ${orphans.length}건 — ${orphans.slice(0, 3).map(o => `${o.from}→${o.missingRef}`).join(', ')}${orphans.length > 3 ? ' …' : ''}`);
|
|
233
|
+
_finding('feature_graph_orphan', 'warn', 'Feature Graph 에 정의되지 않은 ID 참조', { count: orphans.length, orphans: orphans.slice(0, 10) });
|
|
234
|
+
log(` → 수정: leerness feature add 또는 link 제거`);
|
|
235
|
+
}
|
|
236
|
+
// (2) cycle: affects 그래프에서 순환 의존성 감지 (DFS)
|
|
237
|
+
const cycles = [];
|
|
238
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
239
|
+
const color = new Map();
|
|
240
|
+
for (const n of fNodes) color.set(n.id, WHITE);
|
|
241
|
+
const byId = new Map(fNodes.map(n => [n.id, n]));
|
|
242
|
+
const dfs = (nodeId, path) => {
|
|
243
|
+
color.set(nodeId, GRAY);
|
|
244
|
+
const node = byId.get(nodeId);
|
|
245
|
+
if (!node) { color.set(nodeId, BLACK); return; }
|
|
246
|
+
for (const next of [...(node.affects || []), ...(node.dependsOn || [])]) {
|
|
247
|
+
if (!byId.has(next)) continue;
|
|
248
|
+
const c = color.get(next);
|
|
249
|
+
if (c === GRAY) {
|
|
250
|
+
// 순환 발견 — path 에 next 까지 자르기
|
|
251
|
+
const idx = path.indexOf(next);
|
|
252
|
+
const cyc = idx >= 0 ? path.slice(idx).concat([next]) : [...path, next];
|
|
253
|
+
if (!cycles.some(existing => existing.join() === cyc.join())) cycles.push(cyc);
|
|
254
|
+
} else if (c === WHITE) {
|
|
255
|
+
dfs(next, [...path, next]);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
color.set(nodeId, BLACK);
|
|
259
|
+
};
|
|
260
|
+
for (const n of fNodes) if (color.get(n.id) === WHITE) dfs(n.id, [n.id]);
|
|
261
|
+
if (cycles.length) {
|
|
262
|
+
warnings++;
|
|
263
|
+
warn(`Feature Graph: 순환 의존 ${cycles.length}건 — ${cycles[0].join(' → ')}${cycles.length > 1 ? ` (외 ${cycles.length-1}건)` : ''}`);
|
|
264
|
+
_finding('feature_graph_cycle', 'warn', 'Feature Graph 에 순환 의존', { count: cycles.length, cycles: cycles.slice(0, 5) });
|
|
265
|
+
log(` → 수정: feature link 재구성 (affects/depends-on 방향 정리)`);
|
|
266
|
+
}
|
|
267
|
+
if (!orphans.length && !cycles.length) {
|
|
268
|
+
ok(`Feature Graph OK (${fNodes.length} 노드, orphan/cycle 없음, 1.9.142)`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch {}
|
|
272
|
+
}
|
|
273
|
+
// 1.9.247 (UR-0015 2단계): api-skill 참조 audit — API 관련 task 인데 .harness/api-skills/ 미참조 시 경고
|
|
274
|
+
// 사용자 명시 (UR-0015): "AI가 정리해둔 파일이 참조되는지 확인"
|
|
275
|
+
// 현재 in-progress task 의 request/nextAction 에 API 키워드 (URL, "API", "endpoint", "REST", "GraphQL", "OAuth", "webhook") 있는데
|
|
276
|
+
// _matchAPISkills() 결과가 0 이면 → 경고 + leerness api-skill add <url> 안내
|
|
277
|
+
try {
|
|
278
|
+
const rows = readProgressRows(root);
|
|
279
|
+
const ip = rows.find(r => r.status === 'in-progress');
|
|
280
|
+
if (ip) {
|
|
281
|
+
const taskText = (ip.request || '') + ' ' + (ip.nextAction || '') + ' ' + (ip.evidence || '');
|
|
282
|
+
const apiKeywords = /\bAPI\b|endpoint|REST|GraphQL|OAuth|webhook|https?:\/\/[^\s]+/i;
|
|
283
|
+
if (apiKeywords.test(taskText)) {
|
|
284
|
+
const matched = _matchAPISkills(root, taskText);
|
|
285
|
+
const allSkills = _listAPISkills(root);
|
|
286
|
+
if (matched.length === 0) {
|
|
287
|
+
warnings++;
|
|
288
|
+
warn(`API 관련 task 감지 (현재: "${(ip.request || '').slice(0, 60)}") — .harness/api-skills/ 매칭 0건 (저장 ${allSkills.length})`);
|
|
289
|
+
warn(` → leerness api-skill add <url> --direction "구현 방향" 으로 정리 권장 (1.9.245 UR-0015 / 1.9.247 audit)`);
|
|
290
|
+
_finding('api_skill_missing', 'warn', 'API 관련 task 인데 .harness/api-skills/ 매칭 없음', {
|
|
291
|
+
taskRequest: (ip.request || '').slice(0, 100),
|
|
292
|
+
apiSkillsTotal: allSkills.length,
|
|
293
|
+
matched: 0,
|
|
294
|
+
hint: 'leerness api-skill add <url> --direction "..."'
|
|
295
|
+
});
|
|
296
|
+
} else {
|
|
297
|
+
ok(`API skill 매칭 OK (현재 task → ${matched.length}건 매칭 in .harness/api-skills/, 1.9.247 UR-0015 2단계)`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} catch {}
|
|
302
|
+
// 1.9.63: --strict — warnings ≥ threshold 시 failures로 승격 (CI 친화)
|
|
303
|
+
if (has('--strict')) {
|
|
304
|
+
const threshold = parseInt(arg('--threshold', '1'), 10);
|
|
305
|
+
if (warnings >= threshold) {
|
|
306
|
+
failures++;
|
|
307
|
+
warn(`--strict 활성: warnings ${warnings} ≥ threshold ${threshold} → failures 승격`);
|
|
308
|
+
_finding('strict_promoted', 'fail', `warnings ${warnings} ≥ threshold ${threshold} → failures 승격`, { warnings, threshold });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
log(`Audit summary: warnings=${warnings} failures=${failures}${fix ? ` fixed=${fixed}` : ''}${has('--strict') ? ` strict-threshold=${arg('--threshold', '1')}` : ''}`);
|
|
312
|
+
} finally {
|
|
313
|
+
// 1.9.102: stdout 복원
|
|
314
|
+
if (jsonMode) process.stdout.write = _origWrite;
|
|
315
|
+
}
|
|
316
|
+
// 1.9.102: JSON 모드 — 구조화 출력
|
|
317
|
+
if (jsonMode) {
|
|
318
|
+
const payload = {
|
|
319
|
+
version: VERSION,
|
|
320
|
+
root,
|
|
321
|
+
warnings,
|
|
322
|
+
failures,
|
|
323
|
+
fixed,
|
|
324
|
+
healthy: failures === 0,
|
|
325
|
+
fixApplied: fix,
|
|
326
|
+
strict: has('--strict'),
|
|
327
|
+
strictThreshold: has('--strict') ? parseInt(arg('--threshold', '1'), 10) : null,
|
|
328
|
+
summary: `warnings=${warnings} failures=${failures}${fix ? ` fixed=${fixed}` : ''}`,
|
|
329
|
+
findings,
|
|
330
|
+
};
|
|
331
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
332
|
+
}
|
|
333
|
+
if (failures) process.exitCode = 1;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
module.exports = { audit };
|