deel-local-cli 1.17.6 → 1.17.8
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.ko.md +38 -3
- package/README.md +40 -3
- package/package.json +1 -1
- package/src/agent/models.js +3 -1
- package/src/agent/route.js +77 -8
- package/src/backend/adapter.js +4 -1
- package/src/backend/http.js +78 -10
- package/src/backend/retry.js +49 -3
- package/src/tools/verify.js +20 -21
- package/src/tools//355/231/225/354/235/270/353/262/225.js +127 -0
package/README.ko.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
[](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
|
|
22
22
|
[](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
|
|
23
|
-
[](docs/ko/develop.md)
|
|
24
24
|
|
|
25
25
|
[](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
|
|
26
26
|
[](package.json)
|
|
@@ -53,6 +53,39 @@
|
|
|
53
53
|
|
|
54
54
|
---
|
|
55
55
|
|
|
56
|
+
### 그래서 뭐가 다른데 — 나란히 세워 재 봤습니다
|
|
57
|
+
|
|
58
|
+
같은 프로젝트, 같은 일, 같은 모델(AWS Bedrock · Claude Opus 5). 양쪽 다 공동
|
|
59
|
+
편집기의 **데이터 손실 결함을 찾아 고치게** 했습니다.
|
|
60
|
+
|
|
61
|
+
| | Claude CLI | deel |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| 관측 비용 | $7.90 | **$6.57** · -16.8% |
|
|
64
|
+
| 소요 시간 | 38분 00초 | **36분 59초** · -2.7% |
|
|
65
|
+
| 종합 상대지수 | 97.9% | 98.5% |
|
|
66
|
+
| 곁들인 것 | `claude-token-saver` (별도 npm) | **없음 — 자체 기능만** |
|
|
67
|
+
|
|
68
|
+
한 줄로 줄이면: **품질은 사실상 동급인데, 절감 패키지를 안 붙이고도 더 쌌습니다.**
|
|
69
|
+
|
|
70
|
+
<picture>
|
|
71
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jysvai/deel-local-cli/main/docs/assets/benchmark-dark.svg">
|
|
72
|
+
<img alt="Claude CLI 와 deel 벤치마크 — 항목별 상대지수" src="https://raw.githubusercontent.com/jysvai/deel-local-cli/main/docs/assets/benchmark-light.svg" width="900">
|
|
73
|
+
</picture>
|
|
74
|
+
|
|
75
|
+
두 실행은 같은 버그를 두 번 찾은 게 아닙니다. **서로 다른 실패 경로**를 하나씩
|
|
76
|
+
없앴습니다 — 한쪽은 한글 조합 중 기준값이 오염되는 길, 한쪽은 `await` 사이에
|
|
77
|
+
입력이 덮이는 경쟁 상태(TOCTOU). 둘 다 고치기 전에 재현했고, 최소 수정 뒤
|
|
78
|
+
회귀 검증까지 갔습니다.
|
|
79
|
+
|
|
80
|
+
> **이 자료로 말할 수 없는 것도 적어 둡니다.** 표본이 2회입니다. Claude 쪽에는
|
|
81
|
+
> 외부 절감 패키지가 붙어 있어서 **엔진 자체의 효율 차이라고 단정할 수 없습니다.**
|
|
82
|
+
> 그리고 **전체 회귀 범위는 Claude 가 넓었습니다** — 우리가 진 항목이고, 그것이
|
|
83
|
+
> 1.17.7 에서 `Verify` 를 고치게 만들었습니다(확인 방법을 하나가 아니라 전부 찾습니다).
|
|
84
|
+
|
|
85
|
+
→ [전체 기록과 방법론](docs/ko/benchmark.md) · [원본 PDF](pdf/cli_benchmark_anonymized_v4.pdf)
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
56
89
|
## 목차
|
|
57
90
|
|
|
58
91
|
- [왜 만들었나](#왜-만들었나)
|
|
@@ -268,7 +301,7 @@ deel --offline
|
|
|
268
301
|
무엇이 어디로 갈 수 있는지는 켤 때 화면 맨 위에 늘 적혀 있습니다.
|
|
269
302
|
|
|
270
303
|
```
|
|
271
|
-
deel 1.17.
|
|
304
|
+
deel 1.17.8 ⌂ 이 안
|
|
272
305
|
보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
|
|
273
306
|
```
|
|
274
307
|
|
|
@@ -1185,7 +1218,7 @@ deel stats # 이 폴더에서 실제로 무엇을 했나 (.de
|
|
|
1185
1218
|
## 개발
|
|
1186
1219
|
|
|
1187
1220
|
```bash
|
|
1188
|
-
npm test 전체 검증 (8,
|
|
1221
|
+
npm test 전체 검증 (8,095항목 — 몇몇은 터미널에 따라 갈립니다)
|
|
1189
1222
|
npm run coverage 검사가 소스의 어디를 밟았는지
|
|
1190
1223
|
npm run verify 반입·통신 검증만
|
|
1191
1224
|
npm run bench 편집 성공률 측정
|
|
@@ -1254,6 +1287,8 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
|
|
|
1254
1287
|
|
|
1255
1288
|
| 판 | 무엇이 바뀌었나 |
|
|
1256
1289
|
|---|---|
|
|
1290
|
+
| [1.17.8](docs/ko/releases/1.17.md#1178) | 긴 지시문을 통째로 붙여 넣으면 시킨 것과 반대로 갔습니다 — 그리고 망이 한 번 끊기면 턴이 사라졌습니다 |
|
|
1291
|
+
| [1.17.7](docs/ko/releases/1.17.md#1177) | 나란히 세워 재 봤고, 진 항목을 고쳤습니다 |
|
|
1257
1292
|
| [1.17.6](docs/ko/releases/1.17.md#1176) | 살아 있다는 신호만 오고 내용이 안 오면, 여태 영원히 기다렸습니다 |
|
|
1258
1293
|
| [1.17.5](docs/ko/releases/1.17.md#1175) | 한 턴 안에서 단계가 일을 따라갑니다 — 그리고 읽기 도구 넷이 여태 줄을 서 있었습니다 |
|
|
1259
1294
|
| [1.17.4](docs/ko/releases/1.17.md#1174) | 같은 요청을 한 번 더 보내던 자리들 — 그리고 배운 것이 스스로를 봉인하던 자리 |
|
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Vendor APIs connect too — **only when you say so**
|
|
|
20
20
|
|
|
21
21
|
[](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
|
|
22
22
|
[](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
|
|
23
|
-
[](docs/en/develop.md)
|
|
24
24
|
|
|
25
25
|
[](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
|
|
26
26
|
[](package.json)
|
|
@@ -60,6 +60,41 @@ summary are what deel actually printed.
|
|
|
60
60
|
|
|
61
61
|
---
|
|
62
62
|
|
|
63
|
+
### So what is actually different — we put them side by side
|
|
64
|
+
|
|
65
|
+
Same project, same job, same model (Claude Opus 5 via AWS Bedrock). Both were asked to
|
|
66
|
+
**find and fix a data-loss defect** in a collaborative editor.
|
|
67
|
+
|
|
68
|
+
| | Claude CLI | deel |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| Observed cost | $7.90 | **$6.57** · -16.8% |
|
|
71
|
+
| Wall time | 38m 00s | **36m 59s** · -2.7% |
|
|
72
|
+
| Composite index | 97.9% | 98.5% |
|
|
73
|
+
| Extras | `claude-token-saver` (separate npm) | **none — built-in only** |
|
|
74
|
+
|
|
75
|
+
In one line: **quality came out effectively equal, and it cost less without bolting on
|
|
76
|
+
a savings package.**
|
|
77
|
+
|
|
78
|
+
<picture>
|
|
79
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jysvai/deel-local-cli/main/docs/assets/benchmark-dark.svg">
|
|
80
|
+
<img alt="Claude CLI and deel benchmark — relative index by dimension" src="https://raw.githubusercontent.com/jysvai/deel-local-cli/main/docs/assets/benchmark-light.svg" width="900">
|
|
81
|
+
</picture>
|
|
82
|
+
|
|
83
|
+
The two runs did not find the same bug twice. Each removed **a different failure
|
|
84
|
+
path** — one where the base value is corrupted mid-IME-composition, one a TOCTOU race
|
|
85
|
+
where input is overwritten across an `await`. Both reproduced the defect before fixing
|
|
86
|
+
it, and carried the minimal fix through regression verification.
|
|
87
|
+
|
|
88
|
+
> **What this data cannot say, stated plainly.** The sample is two runs. The Claude
|
|
89
|
+
> side had an external savings package attached, so **the gap cannot be attributed to
|
|
90
|
+
> engine efficiency.** And **Claude covered more regression scope** — that is the row
|
|
91
|
+
> we lost, and it is what made 1.17.7 fix `Verify` to find every way a project checks
|
|
92
|
+
> itself, not just one.
|
|
93
|
+
|
|
94
|
+
→ [Full record and methodology](docs/en/benchmark.md) · [source PDF](pdf/cli_benchmark_anonymized_v4.pdf)
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
63
98
|
## 60 seconds
|
|
64
99
|
|
|
65
100
|
```bash
|
|
@@ -339,7 +374,7 @@ deel --offline
|
|
|
339
374
|
The destination is printed at the top of every session:
|
|
340
375
|
|
|
341
376
|
```
|
|
342
|
-
deel 1.17.
|
|
377
|
+
deel 1.17.8 ⌂ inside
|
|
343
378
|
Sends to this machine 127.0.0.1:11434 ← nowhere else
|
|
344
379
|
```
|
|
345
380
|
|
|
@@ -1258,7 +1293,7 @@ Stored in `~/.deel/config.json`. A `.deel/config.json` in the project folder tak
|
|
|
1258
1293
|
## Development
|
|
1259
1294
|
|
|
1260
1295
|
```bash
|
|
1261
|
-
npm test Full suite (8,
|
|
1296
|
+
npm test Full suite (8,095 checks; a few are TTY-dependent)
|
|
1262
1297
|
npm run coverage Which lines the tests actually execute
|
|
1263
1298
|
npm run verify Import + network checks only
|
|
1264
1299
|
npm run bench Edit success rate
|
|
@@ -1313,6 +1348,8 @@ so one run tells you everything.
|
|
|
1313
1348
|
|
|
1314
1349
|
| Version | What changed |
|
|
1315
1350
|
|---|---|
|
|
1351
|
+
| [1.17.8](docs/en/releases/1.17.md#1178) | A long brief got the opposite of what it asked for — and one network blip threw the turn away |
|
|
1352
|
+
| [1.17.7](docs/en/releases/1.17.md#1177) | We measured it side by side, and fixed the row we lost |
|
|
1316
1353
|
| [1.17.6](docs/en/releases/1.17.md#1176) | Only keep-alive and no content used to mean waiting forever |
|
|
1317
1354
|
| [1.17.5](docs/en/releases/1.17.md#1175) | The phase now follows the work inside a turn — and four read tools had been queueing up |
|
|
1318
1355
|
| [1.17.4](docs/en/releases/1.17.md#1174) | The places that sent the same request twice — and where what it learned sealed itself in |
|
package/package.json
CHANGED
package/src/agent/models.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { load, activeProfile, resolveKey } from '../config.js';
|
|
36
36
|
import { isLocalHost, isOffline } from '../safety/network.js';
|
|
37
|
-
import { 잠잠기본,
|
|
37
|
+
import { 잠잠기본, 무소식기본, 연결기본 } from '../backend/http.js';
|
|
38
38
|
import { 인증서설정 } from '../backend/clientcert.js';
|
|
39
39
|
|
|
40
40
|
// 컨텍스트를 못 알아냈을 때 쓰는 값. repl.js 와 같은 값을 봐야 한다.
|
|
@@ -61,6 +61,8 @@ export function 연결만들기(prof, { ctx = null, maxTokens = null } = {}) {
|
|
|
61
61
|
잠잠: prof.잠잠 ?? prof.streamIdleMs ?? 잠잠기본,
|
|
62
62
|
// 바이트는 오는데 내용이 안 올 때의 전체 상한 (backend/http.js 의 무소식기본).
|
|
63
63
|
무소식: prof.무소식 ?? prof.streamNoNewsMs ?? 무소식기본,
|
|
64
|
+
// 소켓을 **얻는 데까지** 줄 시간. 못 붙은 것은 다시 부른다 (backend/retry.js).
|
|
65
|
+
연결: prof.연결 ?? prof.connectMs ?? 연결기본,
|
|
64
66
|
인증서: 인증서설정(prof),
|
|
65
67
|
tools: prof.tools ?? false,
|
|
66
68
|
json: prof.json ?? false,
|
package/src/agent/route.js
CHANGED
|
@@ -177,8 +177,27 @@ const 계획말 = '계획|플랜|설계|구상|기획|정리|검토|조사|파
|
|
|
177
177
|
const 잇는말 = '해\\s*주고|해\\s*놓고|하고\\s*나서|하고서|한\\s*다음|한\\s*뒤|한\\s*후|하고|해서|잡고|짜고|세우고|낸\\s*뒤|낸\\s*다음';
|
|
178
178
|
const 실행말 = /만들어|만들자|만들어라|구현|작성|추가(해|하)|생성|써\s*(줘|주세요)|짜\s*(줘|주세요)|고쳐|세팅|설치|배포/;
|
|
179
179
|
const 계획먼저 = new RegExp(`(${계획말})\\s*(?:을|를)?\\s*(?:${잇는말})`);
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
/*
|
|
181
|
+
* 영어도 같이 본다 — "plan and then build it" 처럼 붙여 넣는 일이 있다.
|
|
182
|
+
*
|
|
183
|
+
* ── 쉼표를 이음말로 세던 것 ────────────────────────────────────────────
|
|
184
|
+
*
|
|
185
|
+
* 이음말 목록에 `,` 가 들어 있었다. 그런데 영어에서 쉼표로 이어진 동사들은
|
|
186
|
+
* **차례가 아니라 열거**다.
|
|
187
|
+
*
|
|
188
|
+
* "independently design, implement, test, verify, and complete …"
|
|
189
|
+
*
|
|
190
|
+
* 이건 「설계하고 나서 만들어라」 가 아니라 「이것들을 다 해라」 다. 그런데
|
|
191
|
+
* `design` 과 `implement` 가 예순 자 안에 있으니 겹침으로 잡혔고, 겹침이면
|
|
192
|
+
* **계획 모드 + 승인 창**으로 간다 — 계획만 내고 멈춘다.
|
|
193
|
+
*
|
|
194
|
+
* 실제로 겪었다. 「Do not stop after producing a plan」 이라고 **명시적으로
|
|
195
|
+
* 적어 둔** 지시문이 계획 모드로 갔다. 시킨 것과 정반대다.
|
|
196
|
+
*
|
|
197
|
+
* 그래서 쉼표를 뺀다. 차례를 뜻하는 말이 있을 때만 겹침이다. 한국어 쪽은
|
|
198
|
+
* 처음부터 잇는말 목록으로 이렇게 하고 있었고, 영어 쪽만 헐거웠다.
|
|
199
|
+
*/
|
|
200
|
+
const 영어겹침 = /\b(plan|design|outline|draft)\b[\s\S]{0,60}?\b(?:and\s+then|then|and|after\s+(?:that|approval)|before\s+you)\s+(?:then\s+)?\b(build|implement|create|write|make|code)\b/i;
|
|
182
201
|
|
|
183
202
|
/*
|
|
184
203
|
* ── 두 말이 **얼마나 붙어 있어야** 겹침인가 ─────────────────────────────
|
|
@@ -212,7 +231,7 @@ const 겹침거리 = 60;
|
|
|
212
231
|
*
|
|
213
232
|
* 이건 짐작이 아니라 사람이 직접 쓴 말이다. 쓴 말이 규칙을 이긴다.
|
|
214
233
|
*/
|
|
215
|
-
const 멈추지마 = /(?:중간에\s*)?(?:멈추지|묻지|물어보지|확인\s*요청하지|되묻지)\s*(?:는)?\s*(?:말|마)|끝까지\s*(?:수행|진행|해)|스스로\s*진행|알아서\s*(?:진행|해)|승인\s*없이|\bwithout\s+(?:asking|stopping|confirmation)\b|\
|
|
234
|
+
const 멈추지마 = /(?:중간에\s*)?(?:멈추지|묻지|물어보지|확인\s*요청하지|되묻지)\s*(?:는)?\s*(?:말|마)|끝까지\s*(?:수행|진행|해)|스스로\s*진행|알아서\s*(?:진행|해)|승인\s*없이|\bwithout\s+(?:asking|stopping|confirmation)\b|\bdo\s*n[o']?t\s+(?:stop|ask|pause|halt)\b|\bcontinue\s+autonomously\b|\bproceed\s+autonomously\b|\bwithout\s+(?:pausing|waiting|approval)\b/i;
|
|
216
235
|
|
|
217
236
|
/** 사람이 「묻지 말고 끝까지 하라」 고 적어 뒀나. */
|
|
218
237
|
export function 묻지말라했나(text) {
|
|
@@ -292,9 +311,36 @@ const 손대라는말 = new RegExp(`(${손대는동사})\\S{0,3}(줘|주세요|
|
|
|
292
311
|
// 파일을 못 고치는 모드. modes.js 에서 쓰기 도구를 안 주는 것들과 같아야 한다.
|
|
293
312
|
const 읽기만하는모드 = new Set(['architect', 'plan', 'ask', 'inspect']);
|
|
294
313
|
|
|
314
|
+
/*
|
|
315
|
+
* ── 영어로 시킨 말은 하나도 못 보고 있었다 ──────────────────────────────
|
|
316
|
+
*
|
|
317
|
+
* 위 규칙은 어미(`줘` · `해라` …)로 시킴꼴을 가린다. 영어에는 그 어미가 없다.
|
|
318
|
+
* 그래서 "Build a complete web application." 이 **손대라는 말이 아닌 것**으로
|
|
319
|
+
* 읽혔고, 읽기 전용 모드(설계·계획·점검·묻기)가 후보에서 안 빠졌다.
|
|
320
|
+
*
|
|
321
|
+
* 영어는 **문장 첫머리의 명령형**으로 가린다. 그래야 시키는 말과 설명하는
|
|
322
|
+
* 말이 갈린다 —
|
|
323
|
+
*
|
|
324
|
+
* "Build a work request app." → 손대라는 말이다
|
|
325
|
+
* "Explain how the build works." → 아니다 (build 가 첫머리가 아니다)
|
|
326
|
+
* "The build system is broken." → 아니다
|
|
327
|
+
*
|
|
328
|
+
* 목록에 없는 동사는 손대라는 말이 아니다. 한국어 쪽과 같은 규칙이다 —
|
|
329
|
+
* 넓히면 「설명해 줘」 가 고치라는 말이 되고, 그러면 읽기 전용 모드가 영영
|
|
330
|
+
* 안 골라진다.
|
|
331
|
+
*/
|
|
332
|
+
/*
|
|
333
|
+
* 정규식 리터럴로 적는다. 글자열을 이어 붙여 new RegExp 에 넣으면 홑따옴표
|
|
334
|
+
* 안에서 `\s` 의 역슬래시가 사라지고 `\b` 는 백스페이스 글자가 된다 —
|
|
335
|
+
* 검사에서 잡히기 전까지 조용히 아무것도 안 맞는다. 실제로 그렇게 났다.
|
|
336
|
+
*/
|
|
337
|
+
const 영어손대라 =
|
|
338
|
+
/(?:^|[.!?\n]\s*|^\s*\d+[.)]\s*|^\s*[-*]\s*)(?:please\s+)?(?:build|implement|create|write|make|add|fix|refactor|remove|delete|rename|migrate|update|modify|change|generate|scaffold|set\s+up|develop)\b/im;
|
|
339
|
+
|
|
295
340
|
/** 파일을 손대라는 말이 들었나. */
|
|
296
341
|
export function 손대라했나(text) {
|
|
297
|
-
|
|
342
|
+
const s = String(text ?? '');
|
|
343
|
+
return 손대라는말.test(s) || 영어손대라.test(s);
|
|
298
344
|
}
|
|
299
345
|
|
|
300
346
|
/**
|
|
@@ -343,13 +389,36 @@ export function route(text) {
|
|
|
343
389
|
for (const [m, n] of 순위) if (읽기만하는모드.has(m) && n > 0) 뺀것.push(m);
|
|
344
390
|
순위 = 순위.filter(([m]) => !읽기만하는모드.has(m));
|
|
345
391
|
}
|
|
392
|
+
/*
|
|
393
|
+
* ── 긴 명세에서는 한 번 걸린 낱말이 결정하면 안 된다 ──────────────────
|
|
394
|
+
*
|
|
395
|
+
* 점수표는 **한마디**를 보라고 만든 것이다. 「이거 왜 안 되지」 처럼 짧은
|
|
396
|
+
* 말에서는 낱말 하나가 곧 뜻이다.
|
|
397
|
+
*
|
|
398
|
+
* 그런데 5,000자짜리 명세를 붙여 넣으면 이야기가 달라진다. 그런 글에는
|
|
399
|
+
* 모든 모드의 낱말이 한 번씩은 나온다 — 설계도 나오고, 검사도 나오고,
|
|
400
|
+
* 보안도 나온다. 거기서 한 번 걸린 것은 **뜻이 아니라 소음**이다.
|
|
401
|
+
*
|
|
402
|
+
* 실제로 겪었다. 「빈 폴더에서 웹 앱을 처음부터 만들어라」 는 5,254자
|
|
403
|
+
* 지시문이 `fails` 한 낱말 때문에 디버그로 갔다. 그 글에 그 낱말은
|
|
404
|
+
* 「검사가 실패하면 원인을 조사하라」 는 조건절에 딱 한 번 있었다.
|
|
405
|
+
*
|
|
406
|
+
* 그래서 글이 길면 **더 뚜렷할 때만** 고른다. 못 고르면 종합에 그대로
|
|
407
|
+
* 두는데, 긴 명세에는 그게 오히려 맞는 답이다 — 종합은 걸음마다 단계를
|
|
408
|
+
* 따라가므로(agent/단계.js) 계획에서 시작해 만들기로 알아서 넘어간다.
|
|
409
|
+
*/
|
|
410
|
+
const 긴글 = s.length >= 1500;
|
|
411
|
+
const 더줘야 = 긴글 ? 2 : 0;
|
|
412
|
+
|
|
346
413
|
const [으뜸, 점수] = 순위[0] ?? [null, 0];
|
|
347
|
-
if (!으뜸 || 점수 < (문턱[으뜸] ?? 4)) {
|
|
414
|
+
if (!으뜸 || 점수 < (문턱[으뜸] ?? 4) + 더줘야) {
|
|
348
415
|
// 왜 안 바꿨는지는 화면에 그대로 뜬다. "설계로 갈 뻔했는데 고치라는 말이라
|
|
349
416
|
// 안 보냈다" 와 "그냥 모르겠다" 는 사람에게 아주 다른 말이다.
|
|
350
|
-
const 왜 =
|
|
351
|
-
?
|
|
352
|
-
:
|
|
417
|
+
const 왜 = 긴글 && 으뜸 && 점수 >= (문턱[으뜸] ?? 4)
|
|
418
|
+
? `글이 길어 ${으뜸} 신호 하나로는 안 정함 — 종합에서 단계를 따라갑니다`
|
|
419
|
+
: 뺀것.length
|
|
420
|
+
? `${뺀것.join('·')} 신호가 있었지만 고치라는 말이라 읽기 전용 모드로 안 보냄`
|
|
421
|
+
: '무슨 일인지 뚜렷하지 않음';
|
|
353
422
|
return { mode: null, score: 점수, why: 왜, 겹침: false, 점수들, 뺀것 };
|
|
354
423
|
}
|
|
355
424
|
|
package/src/backend/adapter.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import {
|
|
6
|
-
req, headersFor, serverMessage, Aborted, 잠잠기본, 초로,
|
|
6
|
+
req, headersFor, serverMessage, Aborted, 잠잠기본, 연결기본, 초로,
|
|
7
7
|
무소식기본, 무소식알림기본, 소식없음오류,
|
|
8
8
|
} from './http.js';
|
|
9
9
|
import { 할당량기억, 미리기다릴까, 마지막할당량, 할당량자리 } from './quota.js';
|
|
@@ -1086,6 +1086,7 @@ export async function chat(conn, opts) {
|
|
|
1086
1086
|
headers: await 머리말짓기(conn, opts),
|
|
1087
1087
|
body,
|
|
1088
1088
|
timeout: opts.timeout ?? 300000,
|
|
1089
|
+
연결: conn.연결 ?? 연결기본,
|
|
1089
1090
|
signal: opts.signal ?? null,
|
|
1090
1091
|
});
|
|
1091
1092
|
// 서버가 남았다고 말해 준 할당량을 적어 둔다 (backend/quota.js).
|
|
@@ -1192,6 +1193,8 @@ export async function* chatStream(conn, opts) {
|
|
|
1192
1193
|
* 모델의 답이 5분에서 잘리고, 멎어 버린 게이트웨이는 5분을 꽉 채운다.
|
|
1193
1194
|
*/
|
|
1194
1195
|
timeout: opts.timeout ?? 300000,
|
|
1196
|
+
// 붙는 데까지만 재는 시계. 못 붙은 것은 다시 부른다 (backend/retry.js).
|
|
1197
|
+
연결: conn.연결 ?? 연결기본,
|
|
1195
1198
|
잠잠: conn.잠잠 ?? 잠잠기본,
|
|
1196
1199
|
stream: true,
|
|
1197
1200
|
signal: opts.signal ?? null,
|
package/src/backend/http.js
CHANGED
|
@@ -79,11 +79,11 @@ export class Aborted extends Error {
|
|
|
79
79
|
* 부르는 쪽이 사람 말로 된 한 줄을 그대로 보여 주면 되게.
|
|
80
80
|
* 자물쇠(NetBlocked)와 사용자 중단(Aborted)만 던진다. 둘은 통신 실패가 아니다.
|
|
81
81
|
*/
|
|
82
|
-
export async function req(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null, 잠잠 = 0 } = {}) {
|
|
82
|
+
export async function req(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null, 잠잠 = 0, 연결 = 연결기본 } = {}) {
|
|
83
83
|
const started = Date.now();
|
|
84
84
|
try {
|
|
85
85
|
const r = await 원시요청(url, {
|
|
86
|
-
method, headers, timeout, stream, signal, 잠잠,
|
|
86
|
+
method, headers, timeout, stream, signal, 잠잠, 연결,
|
|
87
87
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
88
88
|
});
|
|
89
89
|
// 프록시가 407 로 막은 것은 통신 실패도 서버 답도 아니다. 사람 말 한 줄로 준다.
|
|
@@ -117,7 +117,7 @@ const 되돌림상태 = new Set([301, 302, 303, 307, 308]);
|
|
|
117
117
|
* 아니면 { ok, status, headers, bytes, text, json, ms }
|
|
118
118
|
* 프록시가 407 로 막으면 { ok:false, status:407, error } (몸은 없다)
|
|
119
119
|
*/
|
|
120
|
-
export async function 원시요청(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null, 되돌림 = null, 최대홉 = 5, 잠잠 = 0 } = {}) {
|
|
120
|
+
export async function 원시요청(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null, 되돌림 = null, 최대홉 = 5, 잠잠 = 0, 연결 = 연결기본 } = {}) {
|
|
121
121
|
const started = Date.now();
|
|
122
122
|
let 지금 = String(url);
|
|
123
123
|
let 방법 = method;
|
|
@@ -135,7 +135,7 @@ export async function 원시요청(url, { method = 'GET', headers = {}, body, ti
|
|
|
135
135
|
*/
|
|
136
136
|
const 인증서 = 인증서찾기(지금);
|
|
137
137
|
const r = (프록시 || 인증서)
|
|
138
|
-
? await 노드로(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 프록시, 인증서,
|
|
138
|
+
? await 노드로(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 프록시, 인증서, 잠잠, 연결 })
|
|
139
139
|
: await 직접(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 잠잠 });
|
|
140
140
|
|
|
141
141
|
const loc = 되돌림상태.has(r.status) ? r.headers?.get?.('location') : null;
|
|
@@ -276,6 +276,36 @@ export const 무소식알림기본 = 90000;
|
|
|
276
276
|
*/
|
|
277
277
|
export const 무소식기본 = 600000;
|
|
278
278
|
|
|
279
|
+
/**
|
|
280
|
+
* 소켓을 **얻는 데까지** 줄 시간.
|
|
281
|
+
*
|
|
282
|
+
* 위의 두 시계는 붙은 뒤를 잰다. 이건 붙기 전을 잰다 — 셋이 재는 자리가 다르다.
|
|
283
|
+
*
|
|
284
|
+
* ── 왜 따로 있어야 하나 ────────────────────────────────────────────────
|
|
285
|
+
*
|
|
286
|
+
* 곧장 가는 길(fetch)에는 원래 이 시계가 있다. undici 가 10초를 세고
|
|
287
|
+
* `UND_ERR_CONNECT_TIMEOUT` 을 준다. 그런데 **프록시·인증서를 쓰는 길**
|
|
288
|
+
* (노드로)에는 없었다. 거기서는 머리말 시계 하나가 「붙는 시간」 과 「답을
|
|
289
|
+
* 기다리는 시간」 을 같이 재고 있었고, 그 시계는 5분이다.
|
|
290
|
+
*
|
|
291
|
+
* 그래서 같은 게이트웨이인데 프록시를 켠 사람만, 망이 잠깐 끊긴 자리에서
|
|
292
|
+
* **5분을 다 쓰고 나서** 턴이 죽었다. 곧장 가는 사람은 10초 만에 알고
|
|
293
|
+
* 다시 붙는데 말이다. 설명하기 제일 어려운 종류의 차이다.
|
|
294
|
+
*
|
|
295
|
+
* 그래서 10초로 맞춘다. 두 길이 같은 자리에서 같은 말을 하게 하는 것이
|
|
296
|
+
* 이 상수가 있는 이유고, 그러면 다시 부르는 규칙(backend/retry.js)도
|
|
297
|
+
* 두 길에 똑같이 걸린다.
|
|
298
|
+
*/
|
|
299
|
+
export const 연결기본 = 10000;
|
|
300
|
+
|
|
301
|
+
/** 붙지도 못한 것. 다시 불러도 되는 자리라 코드를 따로 준다 (backend/retry.js). */
|
|
302
|
+
export function 못붙음오류(연결) {
|
|
303
|
+
return Object.assign(
|
|
304
|
+
new Error(`${초로(연결)}초 안에 연결하지 못했습니다`),
|
|
305
|
+
{ name: 'ConnectTimeoutError', code: 'CONNECT_TIMEOUT', 연결 },
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
279
309
|
export function 소식없음오류(무소식) {
|
|
280
310
|
return Object.assign(
|
|
281
311
|
new Error(`${초로(무소식)}초 동안 살아 있다는 신호만 오고 내용이 안 왔습니다`),
|
|
@@ -397,7 +427,7 @@ async function 직접흘려(url, { method, headers, body, timeout, signal, 잠
|
|
|
397
427
|
* 둘 다 「fetch 로는 못 하는 것」 이라 길이 같다. 여기가 프록시 전용이던 시절
|
|
398
428
|
* 이름이 프록시로 였는데, 인증서를 실으면서 아닌 자리가 생겼다.
|
|
399
429
|
*/
|
|
400
|
-
function 노드로(url, { method, headers, body, timeout, stream, signal, 프록시 = null, 인증서 = null, 잠잠 = 0 }) {
|
|
430
|
+
function 노드로(url, { method, headers, body, timeout, stream, signal, 프록시 = null, 인증서 = null, 잠잠 = 0, 연결 = 연결기본 }) {
|
|
401
431
|
const 대상 = new URL(url);
|
|
402
432
|
const 포트 = Number(대상.port || (대상.protocol === 'https:' ? 443 : 80));
|
|
403
433
|
/*
|
|
@@ -412,17 +442,32 @@ function 노드로(url, { method, headers, body, timeout, stream, signal, 프록
|
|
|
412
442
|
const sig = 손.signal;
|
|
413
443
|
let 멎음 = null;
|
|
414
444
|
let 시간초과 = false;
|
|
445
|
+
let 못붙음 = false;
|
|
415
446
|
let 머리시계 = setTimeout(() => { 시간초과 = true; 손.abort(); }, timeout);
|
|
416
447
|
const 시계끄기 = () => { if (머리시계) { clearTimeout(머리시계); 머리시계 = null; } };
|
|
448
|
+
/*
|
|
449
|
+
* ── 붙는 데까지만 재는 시계 ──────────────────────────────────────────
|
|
450
|
+
*
|
|
451
|
+
* 곧장 가는 길에는 undici 가 이걸 갖고 있다(10초). 여기엔 없어서, 프록시를
|
|
452
|
+
* 켠 사람만 망이 끊긴 자리에서 **머리말 시계 5분을 통째로** 쓰고 죽었다.
|
|
453
|
+
* 두 길이 같은 자리에서 같은 말을 하게 맞춘다 (연결기본).
|
|
454
|
+
*
|
|
455
|
+
* 소켓을 얻는 순간 끈다. 그 뒤로 오래 걸리는 것은 「못 붙은 것」 이 아니라
|
|
456
|
+
* 「생각하는 중」 이고, 그건 머리말 시계와 잠잠 시계가 맡는 자리다.
|
|
457
|
+
*/
|
|
458
|
+
let 연결시계 = 연결 > 0 ? setTimeout(() => { 못붙음 = true; 손.abort(); }, 연결) : null;
|
|
459
|
+
const 붙었다 = () => { if (연결시계) { clearTimeout(연결시계); 연결시계 = null; } };
|
|
417
460
|
const 사람이 = () => 손.abort();
|
|
418
461
|
if (signal?.aborted) 손.abort();
|
|
419
462
|
else signal?.addEventListener('abort', 사람이, { once: true });
|
|
420
|
-
// 끊긴
|
|
421
|
-
// 화면에서
|
|
463
|
+
// 끊긴 까닭을 가른다 — 사람이 끊은 것 · 못 붙은 것 · 시계가 끊은 것 · 흐름이 멎은 것.
|
|
464
|
+
// 화면에서 넷이 다른 말이고, 다시 불러도 되는지도 넷이 다르다.
|
|
422
465
|
const 왜끊겼나 = () => (signal?.aborted ? new Aborted()
|
|
423
|
-
: 멎음 ?? (
|
|
424
|
-
?
|
|
425
|
-
:
|
|
466
|
+
: 멎음 ?? (못붙음
|
|
467
|
+
? 못붙음오류(연결)
|
|
468
|
+
: 시간초과
|
|
469
|
+
? Object.assign(new Error('시간 초과 — 응답이 없습니다'), { name: 'TimeoutError', code: 'TimeoutError' })
|
|
470
|
+
: new Aborted()));
|
|
426
471
|
|
|
427
472
|
return new Promise((resolve, reject) => {
|
|
428
473
|
let rq = null;
|
|
@@ -430,6 +475,7 @@ function 노드로(url, { method, headers, body, timeout, stream, signal, 프록
|
|
|
430
475
|
let 끝났나 = false;
|
|
431
476
|
const 정리 = () => {
|
|
432
477
|
시계끄기();
|
|
478
|
+
붙었다();
|
|
433
479
|
sig.removeEventListener('abort', 끊기);
|
|
434
480
|
signal?.removeEventListener('abort', 사람이);
|
|
435
481
|
};
|
|
@@ -535,6 +581,19 @@ function 노드로(url, { method, headers, body, timeout, stream, signal, 프록
|
|
|
535
581
|
}, 받기);
|
|
536
582
|
}
|
|
537
583
|
if (sig.aborted) return 끊기();
|
|
584
|
+
/*
|
|
585
|
+
* 소켓을 얻는 순간 연결 시계는 할 일이 끝났다.
|
|
586
|
+
*
|
|
587
|
+
* 터널로 받은 소켓은 이미 붙어 있다(createConnection) — `connecting` 이
|
|
588
|
+
* 거짓이라 그 자리에서 바로 꺼진다. 새로 붙는 소켓은 TCP 면 `connect`,
|
|
589
|
+
* TLS 면 `secureConnect` 에서 꺼진다. 둘 다 걸어 두면 어느 길로 와도
|
|
590
|
+
* 한 번은 꺼진다.
|
|
591
|
+
*/
|
|
592
|
+
rq.once('socket', (sock) => {
|
|
593
|
+
if (!sock || sock.connecting !== true) return 붙었다();
|
|
594
|
+
sock.once('connect', 붙었다);
|
|
595
|
+
sock.once('secureConnect', 붙었다);
|
|
596
|
+
});
|
|
538
597
|
rq.once('error', (e) => 실패(e));
|
|
539
598
|
if (body !== undefined) rq.write(body);
|
|
540
599
|
rq.end();
|
|
@@ -630,6 +689,15 @@ function normalizeError(err) {
|
|
|
630
689
|
if (err?.code === 'CERT_READ') return m;
|
|
631
690
|
// 흐름이 멎어서 우리가 끊은 것. 서버가 끊은 것과 다른 말이어야 한다.
|
|
632
691
|
if (err?.code === 'STALL') return `${m} — 답을 통째로 모았다가 주는 게이트웨이면 프로필의 잠잠 을 올려 보세요`;
|
|
692
|
+
/*
|
|
693
|
+
* 아직 **붙지도 못한** 것. 아래의 「응답이 없습니다」 와 반드시 갈라야 한다 —
|
|
694
|
+
* 저건 서버가 받아 놓고 답을 안 한 것이고, 이건 서버까지 가지도 못한 것이다.
|
|
695
|
+
* 사람이 볼 자리가 다르고(망·프록시 대 게이트웨이), 다시 불러도 되는지도
|
|
696
|
+
* 다르다 (backend/retry.js 의 못붙은코드).
|
|
697
|
+
*/
|
|
698
|
+
if (코드 === 'CONNECT_TIMEOUT' || 코드 === 'UND_ERR_CONNECT_TIMEOUT') {
|
|
699
|
+
return '연결하지 못했습니다 — 망이나 프록시를 확인하세요 (잠시 뒤 다시 해 봅니다)';
|
|
700
|
+
}
|
|
633
701
|
if (err?.name === 'TimeoutError' || 코드 === 'TimeoutError' || /timed? ?out/i.test(m)) return '시간 초과 — 응답이 없습니다';
|
|
634
702
|
if (/ENOTFOUND|EAI_AGAIN/.test(코드) || /ENOTFOUND|getaddrinfo/i.test(m)) return '주소를 찾을 수 없습니다 (DNS)';
|
|
635
703
|
if (코드 === 'ECONNREFUSED' || /ECONNREFUSED/i.test(m)) return '연결이 거부되었습니다 (서버가 꺼져 있거나 포트가 다릅니다)';
|
package/src/backend/retry.js
CHANGED
|
@@ -20,8 +20,13 @@
|
|
|
20
20
|
// 401 에 딱 하나 예외가 있는데, 그것도 여기가 아니라 backend/adapter.js 가 한다 —
|
|
21
21
|
// 열쇠받기(safety/authcmd.js)가 걸려 있으면 **같은 열쇠로 다시 부르는 것이 아니라**
|
|
22
22
|
// 새 열쇠를 받아서 부르는 것이라, 「불러 봐야 같다」 에 해당하지 않는다. 딱 한 번이다.
|
|
23
|
-
// · ECONNREFUSED 는 서버가 꺼진 것이고,
|
|
24
|
-
// 둘 다 안 부른다.
|
|
23
|
+
// · ECONNREFUSED 는 서버가 꺼진 것이고, 주소가 틀린 것(ENOTFOUND)은 백 번 물어도
|
|
24
|
+
// 틀리다. 둘 다 안 부른다.
|
|
25
|
+
// · **머리말 시계(5분)** 가 끝난 것도 안 부른다. 서버가 받아 놓고 답을 안 한
|
|
26
|
+
// 자리라, 5분을 또 기다릴 일이 아니다.
|
|
27
|
+
// · 다만 **아직 붙지도 못한 것**은 부른다 (아래 못붙은코드). 연결 시계는 10초라
|
|
28
|
+
// 다시 해 보는 값이 싸고, 소켓이 없었으니 서버에는 아무것도 안 갔다 — 다시
|
|
29
|
+
// 불러도 두 벌이 될 것이 없다. 무선이 끊겼다 붙는 동안이 전부 여기다.
|
|
25
30
|
//
|
|
26
31
|
// 얼마나 기다리나:
|
|
27
32
|
// 서버가 Retry-After 로 말해 주면 그것(60초에서 자른다 — 그 이상은 사람이 결정할
|
|
@@ -50,6 +55,41 @@ const 다시부를상태 = new Set([408, 429, 500, 502, 503, 504, 529]);
|
|
|
50
55
|
// 머리말도 못 받고 끊긴 것. undici 는 상대가 닫으면 UND_ERR_SOCKET 으로 온다.
|
|
51
56
|
const 다시부를코드 = new Set(['ECONNRESET', 'EPIPE', 'UND_ERR_SOCKET', 'ECONNABORTED']);
|
|
52
57
|
|
|
58
|
+
/*
|
|
59
|
+
* ── 아직 **붙지도 못한** 것 ─────────────────────────────────────────────
|
|
60
|
+
*
|
|
61
|
+
* 위의 것들은 붙었다가 끊긴 자리다. 여기는 소켓이 아예 안 생긴 자리 —
|
|
62
|
+
* 손이 닿기 전이라 서버에는 아무것도 안 갔다. 그래서 **다시 부르는 것이
|
|
63
|
+
* 제일 안전한 종류**다. 두 벌이 될 답도, 반쯤 흘러간 글자도 없다.
|
|
64
|
+
*
|
|
65
|
+
* 왜 따로 두나: 이 파일은 여태 「시간 초과는 다시 안 부른다」 였고, 그건
|
|
66
|
+
* **머리말 시계(5분)** 를 두고 한 말로는 옳았다. 5분을 기다렸다가 또 5분을
|
|
67
|
+
* 기다리는 것은 사람을 붙드는 짓이다.
|
|
68
|
+
*
|
|
69
|
+
* 그런데 그 규칙이 **붙지도 못한 것**까지 같이 덮고 있었다. 연결 시계는
|
|
70
|
+
* 10초다(undici 의 UND_ERR_CONNECT_TIMEOUT · backend/http.js 의 연결기본).
|
|
71
|
+
* 10초 만에 못 붙은 것을 두 번 더 해 보는 값은 1초·2초 기다림이지 5분이
|
|
72
|
+
* 아니다. 그런데도 여태 턴이 통째로 죽었다 — 사람은 같은 말을 다시 치고,
|
|
73
|
+
* 그 턴에 읽어 둔 것은 날아갔다.
|
|
74
|
+
*
|
|
75
|
+
* 무선이 잠깐 끊기거나, 노트북이 깨어나거나, VPN 이 다시 붙는 동안이 전부
|
|
76
|
+
* 여기다. 몇 초 뒤에는 되는 것들이라 **한 세션이 거기서 끝날 이유가 없다.**
|
|
77
|
+
*
|
|
78
|
+
* `ENOTFOUND` 는 여기 없다. 이름이 틀린 것은 백 번 물어도 틀리다. 반대로
|
|
79
|
+
* `EAI_AGAIN` 은 규격이 「지금은 못 하겠다, 다시 물어라」 라고 정해 둔
|
|
80
|
+
* 코드라 여기 있다 — 이름이 같아도 뜻이 정반대다.
|
|
81
|
+
*/
|
|
82
|
+
const 못붙은코드 = new Set([
|
|
83
|
+
'UND_ERR_CONNECT_TIMEOUT', // undici 가 10초 안에 소켓을 못 얻음
|
|
84
|
+
'ETIMEDOUT', // OS 가 TCP 연결에서 손을 뗌
|
|
85
|
+
'EAI_AGAIN', // 이름 풀이가 **잠깐** 안 됨 (ENOTFOUND 와 다르다)
|
|
86
|
+
'ENETUNREACH', // 망이 잠깐 없음 (깨어나는 중·VPN 이 붙는 중)
|
|
87
|
+
'EHOSTUNREACH',
|
|
88
|
+
'ENETDOWN',
|
|
89
|
+
'ENETRESET',
|
|
90
|
+
'CONNECT_TIMEOUT', // 우리가 노드 길에서 붙인 이름 (backend/http.js)
|
|
91
|
+
]);
|
|
92
|
+
|
|
53
93
|
/**
|
|
54
94
|
* 이 실패를 두고 다시 불러도 되나.
|
|
55
95
|
* @param {{status?: number, code?: string|null, attempt?: number}} 실패 attempt 는 방금 실패한 것이 몇 번째였나 (1부터)
|
|
@@ -60,7 +100,13 @@ export function 다시부를까({ status = 0, code = null, attempt = 1 } = {},
|
|
|
60
100
|
const 한도 = Number(status) === 429 ? (정책.막힘최대 ?? 정책.최대) : 정책.최대;
|
|
61
101
|
if (attempt > 한도) return false;
|
|
62
102
|
if (status) return 다시부를상태.has(Number(status));
|
|
63
|
-
|
|
103
|
+
const c = String(code ?? '');
|
|
104
|
+
return !!code && (다시부를코드.has(c) || 못붙은코드.has(c));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 아직 붙지도 못한 실패인가. 화면이 무슨 말을 할지 여기서 갈린다. */
|
|
108
|
+
export function 못붙은것인가(code) {
|
|
109
|
+
return 못붙은코드.has(String(code ?? ''));
|
|
64
110
|
}
|
|
65
111
|
|
|
66
112
|
/**
|
package/src/tools/verify.js
CHANGED
|
@@ -30,9 +30,10 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { existsSync, statSync, readFileSync } from 'node:fs';
|
|
32
32
|
import { execFile } from 'node:child_process';
|
|
33
|
-
import { extname, dirname,
|
|
33
|
+
import { extname, dirname, resolve } from 'node:path';
|
|
34
34
|
import { walk, SKIP_DIRS, 내부살림 } from './fsutil.js';
|
|
35
35
|
import { 건너뜀말 } from './ignore.js';
|
|
36
|
+
import { 확인법들 } from './확인법.js';
|
|
36
37
|
import { decode, looksBinary } from './encoding.js';
|
|
37
38
|
import { checkCommand } from '../safety/guard.js';
|
|
38
39
|
import { 말 } from '../i18n/index.js';
|
|
@@ -315,10 +316,24 @@ export const VERIFY_TOOL = {
|
|
|
315
316
|
* 검사가 걸려 있기 때문이다. 이 도구가 몰래 돌리면 strict 모드에서
|
|
316
317
|
* "물어보고 실행한다" 는 약속이 이 자리에서만 깨진다.
|
|
317
318
|
*/
|
|
318
|
-
const
|
|
319
|
-
if (
|
|
319
|
+
const 확인들 = 확인법들(뿌리);
|
|
320
|
+
if (확인들.length) {
|
|
320
321
|
줄.push('');
|
|
321
|
-
|
|
322
|
+
if (확인들.length === 1) {
|
|
323
|
+
줄.push(`이 프로젝트에는 ${확인들[0].명령} 가 있습니다. 진짜로 도는지 보려면 Bash 로 돌려라.`);
|
|
324
|
+
} else {
|
|
325
|
+
/*
|
|
326
|
+
* **하나만 알려 주면 하나만 돈다.**
|
|
327
|
+
*
|
|
328
|
+
* 벤치마크에서 진 자리가 여기다 — 검사가 셋인 프로젝트에서 우리는
|
|
329
|
+
* 하나만 알려 줬고, 그래서 하나만 돌았고, 회귀 범위가 좁았다.
|
|
330
|
+
* 고른 하나가 아니라 **찾은 전부**를 준다. 무엇을 돌릴지는 모델이
|
|
331
|
+
* 정하되, 있는 줄도 몰라서 못 도는 일은 없어야 한다.
|
|
332
|
+
*/
|
|
333
|
+
줄.push(`이 프로젝트의 확인 방법은 ${확인들.length}가지입니다. 고친 곳과 닿는 것은 **전부** Bash 로 돌려라 —`);
|
|
334
|
+
for (const { 명령, 어디서 } of 확인들) 줄.push(` ${명령} (${어디서})`);
|
|
335
|
+
줄.push('하나만 돌리고 "회귀 검증했다" 고 말하지 마라. 안 돌린 것은 안 돌렸다고 적어라.');
|
|
336
|
+
}
|
|
322
337
|
}
|
|
323
338
|
|
|
324
339
|
const 다됐나 = !탈난것.length;
|
|
@@ -339,20 +354,4 @@ export const VERIFY_TOOL = {
|
|
|
339
354
|
},
|
|
340
355
|
};
|
|
341
356
|
|
|
342
|
-
|
|
343
|
-
* 이 프로젝트가 스스로 정해 둔 확인 방법.
|
|
344
|
-
*
|
|
345
|
-
* package.json 에 test 가 있으면 그것이 답이다. 없는 것을 지어내지는 않는다 —
|
|
346
|
-
* 없으면 null 이고, 그러면 파일별 확인만 한 것이 전부다.
|
|
347
|
-
* 돌리지는 않는다. 알려 주기만 한다 (위 머리말 참고).
|
|
348
|
-
*/
|
|
349
|
-
function 프로젝트확인법(뿌리) {
|
|
350
|
-
const pkg = join(뿌리, 'package.json');
|
|
351
|
-
if (!existsSync(pkg)) return null;
|
|
352
|
-
try {
|
|
353
|
-
const j = JSON.parse(readFileSync(pkg, 'utf8'));
|
|
354
|
-
if (j.scripts?.test) return 'npm test';
|
|
355
|
-
if (j.scripts?.build) return 'npm run build';
|
|
356
|
-
} catch { /* 망가진 package.json 은 아래 파일 확인에서 잡힌다 */ }
|
|
357
|
-
return null;
|
|
358
|
-
}
|
|
357
|
+
// 확인 방법 찾기는 tools/확인법.js 로 옮겼다 — 찾을 자리가 늘어 파일이 무거워졌다.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 이 프로젝트가 스스로 정해 둔 **확인 방법들**을 찾는다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 따로 나왔나 ─────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 벤치마크에서 진 자리다(`docs/ko/benchmark.md`).
|
|
7
|
+
*
|
|
8
|
+
* 같은 프로젝트에 같은 일을 시킨 비교에서, 품질은 거의 동급이었는데 **전체
|
|
9
|
+
* 회귀 범위**만 우리가 낮았다(85% 대 100%). 까닭은 단순했다 — 그 프로젝트의
|
|
10
|
+
* 검사는 `scripts/` 밑의 독립 실행 파일 셋이었는데, 우리는 `package.json` 의
|
|
11
|
+
* `test` 하나만 보고 있었다. 못 본 검사는 안 돌고, 안 돈 검사는 회귀를 못 잡는다.
|
|
12
|
+
*
|
|
13
|
+
* 고칠 자리가 「더 열심히 하라」 가 아니라 **「무엇이 있는지 알려 주기」** 였다.
|
|
14
|
+
* 시킴말에 "확인해라" 는 이미 있었다. 확인할 **길**의 목록이 없었을 뿐이다.
|
|
15
|
+
*
|
|
16
|
+
* ── 여기서도 안 돌린다 ──────────────────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* 찾기만 하고 **실행은 안 한다.** 검사 스크립트는 무슨 짓이든 할 수 있어서,
|
|
19
|
+
* 그걸 돌리는 길은 Bash 하나여야 한다 — 승인 관문과 안전 검사가 거기에만
|
|
20
|
+
* 걸려 있다. 이 파일이 몰래 돌리면 strict 모드의 약속이 여기서만 깨진다.
|
|
21
|
+
*
|
|
22
|
+
* ── 없는 것은 지어내지 않는다 ───────────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* 있을 법한 이름을 추측해서 채우지 않는다. **파일이나 스크립트로 실재하는
|
|
25
|
+
* 것만** 담는다. 없는 명령을 알려 주면 모델이 그걸 부르고, 실패를 받고, 또
|
|
26
|
+
* 부른다 — 걸음만 태우고 사람은 왜 헤매는지 모른다.
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
|
|
31
|
+
/** 한 판에 몇 개까지 알려 줄까. 스무 개를 늘어놓으면 그건 목록이 아니라 소음이다. */
|
|
32
|
+
export const 최대 = 8;
|
|
33
|
+
|
|
34
|
+
/*
|
|
35
|
+
* package.json 의 스크립트 가운데 **확인에 해당하는 것**.
|
|
36
|
+
*
|
|
37
|
+
* `test` 만 보던 것을 넓힌다. 요즘 저장소는 확인을 여러 칸에 나눠 둔다 —
|
|
38
|
+
* 형 검사와 린트와 e2e 가 따로 있고, 그 중 하나만 돌리면 나머지는 안 돈다.
|
|
39
|
+
*
|
|
40
|
+
* `build` 는 확인이 아니지만 **깨지면 아무것도 안 되는 것**이라 맨 뒤에 붙인다.
|
|
41
|
+
*/
|
|
42
|
+
const 확인스크립트 = /^(test|tests|check|checks|lint|typecheck|types?|verify|qa|e2e|spec|coverage)(:|$)/i;
|
|
43
|
+
const 뒤에붙일것 = /^build(:|$)/i;
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* 독립 실행 검사 파일이 사는 자리와 이름.
|
|
47
|
+
*
|
|
48
|
+
* `package.json` 에 안 걸리는 검사가 여기 산다. 벤치마크에서 놓친 것이 정확히
|
|
49
|
+
* 이 모양이었다 — `scripts/qa-collaboration.js` 처럼 npm 스크립트로 등록되지
|
|
50
|
+
* 않은 채 `node` 로 직접 도는 파일들.
|
|
51
|
+
*/
|
|
52
|
+
const 볼폴더 = ['scripts', 'script', 'test', 'tests', 'qa', 'tools', 'bin'];
|
|
53
|
+
const 검사파일 = /^(qa|test|tests|check|verify|e2e|spec|bench)[-_.].+\.(m?js|cjs|ts|py|sh)$/i;
|
|
54
|
+
const 부르는법 = { '.js': 'node', '.mjs': 'node', '.cjs': 'node', '.ts': 'node', '.py': 'python', '.sh': 'sh' };
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
* 다른 생태계의 관례.
|
|
58
|
+
*
|
|
59
|
+
* 자리(표식 파일)가 실재할 때만 담는다. Makefile 은 `test:` 칸이 진짜로
|
|
60
|
+
* 있을 때만 — 없는데 `make test` 를 알려 주면 그건 지어낸 것이다.
|
|
61
|
+
*/
|
|
62
|
+
function 생태계(뿌리, 담기) {
|
|
63
|
+
const 있나 = (f) => existsSync(join(뿌리, f));
|
|
64
|
+
if (있나('Cargo.toml')) 담기('cargo test', 'Cargo.toml');
|
|
65
|
+
if (있나('go.mod')) 담기('go test ./...', 'go.mod');
|
|
66
|
+
if (있나('pytest.ini') || 있나('tox.ini')) 담기('pytest', 'pytest 설정');
|
|
67
|
+
else if ((있나('pyproject.toml') || 있나('setup.cfg')) && (있나('tests') || 있나('test'))) 담기('pytest', 'pyproject + tests/');
|
|
68
|
+
if (있나('go.work')) 담기('go test ./...', 'go.work');
|
|
69
|
+
for (const 이름 of ['Makefile', 'makefile']) {
|
|
70
|
+
if (!있나(이름)) continue;
|
|
71
|
+
try {
|
|
72
|
+
// 진짜로 `test:` 칸이 있을 때만. 있을 법하다고 넣지 않는다.
|
|
73
|
+
if (/^\.?test\s*:/m.test(readFileSync(join(뿌리, 이름), 'utf8'))) 담기('make test', 이름);
|
|
74
|
+
} catch { /* 못 읽으면 없는 것으로 둔다 */ }
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 확인 방법들을 찾아 돌려준다.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} 뿌리 프로젝트 뿌리 경로
|
|
83
|
+
* @returns {{명령: string, 어디서: string}[]} 실재하는 것만. 없으면 빈 배열.
|
|
84
|
+
*/
|
|
85
|
+
export function 확인법들(뿌리) {
|
|
86
|
+
const 나온것 = [];
|
|
87
|
+
const 본명령 = new Set();
|
|
88
|
+
const 담기 = (명령, 어디서) => {
|
|
89
|
+
if (본명령.has(명령) || 나온것.length >= 최대) return;
|
|
90
|
+
본명령.add(명령);
|
|
91
|
+
나온것.push({ 명령, 어디서 });
|
|
92
|
+
};
|
|
93
|
+
const 나중에 = [];
|
|
94
|
+
|
|
95
|
+
const pkg = join(뿌리, 'package.json');
|
|
96
|
+
if (existsSync(pkg)) {
|
|
97
|
+
try {
|
|
98
|
+
const j = JSON.parse(readFileSync(pkg, 'utf8'));
|
|
99
|
+
for (const 이름 of Object.keys(j.scripts ?? {})) {
|
|
100
|
+
// `npm test` 는 관례가 있고 나머지는 `npm run` 을 붙여야 돈다.
|
|
101
|
+
const 명령 = 이름 === 'test' ? 'npm test' : `npm run ${이름}`;
|
|
102
|
+
if (확인스크립트.test(이름)) 담기(명령, 'package.json');
|
|
103
|
+
else if (뒤에붙일것.test(이름)) 나중에.push([명령, 'package.json']);
|
|
104
|
+
}
|
|
105
|
+
} catch { /* 망가진 package.json 은 파일별 확인에서 잡힌다 */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const 폴더 of 볼폴더) {
|
|
109
|
+
const 곳 = join(뿌리, 폴더);
|
|
110
|
+
let 목록;
|
|
111
|
+
try {
|
|
112
|
+
if (!statSync(곳).isDirectory()) continue;
|
|
113
|
+
목록 = readdirSync(곳);
|
|
114
|
+
} catch { continue; }
|
|
115
|
+
for (const 이름 of 목록.sort()) {
|
|
116
|
+
if (!검사파일.test(이름)) continue;
|
|
117
|
+
const 끝 = 이름.slice(이름.lastIndexOf('.')).toLowerCase();
|
|
118
|
+
const 앞 = 부르는법[끝];
|
|
119
|
+
if (!앞) continue;
|
|
120
|
+
담기(`${앞} ${폴더}/${이름}`, `${폴더}/`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
생태계(뿌리, 담기);
|
|
125
|
+
for (const [명령, 어디서] of 나중에) 담기(명령, 어디서);
|
|
126
|
+
return 나온것;
|
|
127
|
+
}
|