leerness 1.36.113 → 1.36.116
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/README.md +4 -4
- package/bin/leerness.js +222 -49
- package/lib/audit.js +535 -421
- package/lib/catalogs.js +505 -505
- package/lib/pure-utils.js +220 -2
- package/package.json +1 -1
- package/scripts/e2e.js +729 -1
package/lib/audit.js
CHANGED
|
@@ -1,421 +1,535 @@
|
|
|
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
|
-
const { findCorruptedStateJson } = require('./state-integrity'); // 1.36.1 (클린룸 리뷰 FN): 상태 JSON 무결성
|
|
10
|
-
const { _briefUnfilled, _planGoalUnfilled } = require('./pure-utils'); // 1.36.19: 전략 앵커
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// 1.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (typeof
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
_finding('
|
|
115
|
-
// 1.
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
ok('
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
+
const { findCorruptedStateJson } = require('./state-integrity'); // 1.36.1 (클린룸 리뷰 FN): 상태 JSON 무결성
|
|
10
|
+
const { _briefUnfilled, _planGoalUnfilled, _detectOrphanGuards } = require('./pure-utils'); // 1.36.19: 전략 앵커 · 1.36.116: 고아 가드
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
// 1.36.116 — "존재하지만 아무도 실행하지 않는 가드" 를 audit 이 본다.
|
|
14
|
+
// 근거는 실제 프로젝트다: 어떤 저장소의 CI 파일이 자기 사고를 이렇게 적어 놨다 —
|
|
15
|
+
// "가드가 여럿 있는데 CI 에 하나도 연결돼 있지 않았다 … 사람이 돌려야만 유효한 검사는 결국 안 돌아간다."
|
|
16
|
+
// leerness 는 위생을 감사하면서 정작 이 클래스를 보지 않았다.
|
|
17
|
+
// 수집은 여기서(I/O), 판정은 pure-utils 의 순수 함수에서 한다.
|
|
18
|
+
function _collectGuardInputs(root) {
|
|
19
|
+
// 1.36.116 (검수 #9/#10): 예전엔 모든 읽기 실패를 조용히 삼켜 **'깨끗함'과 '못 읽었음'이 같은 결과**였다.
|
|
20
|
+
// 워크플로 하나만 못 읽어도 거기서 부르는 정상 가드가 통째로 고아로 뒤집힌다 — 그건 오탐인데 사용자는 알 길이 없다.
|
|
21
|
+
// 그래서 실패를 세어 돌려주고, 판정기가 근거에 적는다. 크기 상한도 함께 둔다(일반 예외와 달리 OOM 은 회복이 안 된다).
|
|
22
|
+
const MAX_FILE = 2 * 1024 * 1024, MAX_TOTAL = 16 * 1024 * 1024;
|
|
23
|
+
let readErrors = 0, skippedLarge = 0, totalBytes = 0;
|
|
24
|
+
const rd = (p) => {
|
|
25
|
+
try {
|
|
26
|
+
const st = fs.statSync(p);
|
|
27
|
+
if (!st.isFile()) return null;
|
|
28
|
+
if (st.size > MAX_FILE) { skippedLarge++; return null; }
|
|
29
|
+
if (totalBytes + st.size > MAX_TOTAL) { skippedLarge++; return null; }
|
|
30
|
+
const t = fs.readFileSync(p, 'utf8');
|
|
31
|
+
totalBytes += st.size;
|
|
32
|
+
return t;
|
|
33
|
+
} catch (e) { if (e && e.code !== 'ENOENT') readErrors++; return null; }
|
|
34
|
+
};
|
|
35
|
+
let packageScripts = {};
|
|
36
|
+
let ownEntries = new Set();
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(rd(path.join(root, 'package.json')) || '{}');
|
|
39
|
+
packageScripts = pkg.scripts || {};
|
|
40
|
+
// 1.36.116 (실측): 패키지 **자신의 진입점**(bin/main)은 러너가 아니라 제품이다. leerness 에서 실제로 물렸다 —
|
|
41
|
+
// `bin/leerness.js` 가 러너로 수집되고, 그 안의 *남의 프로젝트용* 생성 코드에 `test:smoke` 문자열이 있어서
|
|
42
|
+
// 자기 자신의 진짜 고아가 '누군가 부른다' 로 뒤집혔다(자기참조 4회차).
|
|
43
|
+
const ent = [];
|
|
44
|
+
if (typeof pkg.bin === 'string') ent.push(pkg.bin);
|
|
45
|
+
else if (pkg.bin && typeof pkg.bin === 'object') ent.push(...Object.values(pkg.bin));
|
|
46
|
+
if (typeof pkg.main === 'string') ent.push(pkg.main);
|
|
47
|
+
ownEntries = new Set(ent.filter(x => typeof x === 'string').map(x => x.replace(/^\.\//, '').replace(/\\/g, '/')));
|
|
48
|
+
} catch {}
|
|
49
|
+
// `viaScript` = 이 파일을 러너로 담게 만든 npm 스크립트 이름(CI 설정 파일이면 null).
|
|
50
|
+
// 1.36.116 (검수 P2): 아무도 안 부르는 wrapper 가 참조하는 파일도 러너로 담기고, 그 텍스트에 잎 가드 이름이
|
|
51
|
+
// 있으면 잎이 루트가 됐다 — BFS 재작성이 막으려던 바로 그 우회로가 한 단계 밖에 그대로 있었다.
|
|
52
|
+
// 판정기가 "그 wrapper 가 실제로 도달 가능한가" 를 따질 수 있도록 출처를 같이 준다.
|
|
53
|
+
const runners = [];
|
|
54
|
+
const push = (file, viaScript) => { const t = rd(path.join(root, file)); if (t != null && !runners.some(r => r.file === file)) runners.push({ file, text: t, viaScript: viaScript || null }); };
|
|
55
|
+
try { for (const f of fs.readdirSync(path.join(root, '.github', 'workflows'))) if (/\.ya?ml$/.test(f)) push(`.github/workflows/${f}`); } catch {}
|
|
56
|
+
// 1.36.116 (검수 #5): 정확히 `Makefile` 과 훅 2개만 읽었다 — `GNUmakefile`·소문자 `makefile`·`commit-msg` 를
|
|
57
|
+
// 놓치면 그 러너가 부르는 정상 가드가 전부 '아무도 안 부름' 으로 뒤집힌다(미수집은 곧 오탐이다).
|
|
58
|
+
for (const f of ['.gitlab-ci.yml', '.gitlab-ci.yaml', 'azure-pipelines.yml', 'Jenkinsfile',
|
|
59
|
+
'Makefile', 'makefile', 'GNUmakefile', 'Taskfile.yml', 'justfile',
|
|
60
|
+
'.husky/pre-commit', '.husky/pre-push', '.husky/commit-msg', '.husky/post-merge']) push(f);
|
|
61
|
+
try { for (const f of fs.readdirSync(path.join(root, '.circleci'))) if (/\.ya?ml$/.test(f)) push(`.circleci/${f}`); } catch {}
|
|
62
|
+
// 러너가 부르는 스크립트 파일도 러너다 — 동적 열거자가 대개 거기 있다(그걸 못 보면 오탐이 폭발한다).
|
|
63
|
+
// 1.36.116 (검수 #5): 경로에 `scripts/` 가 있어야만 수집했다 — `tools/run-ci.mjs`·`ci/run.mjs`·`bin/run-checks.js`
|
|
64
|
+
// 같은 흔한 배치를 놓치고, 그 안의 동적 열거자를 못 보면 다시 오탐이 폭발한다. 디렉터리 이름을 넓힌다.
|
|
65
|
+
const _FILE_RE = /[\w./-]*(?:scripts|tools|ci|bin|tasks)\/[\w.-]+\.(?:m?js|cjs|ts|sh)/g;
|
|
66
|
+
// CI 설정 파일이 부르는 것은 출처 null(무조건 유효한 러너), npm 스크립트가 부르는 것은 그 스크립트가 출처다.
|
|
67
|
+
for (const r of runners.slice()) for (const m of String(r.text).matchAll(_FILE_RE)) {
|
|
68
|
+
const rel = m[0].replace(/^\.\//, '');
|
|
69
|
+
if (!ownEntries.has(rel)) push(rel, null);
|
|
70
|
+
}
|
|
71
|
+
for (const [name, body] of Object.entries(packageScripts)) for (const m of String(body || '').matchAll(_FILE_RE)) {
|
|
72
|
+
const rel = m[0].replace(/^\.\//, '');
|
|
73
|
+
if (!ownEntries.has(rel)) push(rel, name); // 자기 진입점은 러너가 아니다 (위 주석)
|
|
74
|
+
}
|
|
75
|
+
// scripts/ 아래 가드처럼 보이는 파일(2단계까지)
|
|
76
|
+
const scriptFiles = [];
|
|
77
|
+
const walk = (d, rel2, depth) => {
|
|
78
|
+
if (depth > 2) return;
|
|
79
|
+
let es = []; try { es = fs.readdirSync(d, { withFileTypes: true }); } catch (e) { if (e && e.code !== 'ENOENT') readErrors++; return; }
|
|
80
|
+
for (const e of es) {
|
|
81
|
+
// 1.36.116 (검수 P2): 상한을 재귀 **진입 시점**에만 봐서 flat 디렉터리는 무제한이었고, 잘라낸 사실도
|
|
82
|
+
// 어디에도 안 남았다("깨끗함"과 "일부만 봄"이 또 같은 출력). 항목마다 재고, 자르면 불완전으로 센다.
|
|
83
|
+
if (scriptFiles.length >= 500) { skippedLarge++; return; }
|
|
84
|
+
const p = path.join(d, e.name), r = (rel2 ? rel2 + '/' : '') + e.name;
|
|
85
|
+
if (e.isDirectory()) { if (!/node_modules|\.git/.test(e.name)) walk(p, r, depth + 1); }
|
|
86
|
+
else if (/\.(m?js|cjs|ts|sh)$/.test(e.name) && /(check|verify|validate|guard|test|gate|audit|scan|lint|e2e|smoke)/i.test(e.name)) scriptFiles.push(r);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
walk(path.join(root, 'scripts'), 'scripts', 0);
|
|
90
|
+
return { packageScripts, runners, scriptFiles, readErrors, skippedLarge };
|
|
91
|
+
}
|
|
92
|
+
// 1.36.108 (T-0097): 락은 harness 가 주입한다(clarify/referee/routing 과 같은 관례) — lib 에 복제본을 두지 않는다.
|
|
93
|
+
// 주입이 없으면 그냥 실행한다(하위호환). 주입 누락은 e2e 락 가드가 실행으로 잡는다.
|
|
94
|
+
function _auditLock(deps, f, fn) { return (deps && typeof deps._withLock === 'function') ? deps._withLock(f, fn) : fn(); }
|
|
95
|
+
|
|
96
|
+
function audit(root, opts = {}, deps = {}) {
|
|
97
|
+
const { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills, _collectSecretFindings } = deps;
|
|
98
|
+
root = absRoot(root);
|
|
99
|
+
let warnings = 0, failures = 0;
|
|
100
|
+
// 1.9.35 개선 #5: --fix 옵션 — 자동 수정 가능한 항목 적용
|
|
101
|
+
const fix = has('--fix');
|
|
102
|
+
let fixed = 0;
|
|
103
|
+
// 1.9.102: --json 모드 — stdout 억제 후 구조화 출력
|
|
104
|
+
const jsonMode = !!opts.json || has('--json');
|
|
105
|
+
const findings = [];
|
|
106
|
+
const _finding = (kind, severity, message, details = {}) => findings.push({ kind, severity, message, ...details });
|
|
107
|
+
const _origWrite = process.stdout.write.bind(process.stdout);
|
|
108
|
+
if (jsonMode) process.stdout.write = () => true;
|
|
109
|
+
try {
|
|
110
|
+
// 외부리뷰 CV-3/UR-0078: 미초기화/존재하지 않는 경로를 healthy 로 오판하던 것 수정 — 필수 마커 부재 시 failure 승격(verify 와 일관).
|
|
111
|
+
if (!exists(root) || !exists(path.join(root, '.harness')) || !exists(path.join(root, 'AGENTS.md'))) {
|
|
112
|
+
failures++;
|
|
113
|
+
fail(`미초기화 또는 존재하지 않는 경로: ${root} (.harness/AGENTS.md 없음 — leerness init 필요)`);
|
|
114
|
+
_finding('not_initialized', 'fail', 'uninitialized or missing path (.harness or AGENTS.md absent)', { root });
|
|
115
|
+
// 1.27.1 (13번째 외부리뷰 #2): 미초기화 시 후속 체크(design/reuse 등)를 없는 하네스에 대해 보고하던 모순 출력 차단 — 요약/JSON 으로 직행 후 종료(exit code/JSON 페이로드는 종전과 동일).
|
|
116
|
+
log(`Audit summary: warnings=${warnings} failures=${failures}`);
|
|
117
|
+
if (jsonMode) { process.stdout.write = _origWrite; process.stdout.write(JSON.stringify({ version: VERSION, root, warnings, failures, fixed, healthy: false, fixApplied: fix, strict: has('--strict'), strictThreshold: has('--strict') ? parseInt(arg('--threshold', '1'), 10) : null, summary: `warnings=${warnings} failures=${failures}`, findings }, null, 2) + '\n'); }
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// 1.36.79 (도그푸딩 P1-E, 실 프로젝트): .harness/AGENTS.md 존재만 보고 통과시켜 **56개 중 44개가 사라진 설치**를
|
|
122
|
+
// healthy:true·failures:0 으로 보고했다(같은 디렉토리에서 integrity check 는 ok:false — 두 판정이 모순).
|
|
123
|
+
// integrity 가 이미 계산하는 관리 문서 부재/손상 신호를 audit 에도 반영해 "가장 먼저 부르는 명령"이 진실을 말하게 한다.
|
|
124
|
+
try {
|
|
125
|
+
if (typeof deps._integrityFindings === 'function') {
|
|
126
|
+
const _integ = deps._integrityFindings(root) || [];
|
|
127
|
+
// (검수 #5) integrity 와 동일 kind 집합 — truncated 도 포함해 두 명령의 판정이 갈리지 않게(단, 차단 등급은 missing 기준)
|
|
128
|
+
const _missing = _integ.filter(f => f && ['missing', 'h1_lost', 'truncated', 'unreadable'].includes(f.kind));
|
|
129
|
+
if (_missing.length) {
|
|
130
|
+
// 코어 문서 다수 부재 = 설치 손상(차단). 소수는 경고.
|
|
131
|
+
const _sev = _missing.filter(f => f.kind === 'missing').length >= 3 ? 'fail' : 'warn';
|
|
132
|
+
if (_sev === 'fail') { failures++; fail(`관리 문서 ${_missing.length}건 부재/손상 — 설치 불완전 (leerness init 또는 integrity check --repair)`); }
|
|
133
|
+
else { warnings++; warn(`관리 문서 ${_missing.length}건 부재/손상 (leerness integrity check --repair)`); }
|
|
134
|
+
_finding('managed_docs_incomplete', _sev, 'managed policy docs missing/damaged (install incomplete)', { count: _missing.length, files: _missing.slice(0, 12).map(f => f.file || f.name), kinds: [...new Set(_missing.map(f => f.kind))] });
|
|
135
|
+
} else {
|
|
136
|
+
ok('관리 문서 무결성 OK (integrity)');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch {}
|
|
140
|
+
// 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태파일 JSON 무결성 — 깨진 JSON 을 그레이스풀 폴백(빈 상태)이 "healthy" 로 감추던 false-negative 차단.
|
|
141
|
+
// 손상 파일을 warning + corrupted_state_json finding 으로 표면화(--strict 시 failure 로 승격). 비-크래시(헬퍼가 파서 예외를 흡수).
|
|
142
|
+
try {
|
|
143
|
+
const corrupted = findCorruptedStateJson(root);
|
|
144
|
+
if (corrupted.length) {
|
|
145
|
+
warnings++;
|
|
146
|
+
warn(`상태파일 JSON 손상 ${corrupted.length}건: ${corrupted.map(c => c.file).join(', ')} (수동 복구 또는 leerness init 필요)`);
|
|
147
|
+
corrupted.slice(0, 6).forEach(c => log(` ${c.file}: ${c.error}`));
|
|
148
|
+
_finding('corrupted_state_json', 'warn', '.harness 상태파일 JSON 파싱 실패 (손상)', { count: corrupted.length, files: corrupted.map(c => c.file), sample: corrupted.slice(0, 10) });
|
|
149
|
+
} else {
|
|
150
|
+
ok('상태파일 JSON 무결성 OK (.harness/*.json)');
|
|
151
|
+
}
|
|
152
|
+
} catch {}
|
|
153
|
+
const designCands = ['designguide.md','design-guide.md','docs/designguide.md','docs/design-guide.md','.harness/designguide.md'];
|
|
154
|
+
const dups = designCands.filter(f => exists(path.join(root,f)));
|
|
155
|
+
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 }); }
|
|
156
|
+
else ok('no duplicate design guide candidates');
|
|
157
|
+
// 1.9.1 P4: <!-- leerness:na --> 마커가 있는 파일은 placeholder 경고 스킵.
|
|
158
|
+
const naMarker = '<!-- leerness:na';
|
|
159
|
+
const ds = exists(path.join(root,'.harness/design-system.md')) ? read(path.join(root,'.harness/design-system.md')) : '';
|
|
160
|
+
if (ds.includes(naMarker)) ok('design-system.md marked NA (skipped)');
|
|
161
|
+
else if (!/\| color\.primary \|/.test(ds) || /\(실제 값으로 업데이트\)|\(update with real value\)/i.test(ds)) { warnings++; warn('design-system.md tokens not customized'); _finding('design_system_default', 'warn', 'design-system.md tokens not customized'); } // 1.36.61: en placeholder 병기
|
|
162
|
+
else ok('design-system tokens populated');
|
|
163
|
+
const reuse = exists(path.join(root,'.harness/reuse-map.md')) ? read(path.join(root,'.harness/reuse-map.md')) : '';
|
|
164
|
+
const reuseLines = reuse.split('\n').filter(l => l.startsWith('|') && !/Capability|---/.test(l)).length;
|
|
165
|
+
if (reuse.includes(naMarker)) ok('reuse-map.md marked NA (skipped)');
|
|
166
|
+
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'); }
|
|
167
|
+
else ok(`reuse-map.md has ${reuseLines} entries`);
|
|
168
|
+
// 1.36.19 (실사용 7 프로젝트 dogfood): 전략 앵커 미작성 — 동적 상태는 유지되나 project-brief Purpose(5/7)·plan Goal(7/7)이
|
|
169
|
+
// 템플릿 그대로라 인계받는 AI 가 "프로젝트가 무엇인지/범위"를 못 받음(관찰된 근본원인). naMarker 로 의도적 스킵 가능.
|
|
170
|
+
const briefFile = path.join(root, '.harness/project-brief.md');
|
|
171
|
+
const briefTxt = exists(briefFile) ? read(briefFile) : '';
|
|
172
|
+
if (briefTxt.includes(naMarker)) ok('project-brief.md marked NA (skipped)');
|
|
173
|
+
else if (_briefUnfilled(briefTxt)) { warnings++; warn('project-brief.md Purpose 미작성(템플릿 그대로) — 인계받는 AI가 프로젝트 목적/맥락을 못 받습니다 (project-brief.md 채우기)'); _finding('project_brief_unfilled', 'warn', 'project-brief.md Purpose still placeholder/empty — AI handoffs lack project context'); }
|
|
174
|
+
else ok('project-brief.md Purpose populated');
|
|
175
|
+
const planText = exists(planPath(root)) ? read(planPath(root)) : '';
|
|
176
|
+
if (planText && !planText.includes(naMarker) && _planGoalUnfilled(planText)) { warnings++; warn('plan.md Goal 미작성(템플릿 그대로) — 프로젝트 목표/범위가 비어 인계 AI가 방향을 못 잡습니다 (plan.md Goal/Scope 채우기)'); _finding('plan_goal_unfilled', 'warn', 'plan.md Goal still placeholder/empty'); }
|
|
177
|
+
|
|
178
|
+
// 1.36.116: 고아 가드 — 검사가 존재하는데 아무 러너도 부르지 않는다.
|
|
179
|
+
// **권고로만 낸다**(warn, 차단 아님). 판정은 휴리스틱이고, 오차단은 사용자가 이 경고 자체를 끄게 만든다.
|
|
180
|
+
// 근거(어떤 열거자가 무엇을 태우는지)를 함께 출력한다 — 목록만 주면 믿을 수 없어 그냥 넘긴다.
|
|
181
|
+
try {
|
|
182
|
+
const g = _detectOrphanGuards(_collectGuardInputs(root));
|
|
183
|
+
if (g.orphanScripts.length || g.orphanFiles.length) {
|
|
184
|
+
warnings++;
|
|
185
|
+
const parts = [];
|
|
186
|
+
if (g.orphanScripts.length) parts.push(`npm 스크립트 ${g.orphanScripts.length}종`);
|
|
187
|
+
if (g.orphanFiles.length) parts.push(`파일 ${g.orphanFiles.length}개`);
|
|
188
|
+
warn(`아무도 실행하지 않는 검사: ${parts.join(' · ')} — 사람이 기억해야만 도는 가드는 결국 안 돈다`);
|
|
189
|
+
log(` 근거: ${g.reason}`);
|
|
190
|
+
if (g.orphanScripts.length) log(` npm: ${g.orphanScripts.slice(0, 8).join(', ')}${g.orphanScripts.length > 8 ? ` 외 ${g.orphanScripts.length - 8}종` : ''}`);
|
|
191
|
+
if (g.orphanFiles.length) log(` 파일: ${g.orphanFiles.slice(0, 6).join(', ')}${g.orphanFiles.length > 6 ? ` 외 ${g.orphanFiles.length - 6}개` : ''}`);
|
|
192
|
+
log(` → CI 워크플로나 집계 스크립트(예: test:ci)에 배선하거나, 더 이상 안 쓰면 지우세요`);
|
|
193
|
+
_finding('orphan_guard', 'warn', 'checks exist but no runner invokes them', {
|
|
194
|
+
orphanScripts: g.orphanScripts, orphanFiles: g.orphanFiles, scanIncomplete: g.scanIncomplete,
|
|
195
|
+
guardCount: g.guardCount, dynamicRunners: g.dynamicRunners, enumPrefixes: g.enumPrefixes, reason: g.reason,
|
|
196
|
+
});
|
|
197
|
+
} else if (g.scanIncomplete) {
|
|
198
|
+
// 1.36.116 (검수 P2): 고아가 0이면 발견을 아예 안 내보내서, 사용자 입장에서 **'깨끗함'과 '일부를 못 읽음'이
|
|
199
|
+
// 같은 출력**이었다. 스캔이 불완전했다는 사실 자체가 보고 대상이다(그래야 0을 믿을지 정할 수 있다).
|
|
200
|
+
warnings++;
|
|
201
|
+
warn(`가드 배선 점검이 불완전합니다 — 0건이라는 결과를 그대로 믿지 마세요`);
|
|
202
|
+
log(` 근거: ${g.reason}`);
|
|
203
|
+
_finding('orphan_guard_scan_incomplete', 'warn', 'guard wiring scan could not read everything', {
|
|
204
|
+
scanIncomplete: g.scanIncomplete, guardCount: g.guardCount, reason: g.reason,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
} catch {}
|
|
208
|
+
const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4,})\./gm)).map(m => m[1]);
|
|
209
|
+
const rows = readProgressRows(root);
|
|
210
|
+
// 1.9.6 수정: 한 row에 여러 plan:M-XXXX 링크가 있어도 모두 인식 (matchAll로 전부 추출)
|
|
211
|
+
const linkedMs = new Set(
|
|
212
|
+
rows.flatMap(r => Array.from(String(r.evidence || '').matchAll(/M-\d{4,}/g), m => m[0]))
|
|
213
|
+
);
|
|
214
|
+
const missingFromProgress = milestoneIds.filter(m => !linkedMs.has(m));
|
|
215
|
+
if (missingFromProgress.length) {
|
|
216
|
+
warnings++;
|
|
217
|
+
warn(`milestones without progress entry: ${missingFromProgress.join(', ')}`);
|
|
218
|
+
_finding('milestone_unlinked', 'warn', 'milestones without progress entry', { milestones: missingFromProgress });
|
|
219
|
+
log(` → 자동 매칭 제안: leerness task relink`);
|
|
220
|
+
log(` → 자동 적용: leerness task relink --apply`);
|
|
221
|
+
}
|
|
222
|
+
else if (milestoneIds.length) ok('all milestones linked in progress-tracker');
|
|
223
|
+
const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
|
|
224
|
+
if (handoff.includes('Last generated: (자동)') || handoff.includes('Last generated: (auto)')) { // 1.36.61: en 센티널 병기
|
|
225
|
+
warnings++; warn('session-handoff.md never auto-generated (run: leerness session close .)');
|
|
226
|
+
_finding('handoff_not_generated', 'warn', 'session-handoff.md never auto-generated');
|
|
227
|
+
// 1.9.35 #5: --fix → session-handoff.md 자동 생성 마커 갱신
|
|
228
|
+
if (fix) {
|
|
229
|
+
// 1.36.108 (T-0097): 락 안에서 **다시 읽고** 스탬프한다. 위에서 읽은 handoff 로 쓰면
|
|
230
|
+
// 그 사이 session close 가 새로 생성한 내용을 낡은 스냅샷으로 덮는다(둘 다 이 파일을 쓴다).
|
|
231
|
+
_auditLock(deps, handoffPath(root), () => {
|
|
232
|
+
const fresh = exists(handoffPath(root)) ? read(handoffPath(root)) : handoff;
|
|
233
|
+
const stamped = fresh.replace(/Last generated: \((자동|auto)\)/, `Last generated: ${today()} (leerness audit --fix)`); // 1.36.61: 양 언어 --fix
|
|
234
|
+
writeUtf8(handoffPath(root), stamped);
|
|
235
|
+
});
|
|
236
|
+
ok(' ↳ fixed: session-handoff.md timestamp 갱신');
|
|
237
|
+
fixed++;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else if (handoff.includes('Last generated:')) ok('session-handoff.md auto-generated previously');
|
|
241
|
+
const cur = exists(currentStatePath(root)) ? read(currentStatePath(root)) : '';
|
|
242
|
+
const updMatch = cur.match(/Updated: (\d{4}-\d{2}-\d{2})/);
|
|
243
|
+
if (updMatch) {
|
|
244
|
+
// 1.36.50 (NaN 날짜 클래스 스윕): "Updated: 2026-99-99" 같은 무효 날짜는 NaN 비교로 무언 통과했다 —
|
|
245
|
+
// 신선함 증명 불가 = stale 취급(여긴 --fix 가 유효 날짜로 재스탬프하므로 자가 복구 경로).
|
|
246
|
+
let dDays = (Date.now() - new Date(updMatch[1]).getTime()) / 86400000;
|
|
247
|
+
if (!Number.isFinite(dDays)) dDays = Infinity;
|
|
248
|
+
if (dDays > 7) {
|
|
249
|
+
const dShow = Number.isFinite(dDays) ? Math.round(dDays) : '무효 날짜';
|
|
250
|
+
warnings++; warn(`current-state.md stale (${dShow} days)`);
|
|
251
|
+
_finding('current_state_stale', 'warn', 'current-state.md stale', { days: Number.isFinite(dDays) ? Math.round(dDays) : -1 });
|
|
252
|
+
// 1.9.35 #5: --fix → current-state.md Updated 라인 갱신
|
|
253
|
+
if (fix) {
|
|
254
|
+
// 1.36.108 (T-0097): 락 안 재읽기 — 사용자 본문이 그 사이 바뀌었으면 낡은 사본으로 덮지 않는다.
|
|
255
|
+
_auditLock(deps, currentStatePath(root), () => {
|
|
256
|
+
const fresh = exists(currentStatePath(root)) ? read(currentStatePath(root)) : cur;
|
|
257
|
+
writeUtf8(currentStatePath(root), fresh.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`));
|
|
258
|
+
});
|
|
259
|
+
ok(' ↳ fixed: current-state.md Updated 갱신');
|
|
260
|
+
fixed++;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else ok('current-state.md fresh');
|
|
264
|
+
}
|
|
265
|
+
// 1.9.40: README의 version 배지 ↔ package.json#version mismatch 감지 (도구 만드는 자가 자기 도구 stale하는 dogfooding gap 차단)
|
|
266
|
+
try {
|
|
267
|
+
const readmePath = path.join(root, 'README.md');
|
|
268
|
+
const pkgPath = path.join(root, 'package.json');
|
|
269
|
+
if (exists(readmePath) && exists(pkgPath)) {
|
|
270
|
+
const readmeText = read(readmePath);
|
|
271
|
+
const pkg = JSON.parse(read(pkgPath));
|
|
272
|
+
const m = readmeText.match(/badge\/version-(\d+\.\d+\.\d+)/);
|
|
273
|
+
if (pkg.version && m && m[1] !== pkg.version) {
|
|
274
|
+
warnings++;
|
|
275
|
+
warn(`README.md version badge mismatch: README=${m[1]} vs package.json=${pkg.version} (run: leerness readme sync)`);
|
|
276
|
+
_finding('readme_version_mismatch', 'warn', 'README.md version badge mismatch', { readme: m[1], pkg: pkg.version });
|
|
277
|
+
if (fix) {
|
|
278
|
+
const updated = readmeText.replace(/badge\/version-[\d.]+-(green|blue|red)/g, `badge/version-${pkg.version}-green`);
|
|
279
|
+
writeUtf8(readmePath, updated);
|
|
280
|
+
ok(' ↳ fixed: README.md version 배지 갱신');
|
|
281
|
+
fixed++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// 1.18.4 (GPT-5.5 평가 #7, UR-0006): 배지뿐 아니라 관리블록의 "Last synced by Leerness vX" 도 검사.
|
|
285
|
+
// 1.35.17 (audit 헌트 FP): 이 라인은 readme sync 가 `Last synced by Leerness v${VERSION}`(leerness 도구 버전)으로 쓴다. 기존엔 이를 pkg.version(프로젝트 버전)과 비교해 유저 프로젝트마다(도구≠프로젝트 버전) 항상 오탐 + --fix 가 pkg.version 을 써 다음 readme sync 가 되돌리는 영구 충돌. → 현재 실행 중 leerness VERSION 과 비교(= "오래된 leerness 로 sync 됨 → 재sync" 를 정확히 감지, leerness 자기 repo 는 VERSION==pkg.version 이라 무변화). --fix 도 VERSION 으로 기록(readme sync 와 정합).
|
|
286
|
+
const sm = readmeText.match(/Last synced by Leerness v(\d+\.\d+\.\d+)/);
|
|
287
|
+
if (VERSION && sm && sm[1] !== VERSION) {
|
|
288
|
+
warnings++;
|
|
289
|
+
warn(`README.md managed-block synced by older Leerness: README=v${sm[1]} vs current=v${VERSION} (run: leerness readme sync)`);
|
|
290
|
+
_finding('readme_synced_version_stale', 'warn', 'README.md managed-block synced by older Leerness version', { readme: sm[1], leerness: VERSION });
|
|
291
|
+
if (fix) {
|
|
292
|
+
const updated2 = read(readmePath).replace(/Last synced by Leerness v\d+\.\d+\.\d+/g, `Last synced by Leerness v${VERSION}`);
|
|
293
|
+
writeUtf8(readmePath, updated2);
|
|
294
|
+
ok(' ↳ fixed: README.md 관리블록 synced 버전 갱신');
|
|
295
|
+
fixed++;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch {}
|
|
300
|
+
// 1.9.62: package.json 있으면 npm audit --json 자동 호출 → CVE 보고 (opt-out: --no-npm-audit)
|
|
301
|
+
// 정책: leerness가 외부 호출하지만 사용자 컨텍스트에 이미 npm 설치되어 있음을 가정 (offline 시 자동 스킵)
|
|
302
|
+
if (exists(path.join(root, 'package.json')) && !has('--no-npm-audit') && process.env.LEERNESS_OFFLINE !== '1') {
|
|
303
|
+
try {
|
|
304
|
+
const r = cp.spawnSync('npm', ['audit', '--json'], {
|
|
305
|
+
cwd: root, encoding: 'utf8', shell: true, timeout: 30000
|
|
306
|
+
});
|
|
307
|
+
if (r.stdout) {
|
|
308
|
+
let j = null;
|
|
309
|
+
try { j = JSON.parse(r.stdout); } catch {}
|
|
310
|
+
if (j && j.metadata && j.metadata.vulnerabilities) {
|
|
311
|
+
const v = j.metadata.vulnerabilities;
|
|
312
|
+
const total = (v.critical || 0) + (v.high || 0) + (v.moderate || 0) + (v.low || 0);
|
|
313
|
+
if (total > 0) {
|
|
314
|
+
warnings++;
|
|
315
|
+
warn(`npm CVE: ${total}건 (critical=${v.critical||0}, high=${v.high||0}, moderate=${v.moderate||0}, low=${v.low||0})`);
|
|
316
|
+
_finding('npm_cve', 'warn', `npm CVE: ${total}건`, { vulnerabilities: v });
|
|
317
|
+
log(` → 수정: npm audit fix · 상세: npm audit`);
|
|
318
|
+
if (v.critical || v.high) {
|
|
319
|
+
warnings++; // critical/high는 추가 가중
|
|
320
|
+
warn(` ⚠ critical/high CVE 즉시 대응 권장`);
|
|
321
|
+
_finding('npm_cve_critical', 'warn', 'critical/high CVE 즉시 대응 권장', { critical: v.critical, high: v.high });
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
ok('npm CVE: 0건');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} catch {}
|
|
329
|
+
}
|
|
330
|
+
// 1.9.75: .gitignore 보안 검증 — .env / 시크릿 파일이 .gitignore에 포함되는지 (--no-gitignore-check로 끄기)
|
|
331
|
+
if (!has('--no-gitignore-check')) {
|
|
332
|
+
try {
|
|
333
|
+
const gi = path.join(root, '.gitignore');
|
|
334
|
+
const envPath = path.join(root, '.env');
|
|
335
|
+
if (exists(envPath)) {
|
|
336
|
+
// .env가 존재하면 .gitignore가 반드시 있어야 하고, .env가 포함되어야 함
|
|
337
|
+
const giText = exists(gi) ? read(gi) : '';
|
|
338
|
+
const giLines = giText.split('\n').map(l => l.trim());
|
|
339
|
+
// 필수 보안 패턴 (글로벌 룰 .gitignore 보안 체크리스트)
|
|
340
|
+
const SECRET_PATTERNS = ['.env', '.env.local', '.env.production', '.env.*.local', '*.pem', 'credentials.json'];
|
|
341
|
+
// 1.35.17 (audit 헌트 FP): 정확-일치만 보던 것을 광역 glob 커버리지 인식으로 완화 — 흔한 `.env*`/`.env.*`(git 이 .env 패밀리 전체를 실제로 ignore) 를 쓰면 필수 패턴이 '누락' 오탐 나던 것 차단. trailing-star prefix 매칭(`.env*`→`.env` 접두 커버)은 git 동작과 일치라 신규 FN 0(더 관대해질 뿐).
|
|
342
|
+
const _covered = (p) => giLines.some(l => { const s = l.replace(/^\//, ''); return s === p || (s.endsWith('*') && p.startsWith(s.slice(0, -1))); });
|
|
343
|
+
const missing = SECRET_PATTERNS.filter(p => !_covered(p));
|
|
344
|
+
if (missing.length) {
|
|
345
|
+
warnings++;
|
|
346
|
+
warn(`.gitignore에 시크릿 패턴 ${missing.length}건 누락: ${missing.slice(0, 4).join(', ')}${missing.length > 4 ? ' …' : ''}`);
|
|
347
|
+
_finding('gitignore_missing_secrets', 'warn', '.gitignore에 시크릿 패턴 누락', { missing });
|
|
348
|
+
if (fix) {
|
|
349
|
+
// 자동 추가
|
|
350
|
+
let newGi = giText;
|
|
351
|
+
if (newGi && !newGi.endsWith('\n')) newGi += '\n';
|
|
352
|
+
newGi += `\n# 1.9.75 audit --fix: 시크릿 파일 보안 패턴 자동 추가 (사용자 글로벌 룰)\n`;
|
|
353
|
+
for (const p of missing) newGi += `${p}\n`;
|
|
354
|
+
writeUtf8(gi, newGi);
|
|
355
|
+
ok(` ↳ fixed: .gitignore에 ${missing.length}건 자동 추가 (시크릿 보안 1.9.75)`);
|
|
356
|
+
fixed++;
|
|
357
|
+
} else {
|
|
358
|
+
log(` → 자동 추가: leerness audit --fix`);
|
|
359
|
+
}
|
|
360
|
+
} else {
|
|
361
|
+
ok('.gitignore 시크릿 패턴 OK (1.9.75)');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} catch {}
|
|
365
|
+
}
|
|
366
|
+
// 1.30.1 (14th 외부리뷰 F1): 커밋된 시크릿(_collectSecretFindings.committed)을 failure 로 승격 — scan secrets 와 일관.
|
|
367
|
+
// 기존엔 .gitignore 패턴/.env 동기화만 검사해 소스에 노출된 실 시크릿(AWS/GitHub 등)을 통과시키고 healthy:true 를 반환하던 정직성 갭
|
|
368
|
+
// (audit 기반 CI 게이트가 노출 시크릿을 통과). gitignored 보관 시크릿은 _collectSecretFindings 가 committed 에서 제외(FP 0). 끄기: --no-secret-scan.
|
|
369
|
+
if (!has('--no-secret-scan') && typeof _collectSecretFindings === 'function') {
|
|
370
|
+
try {
|
|
371
|
+
// 1.36.104 (P-0014): 인정된 픽스처는 실패로 세지 않는다 — headline/health/session-close 와 같은 술어.
|
|
372
|
+
const _sec = _collectSecretFindings(root);
|
|
373
|
+
const committed = _sec.unacknowledged || _sec.committed;
|
|
374
|
+
if (committed && committed.length) {
|
|
375
|
+
failures++;
|
|
376
|
+
fail(`커밋된 시크릿 ${committed.length}건 발견 (소스 노출) — leerness scan secrets 로 상세 확인`);
|
|
377
|
+
committed.slice(0, 4).forEach(f => log(` ${f.file}:${f.line} ${f.name}`));
|
|
378
|
+
_finding('committed_secret', 'fail', '커밋된 시크릿 발견 (소스 노출)', { count: committed.length, sample: committed.slice(0, 10).map(f => ({ file: f.file, line: f.line, name: f.name })) });
|
|
379
|
+
} else {
|
|
380
|
+
ok('커밋된 시크릿 없음 (소스 스캔, 1.30.1)');
|
|
381
|
+
}
|
|
382
|
+
} catch {}
|
|
383
|
+
}
|
|
384
|
+
// 1.9.71: .env / .env.example 동기화 감사 (--no-env-check로 끄기)
|
|
385
|
+
if (!has('--no-env-check')) {
|
|
386
|
+
try {
|
|
387
|
+
const d = envDiff(root);
|
|
388
|
+
if (exists(d.envPath) && exists(d.examplePath)) {
|
|
389
|
+
if (d.inEnvOnly.length) {
|
|
390
|
+
warnings++;
|
|
391
|
+
warn(`.env에 있는 키 ${d.inEnvOnly.length}건이 .env.example에 누락: ${d.inEnvOnly.slice(0, 4).join(', ')}${d.inEnvOnly.length > 4 ? ' …' : ''}`);
|
|
392
|
+
_finding('env_keys_missing', 'warn', '.env 키가 .env.example에 누락', { keys: d.inEnvOnly });
|
|
393
|
+
if (fix) {
|
|
394
|
+
// 자동 동기화: 누락 키만 .env.example 끝에 append (값 비움)
|
|
395
|
+
let example = read(d.examplePath);
|
|
396
|
+
if (!example.endsWith('\n')) example += '\n';
|
|
397
|
+
example += `\n# 1.9.71 audit --fix: 누락 키 자동 추가 (값은 빈 문자열, 보안 정책)\n`;
|
|
398
|
+
for (const k of d.inEnvOnly) example += `${k}=\n`;
|
|
399
|
+
writeUtf8(d.examplePath, example);
|
|
400
|
+
ok(` ↳ fixed: .env.example에 ${d.inEnvOnly.length}건 자동 추가 (값은 빈 문자열, 1.9.71)`);
|
|
401
|
+
fixed++;
|
|
402
|
+
} else {
|
|
403
|
+
log(` → 자동 동기화: leerness env sync 또는 leerness audit --fix`);
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
ok('.env ↔ .env.example 동기화됨 (1.9.71)');
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
} catch {}
|
|
410
|
+
}
|
|
411
|
+
// 1.9.142: Feature Graph 무결성 검증 — orphan/cycle 자동 감지 (--no-feature-check로 끄기)
|
|
412
|
+
if (!has('--no-feature-check')) {
|
|
413
|
+
try {
|
|
414
|
+
const { nodes: fNodes } = _readFeatureGraph(root);
|
|
415
|
+
if (fNodes.length > 0) {
|
|
416
|
+
const ids = new Set(fNodes.map(n => n.id));
|
|
417
|
+
// (1) orphan: 다른 노드가 참조하는데 정의가 없는 ID
|
|
418
|
+
const orphans = [];
|
|
419
|
+
for (const n of fNodes) {
|
|
420
|
+
for (const ref of [...(n.dependsOn || []), ...(n.affects || []), ...(n.coChangesWith || [])]) {
|
|
421
|
+
if (!ids.has(ref)) orphans.push({ from: n.id, missingRef: ref });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (orphans.length) {
|
|
425
|
+
warnings++;
|
|
426
|
+
warn(`Feature Graph: orphan 참조 ${orphans.length}건 — ${orphans.slice(0, 3).map(o => `${o.from}→${o.missingRef}`).join(', ')}${orphans.length > 3 ? ' …' : ''}`);
|
|
427
|
+
_finding('feature_graph_orphan', 'warn', 'Feature Graph 에 정의되지 않은 ID 참조', { count: orphans.length, orphans: orphans.slice(0, 10) });
|
|
428
|
+
log(` → 수정: leerness feature add 또는 link 제거`);
|
|
429
|
+
}
|
|
430
|
+
// (2) cycle: affects 그래프에서 순환 의존성 감지 (DFS)
|
|
431
|
+
const cycles = [];
|
|
432
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
433
|
+
const color = new Map();
|
|
434
|
+
for (const n of fNodes) color.set(n.id, WHITE);
|
|
435
|
+
const byId = new Map(fNodes.map(n => [n.id, n]));
|
|
436
|
+
const dfs = (nodeId, path) => {
|
|
437
|
+
color.set(nodeId, GRAY);
|
|
438
|
+
const node = byId.get(nodeId);
|
|
439
|
+
if (!node) { color.set(nodeId, BLACK); return; }
|
|
440
|
+
for (const next of [...(node.affects || []), ...(node.dependsOn || [])]) {
|
|
441
|
+
if (!byId.has(next)) continue;
|
|
442
|
+
const c = color.get(next);
|
|
443
|
+
if (c === GRAY) {
|
|
444
|
+
// 순환 발견 — path 에 next 까지 자르기
|
|
445
|
+
const idx = path.indexOf(next);
|
|
446
|
+
const cyc = idx >= 0 ? path.slice(idx).concat([next]) : [...path, next];
|
|
447
|
+
if (!cycles.some(existing => existing.join() === cyc.join())) cycles.push(cyc);
|
|
448
|
+
} else if (c === WHITE) {
|
|
449
|
+
dfs(next, [...path, next]);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
color.set(nodeId, BLACK);
|
|
453
|
+
};
|
|
454
|
+
for (const n of fNodes) if (color.get(n.id) === WHITE) dfs(n.id, [n.id]);
|
|
455
|
+
if (cycles.length) {
|
|
456
|
+
warnings++;
|
|
457
|
+
warn(`Feature Graph: 순환 의존 ${cycles.length}건 — ${cycles[0].join(' → ')}${cycles.length > 1 ? ` (외 ${cycles.length-1}건)` : ''}`);
|
|
458
|
+
_finding('feature_graph_cycle', 'warn', 'Feature Graph 에 순환 의존', { count: cycles.length, cycles: cycles.slice(0, 5) });
|
|
459
|
+
log(` → 수정: feature link 재구성 (affects/depends-on 방향 정리)`);
|
|
460
|
+
}
|
|
461
|
+
if (!orphans.length && !cycles.length) {
|
|
462
|
+
ok(`Feature Graph OK (${fNodes.length} 노드, orphan/cycle 없음, 1.9.142)`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} catch {}
|
|
466
|
+
}
|
|
467
|
+
// 1.9.247 (UR-0015 2단계): api-skill 참조 audit — API 관련 task 인데 .harness/api-skills/ 미참조 시 경고
|
|
468
|
+
// 사용자 명시 (UR-0015): "AI가 정리해둔 파일이 참조되는지 확인"
|
|
469
|
+
// 현재 in-progress task 의 request/nextAction 에 API 키워드 (URL, "API", "endpoint", "REST", "GraphQL", "OAuth", "webhook") 있는데
|
|
470
|
+
// _matchAPISkills() 결과가 0 이면 → 경고 + leerness api-skill add <url> 안내
|
|
471
|
+
try {
|
|
472
|
+
const rows = readProgressRows(root);
|
|
473
|
+
const ip = rows.find(r => r.status === 'in-progress');
|
|
474
|
+
if (ip) {
|
|
475
|
+
const taskText = (ip.request || '') + ' ' + (ip.nextAction || '') + ' ' + (ip.evidence || '');
|
|
476
|
+
// 1.35.17 (audit 헌트 FP, codex): API/REST 는 대소문자-민감(약어)로 분리 — 기존 /…|REST|…/i 는 영어 단어 "rest"("clean up the rest of …")를 매칭해 api_skill 오탐. 서술형 토큰(endpoint/graphql/oauth/webhook/url)만 대소문자-무관 유지.
|
|
477
|
+
const apiKeywords = /endpoint|GraphQL|OAuth|webhook|https?:\/\/[^\s]+/i;
|
|
478
|
+
const apiAcronym = /\bAPI\b|\bREST\b/; // 대문자 약어만
|
|
479
|
+
if (apiKeywords.test(taskText) || apiAcronym.test(taskText)) {
|
|
480
|
+
const matched = _matchAPISkills(root, taskText);
|
|
481
|
+
const allSkills = _listAPISkills(root);
|
|
482
|
+
if (matched.length === 0) {
|
|
483
|
+
warnings++;
|
|
484
|
+
warn(`API 관련 task 감지 (현재: "${(ip.request || '').slice(0, 60)}") — .harness/api-skills/ 매칭 0건 (저장 ${allSkills.length})`);
|
|
485
|
+
warn(` → leerness api-skill add <url> --direction "구현 방향" 으로 정리 권장 (1.9.245 UR-0015 / 1.9.247 audit)`);
|
|
486
|
+
_finding('api_skill_missing', 'warn', 'API 관련 task 인데 .harness/api-skills/ 매칭 없음', {
|
|
487
|
+
taskRequest: (ip.request || '').slice(0, 100),
|
|
488
|
+
apiSkillsTotal: allSkills.length,
|
|
489
|
+
matched: 0,
|
|
490
|
+
hint: 'leerness api-skill add <url> --direction "..."'
|
|
491
|
+
});
|
|
492
|
+
} else {
|
|
493
|
+
ok(`API skill 매칭 OK (현재 task → ${matched.length}건 매칭 in .harness/api-skills/, 1.9.247 UR-0015 2단계)`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
} catch {}
|
|
498
|
+
// 1.9.63: --strict — warnings ≥ threshold 시 failures로 승격 (CI 친화)
|
|
499
|
+
if (has('--strict')) {
|
|
500
|
+
const threshold = parseInt(arg('--threshold', '1'), 10);
|
|
501
|
+
// 1.35.17 (audit 헌트 FP, codex): warnings>0 가드 추가 — `--threshold 0` (또는 음수)이면 `warnings>=0` 이 항상 참이라 경고 0인 clean 프로젝트도 실패시키던 footgun. 실제 경고가 있을 때만 승격.
|
|
502
|
+
if (warnings > 0 && warnings >= threshold) {
|
|
503
|
+
failures++;
|
|
504
|
+
warn(`--strict 활성: warnings ${warnings} ≥ threshold ${threshold} → failures 승격`);
|
|
505
|
+
_finding('strict_promoted', 'fail', `warnings ${warnings} ≥ threshold ${threshold} → failures 승격`, { warnings, threshold });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
log(`Audit summary: warnings=${warnings} failures=${failures}${fix ? ` fixed=${fixed}` : ''}${has('--strict') ? ` strict-threshold=${arg('--threshold', '1')}` : ''}`);
|
|
509
|
+
} finally {
|
|
510
|
+
// 1.9.102: stdout 복원
|
|
511
|
+
if (jsonMode) process.stdout.write = _origWrite;
|
|
512
|
+
}
|
|
513
|
+
// 1.9.102: JSON 모드 — 구조화 출력
|
|
514
|
+
if (jsonMode) {
|
|
515
|
+
const payload = {
|
|
516
|
+
version: VERSION,
|
|
517
|
+
root,
|
|
518
|
+
warnings,
|
|
519
|
+
failures,
|
|
520
|
+
fixed,
|
|
521
|
+
healthy: failures === 0,
|
|
522
|
+
fixApplied: fix,
|
|
523
|
+
strict: has('--strict'),
|
|
524
|
+
strictThreshold: has('--strict') ? parseInt(arg('--threshold', '1'), 10) : null,
|
|
525
|
+
summary: `warnings=${warnings} failures=${failures}${fix ? ` fixed=${fixed}` : ''}`,
|
|
526
|
+
findings,
|
|
527
|
+
};
|
|
528
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
529
|
+
}
|
|
530
|
+
if (failures) process.exitCode = 1;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// 1.36.116 (검수 #17): `_collectGuardInputs` 가 export 되지 않아 e2e 가 **수집→판정 배선**을 볼 수 없었다 —
|
|
534
|
+
// 수집기가 러너를 하나도 안 돌려줘도 기존 단언은 전부 통과했다(가드가 자기가 밟지 않는 경로를 못 지킨다).
|
|
535
|
+
module.exports = { audit, _collectGuardInputs };
|