coderail-watch 0.1.8 → 0.1.11
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/bin/coderail-watch.js +674 -78
- package/package.json +1 -1
package/bin/coderail-watch.js
CHANGED
|
@@ -12,6 +12,8 @@ const ENV_BASE_URLS = {
|
|
|
12
12
|
production: "https://api.coderail.local",
|
|
13
13
|
};
|
|
14
14
|
const DEFAULT_RETRY_WAIT_MS = 2000;
|
|
15
|
+
const DEFAULT_LOG_BATCH_MS = 300;
|
|
16
|
+
const DEFAULT_LOG_BATCH_MAX_LINES = 50;
|
|
15
17
|
const ALERT_PREFIX = "[[CODERAIL_ERROR]] ";
|
|
16
18
|
const ERROR_PATTERN = /(^|[\s:])(?:error|exception|traceback|panic|fatal)(?=[\s:])/i;
|
|
17
19
|
const DEFAULT_PROJECT_TREE_MAX_FILES = 1000;
|
|
@@ -55,7 +57,21 @@ const parseArgs = (argv) => {
|
|
|
55
57
|
return args;
|
|
56
58
|
};
|
|
57
59
|
|
|
58
|
-
const
|
|
60
|
+
const LOG_LEVELS = {
|
|
61
|
+
error: 0,
|
|
62
|
+
warn: 1,
|
|
63
|
+
info: 2,
|
|
64
|
+
debug: 3,
|
|
65
|
+
};
|
|
66
|
+
let currentLogLevel = LOG_LEVELS.warn;
|
|
67
|
+
|
|
68
|
+
const shouldLog = (level) => {
|
|
69
|
+
const resolved = LOG_LEVELS[level] ?? LOG_LEVELS.info;
|
|
70
|
+
return currentLogLevel >= resolved;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const log = (message, level = "info") => {
|
|
74
|
+
if (!shouldLog(level)) return;
|
|
59
75
|
process.stderr.write(`[coderail-watch] ${message}\n`);
|
|
60
76
|
};
|
|
61
77
|
|
|
@@ -66,6 +82,7 @@ if (!WebSocketImpl) {
|
|
|
66
82
|
} catch (error) {
|
|
67
83
|
log(
|
|
68
84
|
"Missing dependency 'ws'. Run `cd tools/coderail-watch && npm install` or use the published package via `npx coderail-watch@latest ...`.",
|
|
85
|
+
"error",
|
|
69
86
|
);
|
|
70
87
|
process.exit(1);
|
|
71
88
|
}
|
|
@@ -85,7 +102,7 @@ const normalizeBaseHttpUrl = (baseUrl) => {
|
|
|
85
102
|
return normalized.replace(/\/$/, "");
|
|
86
103
|
};
|
|
87
104
|
|
|
88
|
-
const buildWsUrl = (baseUrl, sessionId, projectKey, token) => {
|
|
105
|
+
const buildWsUrl = (baseUrl, sessionId, projectKey, token, flowId) => {
|
|
89
106
|
const url = new URL(normalizeHttpBaseUrl(baseUrl));
|
|
90
107
|
const wsProtocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
91
108
|
url.protocol = wsProtocol;
|
|
@@ -96,6 +113,9 @@ const buildWsUrl = (baseUrl, sessionId, projectKey, token) => {
|
|
|
96
113
|
if (token) {
|
|
97
114
|
url.searchParams.set("token", token);
|
|
98
115
|
}
|
|
116
|
+
if (flowId) {
|
|
117
|
+
url.searchParams.set("flow_id", flowId);
|
|
118
|
+
}
|
|
99
119
|
return url.toString();
|
|
100
120
|
};
|
|
101
121
|
|
|
@@ -148,7 +168,7 @@ const collectSnapshotPaths = (rootDir, excludes) => {
|
|
|
148
168
|
try {
|
|
149
169
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
150
170
|
} catch (error) {
|
|
151
|
-
log(`snapshot read failed: ${current} (${error})
|
|
171
|
+
log(`snapshot read failed: ${current} (${error})`, "warn");
|
|
152
172
|
continue;
|
|
153
173
|
}
|
|
154
174
|
for (const entry of entries) {
|
|
@@ -255,7 +275,7 @@ let baseUrl = "";
|
|
|
255
275
|
try {
|
|
256
276
|
baseUrl = resolveBaseUrl(args);
|
|
257
277
|
} catch (error) {
|
|
258
|
-
log(error.message || String(error));
|
|
278
|
+
log(error.message || String(error), "error");
|
|
259
279
|
process.exit(1);
|
|
260
280
|
}
|
|
261
281
|
const token = args["token"];
|
|
@@ -264,6 +284,16 @@ const retryWaitMs = normalizeIntArg(
|
|
|
264
284
|
DEFAULT_RETRY_WAIT_MS,
|
|
265
285
|
0,
|
|
266
286
|
);
|
|
287
|
+
const logBatchMs = normalizeIntArg(
|
|
288
|
+
args["log-batch-ms"],
|
|
289
|
+
DEFAULT_LOG_BATCH_MS,
|
|
290
|
+
0,
|
|
291
|
+
);
|
|
292
|
+
const logBatchMaxLines = normalizeIntArg(
|
|
293
|
+
args["log-batch-lines"],
|
|
294
|
+
DEFAULT_LOG_BATCH_MAX_LINES,
|
|
295
|
+
1,
|
|
296
|
+
);
|
|
267
297
|
const logPath = args["log-path"];
|
|
268
298
|
const projectRoot = path.resolve(args["project-root"] || process.cwd());
|
|
269
299
|
const sendProjectTree = normalizeBoolArg(args["project-tree"], true);
|
|
@@ -280,7 +310,8 @@ const projectTreeMaxBytes = normalizeIntArg(
|
|
|
280
310
|
DEFAULT_PROJECT_TREE_MAX_BYTES,
|
|
281
311
|
1,
|
|
282
312
|
);
|
|
283
|
-
const
|
|
313
|
+
const runMode = args["mode"] || "default";
|
|
314
|
+
const enableApiCheck = true;
|
|
284
315
|
const enableScreenCheck = normalizeBoolArg(args["screen-check"], true);
|
|
285
316
|
const dotenvPath = args["env-file"] || path.join(projectRoot, ".env");
|
|
286
317
|
const loadEnvFile = (filePath) => {
|
|
@@ -318,20 +349,57 @@ const openaiModel =
|
|
|
318
349
|
process.env.OPENAI_MODEL ||
|
|
319
350
|
envFromFile.OPENAI_MODEL ||
|
|
320
351
|
DEFAULT_OPENAI_MODEL;
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
)}/
|
|
352
|
+
const isNumericProjectKey = /^[0-9]+$/.test(projectKey);
|
|
353
|
+
const apiEndpointUrl = isNumericProjectKey
|
|
354
|
+
? `${baseUrl.replace(/\/$/, "")}/api/v1/projects/${encodeURIComponent(
|
|
355
|
+
projectKey,
|
|
356
|
+
)}/api_endpoint_list`
|
|
357
|
+
: `${baseUrl.replace(/\/$/, "")}/api/v1/projects/by_public/${encodeURIComponent(
|
|
358
|
+
projectKey,
|
|
359
|
+
)}/api_endpoint_list`;
|
|
360
|
+
const screenListUrl = isNumericProjectKey
|
|
361
|
+
? `${baseUrl.replace(/\/$/, "")}/api/v1/projects/${encodeURIComponent(
|
|
362
|
+
projectKey,
|
|
363
|
+
)}/screen_list`
|
|
364
|
+
: `${baseUrl.replace(/\/$/, "")}/api/v1/projects/by_public/${encodeURIComponent(
|
|
365
|
+
projectKey,
|
|
366
|
+
)}/screen_list`;
|
|
324
367
|
let apiEndpointCandidatesCached = [];
|
|
368
|
+
let screenCandidatesCached = [];
|
|
369
|
+
const toScreenName = (item) =>
|
|
370
|
+
typeof item === "object" && item !== null ? item.name || JSON.stringify(item) : String(item);
|
|
371
|
+
let apiEndpointFetchStatus = "unknown";
|
|
372
|
+
let apiEndpointFetchDetail = "";
|
|
373
|
+
let screenListFetchStatus = "unknown";
|
|
374
|
+
let screenListFetchDetail = "";
|
|
325
375
|
|
|
326
376
|
if (!sessionId || !projectKey) {
|
|
327
|
-
log("Missing required args: --session-id and --project-key");
|
|
377
|
+
log("Missing required args: --session-id and --project-key", "error");
|
|
328
378
|
process.exit(1);
|
|
329
379
|
}
|
|
330
380
|
|
|
331
|
-
const
|
|
381
|
+
const requestedLogLevel = String(
|
|
382
|
+
args["log-level"] || args["loglevel"] || "",
|
|
383
|
+
).toLowerCase();
|
|
384
|
+
if (requestedLogLevel in LOG_LEVELS) {
|
|
385
|
+
currentLogLevel = LOG_LEVELS[requestedLogLevel];
|
|
386
|
+
}
|
|
387
|
+
if (args["quiet"] === "true") {
|
|
388
|
+
currentLogLevel = -1;
|
|
389
|
+
}
|
|
390
|
+
// --mode security はデフォルトで info レベル以上を表示
|
|
391
|
+
if (runMode === "security" && !requestedLogLevel && args["quiet"] !== "true") {
|
|
392
|
+
currentLogLevel = LOG_LEVELS.info;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const MODE_FLOW_IDS = { security: "security_check" };
|
|
396
|
+
const resolvedFlowId = args["flow-id"] || MODE_FLOW_IDS[runMode] || null;
|
|
397
|
+
const wsUrl = buildWsUrl(baseUrl, sessionId, projectKey, token, resolvedFlowId);
|
|
332
398
|
let socket = null;
|
|
333
399
|
let isOpen = false;
|
|
334
400
|
const queue = [];
|
|
401
|
+
const logBuffer = [];
|
|
402
|
+
let logFlushTimer = null;
|
|
335
403
|
let retryCount = 0;
|
|
336
404
|
let lastErrorMessage = "";
|
|
337
405
|
let apiCheckInFlight = false;
|
|
@@ -354,22 +422,67 @@ const isSocketOpen = () => {
|
|
|
354
422
|
return isOpen;
|
|
355
423
|
};
|
|
356
424
|
|
|
425
|
+
const CONNECT_TIMEOUT_MS = 10000;
|
|
426
|
+
|
|
357
427
|
const connect = () => {
|
|
358
428
|
retryCount += 1;
|
|
359
|
-
|
|
429
|
+
const attempt = retryCount;
|
|
430
|
+
log(`connecting to ${wsUrl} (#${attempt})...`, "warn");
|
|
360
431
|
try {
|
|
361
|
-
|
|
432
|
+
const wsOptions = wsUrl.startsWith("wss://")
|
|
433
|
+
? { rejectUnauthorized: false }
|
|
434
|
+
: undefined;
|
|
435
|
+
socket = new WebSocket(wsUrl, wsOptions);
|
|
362
436
|
} catch (error) {
|
|
363
437
|
lastErrorMessage = String(error);
|
|
364
|
-
log(`connect failed: ${lastErrorMessage}
|
|
438
|
+
log(`connect failed: ${lastErrorMessage}`, "error");
|
|
365
439
|
setTimeout(connect, retryWaitMs);
|
|
366
440
|
return;
|
|
367
441
|
}
|
|
368
442
|
|
|
443
|
+
// 接続タイムアウト検知
|
|
444
|
+
const connectTimer = setTimeout(() => {
|
|
445
|
+
if (socket && !isOpen) {
|
|
446
|
+
log(`connection timeout (${CONNECT_TIMEOUT_MS}ms). 接続先を確認してください: ${wsUrl}`, "error");
|
|
447
|
+
try { socket.close(); } catch (_e) { /* ignore */ }
|
|
448
|
+
}
|
|
449
|
+
}, CONNECT_TIMEOUT_MS);
|
|
450
|
+
|
|
369
451
|
attachHandler(socket, "open", () => {
|
|
452
|
+
clearTimeout(connectTimer);
|
|
370
453
|
isOpen = true;
|
|
371
454
|
retryCount = 0;
|
|
372
|
-
log(`connected: ${wsUrl}
|
|
455
|
+
log(`connected: ${wsUrl}`, "warn");
|
|
456
|
+
|
|
457
|
+
// --mode security: run security check only, then exit
|
|
458
|
+
if (runMode === "security") {
|
|
459
|
+
void (async () => {
|
|
460
|
+
try {
|
|
461
|
+
const result = await triggerSecurityCheck("mode:security");
|
|
462
|
+
flushLogBuffer();
|
|
463
|
+
// Send done summary as code-analysis done for backend pipeline
|
|
464
|
+
sendCodeAnalysisLine(`done ${JSON.stringify({
|
|
465
|
+
api_missing_count: 0, api_missing_items: [],
|
|
466
|
+
api_implemented_count: 0, api_implemented_items: [],
|
|
467
|
+
screen_missing_count: 0, screen_missing_items: [],
|
|
468
|
+
screen_implemented_count: 0, screen_implemented_items: [],
|
|
469
|
+
...result,
|
|
470
|
+
})}`);
|
|
471
|
+
flushLogBuffer();
|
|
472
|
+
// Give time for send to complete
|
|
473
|
+
setTimeout(() => {
|
|
474
|
+
log("security mode complete, exiting.", "warn");
|
|
475
|
+
if (socket) socket.close();
|
|
476
|
+
process.exit(0);
|
|
477
|
+
}, 500);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
log(`security mode error: ${error}`, "error");
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
482
|
+
})();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
373
486
|
if (sendProjectTree) {
|
|
374
487
|
emitProjectTree();
|
|
375
488
|
}
|
|
@@ -384,6 +497,14 @@ const connect = () => {
|
|
|
384
497
|
}
|
|
385
498
|
})();
|
|
386
499
|
}
|
|
500
|
+
if (enableScreenCheck) {
|
|
501
|
+
void (async () => {
|
|
502
|
+
if (!screenCandidatesCached.length) {
|
|
503
|
+
await fetchScreenList();
|
|
504
|
+
}
|
|
505
|
+
logScreenCheckStatus();
|
|
506
|
+
})();
|
|
507
|
+
}
|
|
387
508
|
while (queue.length) {
|
|
388
509
|
const payload = queue.shift();
|
|
389
510
|
if (!isSocketOpen()) {
|
|
@@ -418,10 +539,18 @@ const connect = () => {
|
|
|
418
539
|
}
|
|
419
540
|
if (!payload || typeof payload !== "object") return;
|
|
420
541
|
if (payload.type !== "control") return;
|
|
542
|
+
log(`[control] received action=${payload.action}`, "info");
|
|
421
543
|
if (payload.action === "api_check") {
|
|
544
|
+
log("[control] triggering api_check", "info");
|
|
422
545
|
void triggerApiCheck("control");
|
|
423
546
|
return;
|
|
424
547
|
}
|
|
548
|
+
if (payload.action === "code_analysis") {
|
|
549
|
+
const role = payload.role || null;
|
|
550
|
+
log(`[control] triggering code_analysis role=${role}`, "info");
|
|
551
|
+
void triggerCodeAnalysis("control", role);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
425
554
|
if (payload.action === "screen_check") {
|
|
426
555
|
if (enableScreenCheck) {
|
|
427
556
|
void triggerScreenCheck("control");
|
|
@@ -429,9 +558,14 @@ const connect = () => {
|
|
|
429
558
|
sendScreenCheckLine("screen check disabled; skipping.");
|
|
430
559
|
}
|
|
431
560
|
}
|
|
561
|
+
if (payload.action === "security_check") {
|
|
562
|
+
log("[control] triggering security_check", "info");
|
|
563
|
+
void triggerSecurityCheck("control");
|
|
564
|
+
}
|
|
432
565
|
});
|
|
433
566
|
|
|
434
567
|
attachHandler(socket, "close", (event) => {
|
|
568
|
+
clearTimeout(connectTimer);
|
|
435
569
|
isOpen = false;
|
|
436
570
|
const code =
|
|
437
571
|
event && typeof event === "object" && "code" in event ? event.code : null;
|
|
@@ -439,13 +573,23 @@ const connect = () => {
|
|
|
439
573
|
event && typeof event === "object" && "reason" in event
|
|
440
574
|
? event.reason
|
|
441
575
|
: "";
|
|
442
|
-
const
|
|
576
|
+
const reasonStr = reason ? String(reason) : "";
|
|
577
|
+
const detail = [code ? `code=${code}` : null, reasonStr || null]
|
|
443
578
|
.filter(Boolean)
|
|
444
579
|
.join(" ");
|
|
580
|
+
if (code === 1008) {
|
|
581
|
+
const hint = reasonStr === "project_key_mismatch"
|
|
582
|
+
? "同じ session_id で別の project_key が既に登録されています。バックエンドサーバーを再起動してください。"
|
|
583
|
+
: reasonStr
|
|
584
|
+
? `サーバーが接続を拒否しました: ${reasonStr}`
|
|
585
|
+
: "サーバーが接続を拒否しました (code=1008)。session_id / project_key を確認してください。";
|
|
586
|
+
log(`error: ${hint}`, "error");
|
|
587
|
+
}
|
|
445
588
|
log(
|
|
446
589
|
`disconnected; retrying in ${retryWaitMs}ms${
|
|
447
590
|
detail ? ` (${detail})` : ""
|
|
448
591
|
}`,
|
|
592
|
+
"warn",
|
|
449
593
|
);
|
|
450
594
|
setTimeout(connect, retryWaitMs);
|
|
451
595
|
});
|
|
@@ -456,7 +600,7 @@ const connect = () => {
|
|
|
456
600
|
? error.message
|
|
457
601
|
: String(error);
|
|
458
602
|
lastErrorMessage = message;
|
|
459
|
-
log(`error: ${message}
|
|
603
|
+
log(`error: ${message}`, "warn");
|
|
460
604
|
});
|
|
461
605
|
};
|
|
462
606
|
|
|
@@ -480,45 +624,96 @@ const enqueueOrSend = (payload) => {
|
|
|
480
624
|
queue.push(payload);
|
|
481
625
|
};
|
|
482
626
|
|
|
483
|
-
const
|
|
627
|
+
const flushLogBuffer = () => {
|
|
628
|
+
if (logFlushTimer) {
|
|
629
|
+
clearTimeout(logFlushTimer);
|
|
630
|
+
logFlushTimer = null;
|
|
631
|
+
}
|
|
632
|
+
if (!logBuffer.length) return;
|
|
633
|
+
const lines = logBuffer.splice(0, logBuffer.length);
|
|
484
634
|
const payload = JSON.stringify({
|
|
485
635
|
type: "log",
|
|
486
|
-
content:
|
|
636
|
+
content: lines.join("\n"),
|
|
487
637
|
timestamp: new Date().toISOString(),
|
|
488
638
|
});
|
|
489
639
|
enqueueOrSend(payload);
|
|
490
640
|
};
|
|
491
641
|
|
|
642
|
+
const enqueueLogLine = (line) => {
|
|
643
|
+
if (!line) return;
|
|
644
|
+
logBuffer.push(line);
|
|
645
|
+
if (logBatchMs === 0 || logBuffer.length >= logBatchMaxLines) {
|
|
646
|
+
flushLogBuffer();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (!logFlushTimer) {
|
|
650
|
+
logFlushTimer = setTimeout(flushLogBuffer, logBatchMs);
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const sendLogLine = (line) => {
|
|
655
|
+
enqueueLogLine(line);
|
|
656
|
+
};
|
|
657
|
+
|
|
492
658
|
const sendApiCheckLine = (line) => {
|
|
493
659
|
const tagged = `[api-check] ${line}`;
|
|
494
|
-
log(tagged);
|
|
660
|
+
log(tagged, "debug");
|
|
661
|
+
sendLogLine(tagged);
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const sendCodeAnalysisLine = (line) => {
|
|
665
|
+
const tagged = `[code-analysis] ${line}`;
|
|
666
|
+
log(tagged, "info");
|
|
495
667
|
sendLogLine(tagged);
|
|
496
668
|
};
|
|
497
669
|
|
|
498
670
|
const sendScreenCheckLine = (line) => {
|
|
499
671
|
const tagged = `[screen-check] ${line}`;
|
|
500
|
-
log(tagged);
|
|
672
|
+
log(tagged, "debug");
|
|
673
|
+
sendLogLine(tagged);
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const sendSecurityCheckLine = (line) => {
|
|
677
|
+
const tagged = `[security-check] ${line}`;
|
|
678
|
+
log(tagged, "info");
|
|
501
679
|
sendLogLine(tagged);
|
|
502
680
|
};
|
|
503
681
|
|
|
504
682
|
const logApiCheckStatus = () => {
|
|
505
683
|
const maskedKey = openaiApiKey ? `${openaiApiKey.slice(0, 6)}...` : "";
|
|
684
|
+
const statusLabel =
|
|
685
|
+
apiEndpointFetchStatus === "error"
|
|
686
|
+
? `NG(fetch_failed${apiEndpointFetchDetail ? `: ${apiEndpointFetchDetail}` : ""})`
|
|
687
|
+
: apiEndpointFetchStatus === "ok_empty"
|
|
688
|
+
? "NG(empty)"
|
|
689
|
+
: apiEndpointCandidatesCached.length
|
|
690
|
+
? "OK"
|
|
691
|
+
: "NG(unknown)";
|
|
506
692
|
sendApiCheckLine(
|
|
507
|
-
`必須チェック: api_endpoint_list=${
|
|
508
|
-
|
|
509
|
-
}
|
|
693
|
+
`必須チェック: api_endpoint_list=${statusLabel} openai_api_key=${
|
|
694
|
+
openaiApiKey ? "OK" : "NG"
|
|
695
|
+
} ${
|
|
510
696
|
maskedKey ? `(${maskedKey})` : ""
|
|
511
697
|
} openai_model=${openaiModel ? "OK" : "NG"}`,
|
|
512
698
|
);
|
|
513
699
|
};
|
|
514
700
|
|
|
701
|
+
const logScreenCheckStatus = () => {
|
|
702
|
+
const statusLabel =
|
|
703
|
+
screenListFetchStatus === "error"
|
|
704
|
+
? `NG(fetch_failed${screenListFetchDetail ? `: ${screenListFetchDetail}` : ""})`
|
|
705
|
+
: screenListFetchStatus === "ok_empty"
|
|
706
|
+
? "NG(empty)"
|
|
707
|
+
: screenCandidatesCached.length
|
|
708
|
+
? "OK"
|
|
709
|
+
: "NG(unknown)";
|
|
710
|
+
sendScreenCheckLine(
|
|
711
|
+
`必須チェック: screen_list=${statusLabel}`,
|
|
712
|
+
);
|
|
713
|
+
};
|
|
714
|
+
|
|
515
715
|
const handleLine = (line) => {
|
|
516
|
-
|
|
517
|
-
type: "log",
|
|
518
|
-
content: line,
|
|
519
|
-
timestamp: new Date().toISOString(),
|
|
520
|
-
});
|
|
521
|
-
enqueueOrSend(payload);
|
|
716
|
+
sendLogLine(line);
|
|
522
717
|
if (isErrorLine(line)) {
|
|
523
718
|
const alertPayload = JSON.stringify({
|
|
524
719
|
type: "alert",
|
|
@@ -538,11 +733,21 @@ const emitProjectTree = () => {
|
|
|
538
733
|
projectTreeMaxBytes,
|
|
539
734
|
);
|
|
540
735
|
lines.forEach((line) => {
|
|
541
|
-
log(line);
|
|
736
|
+
log(line, "debug");
|
|
542
737
|
sendLogLine(line);
|
|
543
738
|
});
|
|
544
739
|
};
|
|
545
740
|
|
|
741
|
+
process.on("SIGINT", () => {
|
|
742
|
+
flushLogBuffer();
|
|
743
|
+
process.exit(0);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
process.on("SIGTERM", () => {
|
|
747
|
+
flushLogBuffer();
|
|
748
|
+
process.exit(0);
|
|
749
|
+
});
|
|
750
|
+
|
|
546
751
|
const buildApiCheckPrompt = (endpoints, treeLines) => {
|
|
547
752
|
return [
|
|
548
753
|
"You are a senior backend engineer. Determine which API endpoints are NOT implemented in the codebase.",
|
|
@@ -569,19 +774,25 @@ const collectTreeForApiCheck = () => {
|
|
|
569
774
|
};
|
|
570
775
|
|
|
571
776
|
const runApiCheck = async () => {
|
|
572
|
-
|
|
573
|
-
|
|
777
|
+
const allMissingResult = () => {
|
|
778
|
+
const items = apiEndpointCandidatesCached.map(String);
|
|
779
|
+
return { api_missing_count: items.length, api_missing_items: items, api_implemented_count: 0, api_implemented_items: [] };
|
|
780
|
+
};
|
|
781
|
+
if (!enableApiCheck) return allMissingResult();
|
|
782
|
+
if (!apiEndpointCandidatesCached.length) {
|
|
783
|
+
return { api_missing_count: 0, api_missing_items: [], api_implemented_count: 0, api_implemented_items: [] };
|
|
784
|
+
}
|
|
574
785
|
if (!openaiApiKey) {
|
|
575
|
-
sendApiCheckLine("OPENAI_API_KEY is not set;
|
|
576
|
-
return;
|
|
786
|
+
sendApiCheckLine("OPENAI_API_KEY is not set; すべて未実装として扱います。");
|
|
787
|
+
return allMissingResult();
|
|
577
788
|
}
|
|
578
789
|
if (!openaiModel) {
|
|
579
|
-
sendApiCheckLine("OPENAI_MODEL is not set;
|
|
580
|
-
return;
|
|
790
|
+
sendApiCheckLine("OPENAI_MODEL is not set; すべて未実装として扱います。");
|
|
791
|
+
return allMissingResult();
|
|
581
792
|
}
|
|
582
793
|
if (typeof fetch !== "function") {
|
|
583
|
-
sendApiCheckLine("fetch is not available in this runtime;
|
|
584
|
-
return;
|
|
794
|
+
sendApiCheckLine("fetch is not available in this runtime; すべて未実装として扱います。");
|
|
795
|
+
return allMissingResult();
|
|
585
796
|
}
|
|
586
797
|
const treeLines = collectTreeForApiCheck();
|
|
587
798
|
const prompt = buildApiCheckPrompt(apiEndpointCandidatesCached, treeLines);
|
|
@@ -609,7 +820,7 @@ const runApiCheck = async () => {
|
|
|
609
820
|
});
|
|
610
821
|
} catch (error) {
|
|
611
822
|
sendApiCheckLine(`OpenAI request failed: ${error}`);
|
|
612
|
-
return;
|
|
823
|
+
return allMissingResult();
|
|
613
824
|
}
|
|
614
825
|
if (!response.ok) {
|
|
615
826
|
const text = await response.text();
|
|
@@ -617,19 +828,19 @@ const runApiCheck = async () => {
|
|
|
617
828
|
`OpenAI request failed: ${response.status} ${response.statusText}`,
|
|
618
829
|
);
|
|
619
830
|
sendApiCheckLine(text.slice(0, 1000));
|
|
620
|
-
return;
|
|
831
|
+
return allMissingResult();
|
|
621
832
|
}
|
|
622
833
|
let data = null;
|
|
623
834
|
try {
|
|
624
835
|
data = await response.json();
|
|
625
836
|
} catch (error) {
|
|
626
837
|
sendApiCheckLine(`OpenAI response parse failed: ${error}`);
|
|
627
|
-
return;
|
|
838
|
+
return allMissingResult();
|
|
628
839
|
}
|
|
629
840
|
const content = data?.choices?.[0]?.message?.content?.trim();
|
|
630
841
|
if (!content) {
|
|
631
842
|
sendApiCheckLine("OpenAI response was empty.");
|
|
632
|
-
return;
|
|
843
|
+
return allMissingResult();
|
|
633
844
|
}
|
|
634
845
|
try {
|
|
635
846
|
const parsed = JSON.parse(content);
|
|
@@ -641,38 +852,68 @@ const runApiCheck = async () => {
|
|
|
641
852
|
sendApiCheckLine(
|
|
642
853
|
`missing=${missing.length} unknown=${unknown.length} implemented=${implemented.length}`,
|
|
643
854
|
);
|
|
855
|
+
const missingItems = [];
|
|
644
856
|
missing.forEach((item) => {
|
|
645
857
|
if (item && typeof item === "object") {
|
|
858
|
+
const label = item.endpoint || "";
|
|
859
|
+
missingItems.push(label);
|
|
646
860
|
sendApiCheckLine(
|
|
647
|
-
`missing: ${
|
|
861
|
+
`missing: ${label} (${item.reason || "no reason"})`,
|
|
648
862
|
);
|
|
649
863
|
} else {
|
|
864
|
+
missingItems.push(String(item));
|
|
650
865
|
sendApiCheckLine(`missing: ${String(item)}`);
|
|
651
866
|
}
|
|
652
867
|
});
|
|
868
|
+
const implementedItems = [];
|
|
653
869
|
unknown.forEach((item) => sendApiCheckLine(`unknown: ${String(item)}`));
|
|
654
|
-
implemented.forEach((item) =>
|
|
655
|
-
|
|
656
|
-
|
|
870
|
+
implemented.forEach((item) => {
|
|
871
|
+
implementedItems.push(String(item));
|
|
872
|
+
sendApiCheckLine(`implemented: ${String(item)}`);
|
|
873
|
+
});
|
|
874
|
+
return {
|
|
875
|
+
api_missing_count: missing.length,
|
|
876
|
+
api_missing_items: missingItems,
|
|
877
|
+
api_implemented_count: implemented.length,
|
|
878
|
+
api_implemented_items: implementedItems,
|
|
879
|
+
};
|
|
657
880
|
} catch (error) {
|
|
658
881
|
sendApiCheckLine("OpenAI output was not valid JSON. Raw output:");
|
|
659
882
|
sendApiCheckLine(content.slice(0, 2000));
|
|
883
|
+
return allMissingResult();
|
|
660
884
|
}
|
|
661
885
|
};
|
|
662
886
|
|
|
663
887
|
const screenCheckUrl = `${normalizeBaseHttpUrl(baseUrl)}/api/v1/project_snapshot/check`;
|
|
664
888
|
|
|
665
889
|
const runScreenCheck = async () => {
|
|
890
|
+
const allMissingResult = () => {
|
|
891
|
+
const items = screenCandidatesCached.map(toScreenName);
|
|
892
|
+
return { screen_missing_count: items.length, screen_missing_items: items, screen_implemented_count: 0, screen_implemented_items: [] };
|
|
893
|
+
};
|
|
666
894
|
if (!projectKey) {
|
|
667
|
-
sendScreenCheckLine("project_key is missing;
|
|
668
|
-
return;
|
|
895
|
+
sendScreenCheckLine("project_key is missing; すべて未実装として扱います。");
|
|
896
|
+
return allMissingResult();
|
|
669
897
|
}
|
|
670
898
|
if (typeof fetch !== "function") {
|
|
671
|
-
sendScreenCheckLine("fetch is not available in this runtime;
|
|
672
|
-
return;
|
|
899
|
+
sendScreenCheckLine("fetch is not available in this runtime; すべて未実装として扱います。");
|
|
900
|
+
return allMissingResult();
|
|
673
901
|
}
|
|
674
|
-
const paths = collectSnapshotPaths(projectRoot, DEFAULT_SNAPSHOT_EXCLUDES)
|
|
902
|
+
const paths = collectSnapshotPaths(projectRoot, DEFAULT_SNAPSHOT_EXCLUDES).filter(
|
|
903
|
+
(item) => item.startsWith("frontend/"),
|
|
904
|
+
);
|
|
675
905
|
const limitedPaths = paths.slice(0, projectTreeMaxFiles);
|
|
906
|
+
if (!limitedPaths.length) {
|
|
907
|
+
sendScreenCheckLine("frontend/ 配下のファイルが見つからないため、すべて未実装として扱います。");
|
|
908
|
+
const allMissing = screenCandidatesCached.map(toScreenName);
|
|
909
|
+
allMissing.forEach((item) => sendScreenCheckLine(`missing: ${item}`));
|
|
910
|
+
return {
|
|
911
|
+
screen_missing_count: allMissing.length,
|
|
912
|
+
screen_missing_items: allMissing,
|
|
913
|
+
screen_implemented_count: 0,
|
|
914
|
+
screen_implemented_items: [],
|
|
915
|
+
};
|
|
916
|
+
}
|
|
676
917
|
const payload = {
|
|
677
918
|
projectPublicId: projectKey,
|
|
678
919
|
paths: limitedPaths,
|
|
@@ -686,7 +927,7 @@ const runScreenCheck = async () => {
|
|
|
686
927
|
});
|
|
687
928
|
} catch (error) {
|
|
688
929
|
sendScreenCheckLine(`screen check request failed: ${error}`);
|
|
689
|
-
return;
|
|
930
|
+
return allMissingResult();
|
|
690
931
|
}
|
|
691
932
|
if (!response.ok) {
|
|
692
933
|
const text = await response.text();
|
|
@@ -694,19 +935,24 @@ const runScreenCheck = async () => {
|
|
|
694
935
|
`screen check failed: ${response.status} ${response.statusText}`,
|
|
695
936
|
);
|
|
696
937
|
sendScreenCheckLine(text.slice(0, 1000));
|
|
697
|
-
return;
|
|
938
|
+
return allMissingResult();
|
|
698
939
|
}
|
|
699
940
|
let data = null;
|
|
700
941
|
try {
|
|
701
942
|
data = await response.json();
|
|
702
943
|
} catch (error) {
|
|
703
944
|
sendScreenCheckLine(`screen check response parse failed: ${error}`);
|
|
704
|
-
return;
|
|
945
|
+
return allMissingResult();
|
|
705
946
|
}
|
|
706
|
-
const expected =
|
|
707
|
-
|
|
947
|
+
const expected = screenCandidatesCached.length
|
|
948
|
+
? screenCandidatesCached.map(toScreenName)
|
|
949
|
+
: (Array.isArray(data?.expectedScreens) ? data.expectedScreens : []);
|
|
950
|
+
const rawMissing = Array.isArray(data?.check?.missingScreens)
|
|
708
951
|
? data.check.missingScreens
|
|
709
952
|
: [];
|
|
953
|
+
// expected をキャッシュに揃えたので、missing も expected に含まれるもののみに絞る
|
|
954
|
+
const expectedSet = new Set(expected.map(String));
|
|
955
|
+
const missing = rawMissing.filter((item) => expectedSet.has(String(item)));
|
|
710
956
|
const implemented = expected.filter((item) => !missing.includes(item));
|
|
711
957
|
sendScreenCheckLine(
|
|
712
958
|
`missing_screens=${missing.length} expected=${expected.length} implemented=${implemented.length}`,
|
|
@@ -718,10 +964,17 @@ const runScreenCheck = async () => {
|
|
|
718
964
|
if (data?.check?.notes) {
|
|
719
965
|
sendScreenCheckLine(`note: ${String(data.check.notes)}`);
|
|
720
966
|
}
|
|
967
|
+
return {
|
|
968
|
+
screen_missing_count: missing.length,
|
|
969
|
+
screen_missing_items: missing.map(String),
|
|
970
|
+
screen_implemented_count: implemented.length,
|
|
971
|
+
screen_implemented_items: implemented.map(String),
|
|
972
|
+
};
|
|
721
973
|
};
|
|
722
974
|
|
|
723
975
|
const triggerApiCheck = async (reason) => {
|
|
724
|
-
|
|
976
|
+
const emptyResult = { api_missing_count: 0, api_missing_items: [], api_implemented_count: 0, api_implemented_items: [] };
|
|
977
|
+
if (apiCheckInFlight) return emptyResult;
|
|
725
978
|
apiCheckInFlight = true;
|
|
726
979
|
try {
|
|
727
980
|
sendApiCheckLine(`api-check start (${reason})`);
|
|
@@ -731,27 +984,292 @@ const triggerApiCheck = async (reason) => {
|
|
|
731
984
|
logApiCheckStatus();
|
|
732
985
|
if (!apiEndpointCandidatesCached.length) {
|
|
733
986
|
sendApiCheckLine("api_endpoint_list is empty; skipping api-check.");
|
|
734
|
-
return;
|
|
987
|
+
return emptyResult;
|
|
735
988
|
}
|
|
736
|
-
await runApiCheck();
|
|
989
|
+
return await runApiCheck();
|
|
737
990
|
} finally {
|
|
738
991
|
apiCheckInFlight = false;
|
|
739
992
|
}
|
|
740
993
|
};
|
|
741
994
|
|
|
742
995
|
const triggerScreenCheck = async (reason) => {
|
|
996
|
+
const emptyResult = { screen_missing_count: 0, screen_missing_items: [], screen_implemented_count: 0, screen_implemented_items: [] };
|
|
743
997
|
try {
|
|
744
998
|
sendScreenCheckLine(`screen-check start (${reason})`);
|
|
745
|
-
|
|
999
|
+
if (!screenCandidatesCached.length) {
|
|
1000
|
+
await fetchScreenList();
|
|
1001
|
+
}
|
|
1002
|
+
logScreenCheckStatus();
|
|
1003
|
+
if (!screenCandidatesCached.length) {
|
|
1004
|
+
sendScreenCheckLine("screen_list is empty; skipping screen-check.");
|
|
1005
|
+
return emptyResult;
|
|
1006
|
+
}
|
|
1007
|
+
return await runScreenCheck();
|
|
746
1008
|
} catch (error) {
|
|
747
1009
|
sendScreenCheckLine(`screen-check failed: ${error}`);
|
|
1010
|
+
return emptyResult;
|
|
748
1011
|
}
|
|
749
1012
|
};
|
|
750
1013
|
|
|
1014
|
+
// ---------------------------------------------------------------------------
|
|
1015
|
+
// Security Check (static regex-based)
|
|
1016
|
+
// ---------------------------------------------------------------------------
|
|
1017
|
+
|
|
1018
|
+
const SECURITY_RULES = [
|
|
1019
|
+
{
|
|
1020
|
+
id: "raw_sql",
|
|
1021
|
+
label: "SQLインジェクション",
|
|
1022
|
+
pattern: /(?:execute\s*\(\s*f["']|\.raw\s*\(|cursor\.execute\s*\(\s*f["'])/,
|
|
1023
|
+
extensions: new Set([".py", ".js", ".ts"]),
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
id: "xss",
|
|
1027
|
+
label: "XSS",
|
|
1028
|
+
pattern: /(?:dangerouslySetInnerHTML|v-html)/,
|
|
1029
|
+
extensions: new Set([".js", ".jsx", ".ts", ".tsx", ".vue"]),
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
id: "hardcoded_secrets",
|
|
1033
|
+
label: "秘密情報ハードコード",
|
|
1034
|
+
pattern: /(?:api_key|apikey|secret_key|password|aws_secret)\s*=\s*["'][^"']{4,}["']/i,
|
|
1035
|
+
extensions: new Set([".py", ".js", ".ts", ".env", ".yaml", ".yml", ".json"]),
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
id: "stack_trace_leak",
|
|
1039
|
+
label: "スタックトレース漏洩",
|
|
1040
|
+
pattern: /(?:traceback\.format_exc|import\s+traceback)/,
|
|
1041
|
+
extensions: new Set([".py"]),
|
|
1042
|
+
},
|
|
1043
|
+
{
|
|
1044
|
+
id: "log_secrets",
|
|
1045
|
+
label: "ログ秘密情報",
|
|
1046
|
+
pattern: /(?:logger\.\w+.*(?:password|token|secret)|print\s*\(.*(?:password|token|secret))/i,
|
|
1047
|
+
extensions: new Set([".py", ".js", ".ts"]),
|
|
1048
|
+
},
|
|
1049
|
+
{
|
|
1050
|
+
id: "cors_wildcard",
|
|
1051
|
+
label: "CORSワイルドカード",
|
|
1052
|
+
pattern: /allow_origins\s*=\s*\[\s*["']\*["']\s*\]/,
|
|
1053
|
+
extensions: new Set([".py"]),
|
|
1054
|
+
},
|
|
1055
|
+
];
|
|
1056
|
+
|
|
1057
|
+
const SECURITY_EXTENSIONS = new Set([".py", ".js", ".jsx", ".ts", ".tsx", ".vue", ".yaml", ".yml", ".json"]);
|
|
1058
|
+
|
|
1059
|
+
const collectSecurityTargetPaths = (rootDir, excludes) => {
|
|
1060
|
+
const results = [];
|
|
1061
|
+
const queue = [rootDir];
|
|
1062
|
+
while (queue.length) {
|
|
1063
|
+
const current = queue.pop();
|
|
1064
|
+
if (!current) continue;
|
|
1065
|
+
let entries = [];
|
|
1066
|
+
try {
|
|
1067
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
for (const entry of entries) {
|
|
1072
|
+
const fullPath = path.join(current, entry.name);
|
|
1073
|
+
const relPath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
|
|
1074
|
+
if (isExcludedPath(relPath, entry.name, excludes)) continue;
|
|
1075
|
+
if (entry.isSymbolicLink && entry.isSymbolicLink()) continue;
|
|
1076
|
+
if (entry.isDirectory()) {
|
|
1077
|
+
queue.push(fullPath);
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
if (entry.isFile()) {
|
|
1081
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
1082
|
+
if (SECURITY_EXTENSIONS.has(ext)) {
|
|
1083
|
+
results.push({ relPath, fullPath, ext });
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return results;
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
const SECURITY_CHECK_STEP_DELAY_MS = 400;
|
|
1092
|
+
const SECURITY_DETAIL_MAX_MATCHES = 5;
|
|
1093
|
+
|
|
1094
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1095
|
+
|
|
1096
|
+
const collectRuleMatches = (rule, files) => {
|
|
1097
|
+
const matches = [];
|
|
1098
|
+
for (const file of files) {
|
|
1099
|
+
if (!rule.extensions.has(file.ext)) continue;
|
|
1100
|
+
let content = "";
|
|
1101
|
+
try {
|
|
1102
|
+
const buffer = fs.readFileSync(file.fullPath);
|
|
1103
|
+
if (isBinaryBuffer(buffer)) continue;
|
|
1104
|
+
content = buffer.toString("utf8");
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
const lines = content.split("\n");
|
|
1109
|
+
const re = new RegExp(rule.pattern.source, rule.pattern.flags);
|
|
1110
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1111
|
+
if (!re.test(lines[i])) continue;
|
|
1112
|
+
const contextStart = Math.max(0, i - 3);
|
|
1113
|
+
const contextEnd = Math.min(lines.length, i + 4);
|
|
1114
|
+
matches.push({
|
|
1115
|
+
file: file.relPath,
|
|
1116
|
+
line: i + 1,
|
|
1117
|
+
code: lines[i].trim(),
|
|
1118
|
+
context: lines.slice(contextStart, contextEnd).join("\n"),
|
|
1119
|
+
});
|
|
1120
|
+
if (matches.length >= SECURITY_DETAIL_MAX_MATCHES) return matches;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return matches;
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
const runSecurityCheck = async () => {
|
|
1127
|
+
const files = collectSecurityTargetPaths(projectRoot, DEFAULT_SNAPSHOT_EXCLUDES);
|
|
1128
|
+
const issueMap = {}; // id -> count
|
|
1129
|
+
const passedIds = [];
|
|
1130
|
+
const issueDetails = []; // { id, label, count }
|
|
1131
|
+
const total = SECURITY_RULES.length + 1; // +1 for .env check
|
|
1132
|
+
|
|
1133
|
+
// Check each rule
|
|
1134
|
+
let ruleIndex = 0;
|
|
1135
|
+
for (const rule of SECURITY_RULES) {
|
|
1136
|
+
ruleIndex += 1;
|
|
1137
|
+
// Collect detailed match info
|
|
1138
|
+
const ruleMatches = collectRuleMatches(rule, files);
|
|
1139
|
+
const hitCount = ruleMatches.length;
|
|
1140
|
+
if (hitCount > 0) {
|
|
1141
|
+
issueMap[rule.id] = hitCount;
|
|
1142
|
+
issueDetails.push({ id: rule.id, label: rule.label, count: hitCount });
|
|
1143
|
+
sendSecurityCheckLine(`progress ${ruleIndex}/${total} ${rule.id} issue ${hitCount}`);
|
|
1144
|
+
// Send detail line with match locations
|
|
1145
|
+
sendSecurityCheckLine(`detail ${JSON.stringify({ rule_id: rule.id, label: rule.label, matches: ruleMatches })}`);
|
|
1146
|
+
} else {
|
|
1147
|
+
passedIds.push(rule.id);
|
|
1148
|
+
sendSecurityCheckLine(`progress ${ruleIndex}/${total} ${rule.id} passed`);
|
|
1149
|
+
}
|
|
1150
|
+
// 1項目ずつ送信してバルーン更新を可視化
|
|
1151
|
+
flushLogBuffer();
|
|
1152
|
+
await sleep(SECURITY_CHECK_STEP_DELAY_MS);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Check .env in .gitignore
|
|
1156
|
+
let envGitignored = false;
|
|
1157
|
+
try {
|
|
1158
|
+
const gitignore = fs.readFileSync(path.join(projectRoot, ".gitignore"), "utf8");
|
|
1159
|
+
envGitignored = /^\.env$/m.test(gitignore);
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
// no .gitignore
|
|
1162
|
+
}
|
|
1163
|
+
if (!envGitignored) {
|
|
1164
|
+
issueDetails.push({ id: "env_not_gitignored", label: ".env未gitignore", count: 1 });
|
|
1165
|
+
issueMap["env_not_gitignored"] = 1;
|
|
1166
|
+
sendSecurityCheckLine(`progress ${total}/${total} env_not_gitignored issue 1`);
|
|
1167
|
+
} else {
|
|
1168
|
+
passedIds.push("env_not_gitignored");
|
|
1169
|
+
sendSecurityCheckLine(`progress ${total}/${total} env_not_gitignored passed`);
|
|
1170
|
+
}
|
|
1171
|
+
flushLogBuffer();
|
|
1172
|
+
|
|
1173
|
+
return {
|
|
1174
|
+
security_issue_count: issueDetails.length,
|
|
1175
|
+
security_issue_items: issueDetails.map((d) => `${d.id} (${d.count}箇所)`),
|
|
1176
|
+
security_passed_count: passedIds.length,
|
|
1177
|
+
security_passed_items: passedIds,
|
|
1178
|
+
};
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
const triggerSecurityCheck = async (reason) => {
|
|
1182
|
+
sendSecurityCheckLine(`security-check start (${reason})`);
|
|
1183
|
+
flushLogBuffer();
|
|
1184
|
+
const result = await runSecurityCheck();
|
|
1185
|
+
|
|
1186
|
+
// Emit detail lines
|
|
1187
|
+
for (const item of result.security_issue_items) {
|
|
1188
|
+
sendSecurityCheckLine(`issue: ${item}`);
|
|
1189
|
+
}
|
|
1190
|
+
for (const id of result.security_passed_items) {
|
|
1191
|
+
sendSecurityCheckLine(`passed: ${id}`);
|
|
1192
|
+
}
|
|
1193
|
+
sendSecurityCheckLine(
|
|
1194
|
+
`issues=${result.security_issue_count} passed=${result.security_passed_count}`,
|
|
1195
|
+
);
|
|
1196
|
+
return result;
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
const triggerCodeAnalysis = async (reason, role = null) => {
|
|
1200
|
+
const runApi = !role || role === "api";
|
|
1201
|
+
const runScreen = !role || role === "screen";
|
|
1202
|
+
|
|
1203
|
+
const steps = [];
|
|
1204
|
+
if (runApi) {
|
|
1205
|
+
const apiCount = apiEndpointCandidatesCached.length;
|
|
1206
|
+
const apiSuffix = apiCount ? ` (対象: ${apiCount}件)` : "";
|
|
1207
|
+
steps.push({ label: `API実装チェック中...${apiSuffix}`, fn: () => triggerApiCheck(`code-analysis:${reason}`) });
|
|
1208
|
+
}
|
|
1209
|
+
if (runScreen && enableScreenCheck) {
|
|
1210
|
+
const screenCount = screenCandidatesCached.length;
|
|
1211
|
+
const screenSuffix = screenCount ? ` (対象: ${screenCount}件)` : "";
|
|
1212
|
+
steps.push({ label: `画面実装チェック中...${screenSuffix}`, fn: () => triggerScreenCheck(`code-analysis:${reason}`) });
|
|
1213
|
+
}
|
|
1214
|
+
// Security check is always included
|
|
1215
|
+
steps.push({ label: "セキュリティチェック中...", fn: async () => triggerSecurityCheck(`code-analysis:${reason}`) });
|
|
1216
|
+
|
|
1217
|
+
const totalSteps = steps.length;
|
|
1218
|
+
let currentStep = 0;
|
|
1219
|
+
|
|
1220
|
+
sendCodeAnalysisLine(`code-analysis start (${reason})`);
|
|
1221
|
+
|
|
1222
|
+
// 各ステップの結果を収集
|
|
1223
|
+
const summary = {
|
|
1224
|
+
api_missing_count: 0, api_missing_items: [], api_implemented_count: 0, api_implemented_items: [],
|
|
1225
|
+
screen_missing_count: 0, screen_missing_items: [], screen_implemented_count: 0, screen_implemented_items: [],
|
|
1226
|
+
security_issue_count: 0, security_issue_items: [], security_passed_count: 0, security_passed_items: [],
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
for (const step of steps) {
|
|
1230
|
+
currentStep += 1;
|
|
1231
|
+
sendCodeAnalysisLine(`[${currentStep}/${totalSteps}] ${step.label}`);
|
|
1232
|
+
const result = await step.fn();
|
|
1233
|
+
if (result) {
|
|
1234
|
+
Object.assign(summary, result);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const apiExpected = apiEndpointCandidatesCached.map(String);
|
|
1239
|
+
const screenExpected = screenCandidatesCached.map(toScreenName);
|
|
1240
|
+
const apiResultEmpty =
|
|
1241
|
+
summary.api_missing_count === 0 &&
|
|
1242
|
+
summary.api_implemented_count === 0 &&
|
|
1243
|
+
!summary.api_missing_items.length &&
|
|
1244
|
+
!summary.api_implemented_items.length;
|
|
1245
|
+
const screenResultEmpty =
|
|
1246
|
+
summary.screen_missing_count === 0 &&
|
|
1247
|
+
summary.screen_implemented_count === 0 &&
|
|
1248
|
+
!summary.screen_missing_items.length &&
|
|
1249
|
+
!summary.screen_implemented_items.length;
|
|
1250
|
+
|
|
1251
|
+
if (runApi && apiExpected.length && apiResultEmpty) {
|
|
1252
|
+
summary.api_missing_count = apiExpected.length;
|
|
1253
|
+
summary.api_missing_items = apiExpected;
|
|
1254
|
+
summary.api_check_inferred = true;
|
|
1255
|
+
}
|
|
1256
|
+
if (runScreen && enableScreenCheck && screenExpected.length && screenResultEmpty) {
|
|
1257
|
+
summary.screen_missing_count = screenExpected.length;
|
|
1258
|
+
summary.screen_missing_items = screenExpected;
|
|
1259
|
+
summary.screen_check_inferred = true;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// done 行にサマリー JSON を埋め込む(バックエンドが確実にパースできるように)
|
|
1263
|
+
flushLogBuffer();
|
|
1264
|
+
sendCodeAnalysisLine(`done ${JSON.stringify(summary)}`);
|
|
1265
|
+
};
|
|
1266
|
+
|
|
751
1267
|
const fetchApiEndpointList = async () => {
|
|
752
1268
|
if (!apiEndpointUrl) return [];
|
|
753
1269
|
if (typeof fetch !== "function") {
|
|
754
1270
|
sendApiCheckLine("fetch is not available in this runtime; skipping fetch.");
|
|
1271
|
+
apiEndpointFetchStatus = "error";
|
|
1272
|
+
apiEndpointFetchDetail = "fetch_unavailable";
|
|
755
1273
|
return [];
|
|
756
1274
|
}
|
|
757
1275
|
try {
|
|
@@ -770,6 +1288,8 @@ const fetchApiEndpointList = async () => {
|
|
|
770
1288
|
sendApiCheckLine(
|
|
771
1289
|
`api_endpoint_list取得失敗: ${response.status} ${response.statusText} (${apiEndpointUrl})${detailSnippet}`,
|
|
772
1290
|
);
|
|
1291
|
+
apiEndpointFetchStatus = "error";
|
|
1292
|
+
apiEndpointFetchDetail = `${response.status}`;
|
|
773
1293
|
return [];
|
|
774
1294
|
}
|
|
775
1295
|
const data = await response.json();
|
|
@@ -777,17 +1297,90 @@ const fetchApiEndpointList = async () => {
|
|
|
777
1297
|
apiEndpointCandidatesCached = extractApiEndpointItems(
|
|
778
1298
|
data.api_endpoint_list,
|
|
779
1299
|
);
|
|
1300
|
+
apiEndpointFetchStatus = apiEndpointCandidatesCached.length
|
|
1301
|
+
? "ok"
|
|
1302
|
+
: "ok_empty";
|
|
1303
|
+
apiEndpointFetchDetail = "";
|
|
780
1304
|
return apiEndpointCandidatesCached;
|
|
781
1305
|
}
|
|
782
1306
|
if (Array.isArray(data?.endpoints)) {
|
|
783
1307
|
apiEndpointCandidatesCached = extractApiEndpointItems(
|
|
784
1308
|
JSON.stringify({ endpoints: data.endpoints }),
|
|
785
1309
|
);
|
|
1310
|
+
apiEndpointFetchStatus = apiEndpointCandidatesCached.length
|
|
1311
|
+
? "ok"
|
|
1312
|
+
: "ok_empty";
|
|
1313
|
+
apiEndpointFetchDetail = "";
|
|
786
1314
|
return apiEndpointCandidatesCached;
|
|
787
1315
|
}
|
|
1316
|
+
apiEndpointFetchStatus = "ok_empty";
|
|
1317
|
+
apiEndpointFetchDetail = "";
|
|
788
1318
|
return [];
|
|
789
1319
|
} catch (error) {
|
|
790
1320
|
sendApiCheckLine(`api_endpoint_list fetch error: ${error}`);
|
|
1321
|
+
apiEndpointFetchStatus = "error";
|
|
1322
|
+
apiEndpointFetchDetail = "exception";
|
|
1323
|
+
return [];
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
const fetchScreenList = async () => {
|
|
1328
|
+
if (!screenListUrl) return [];
|
|
1329
|
+
if (typeof fetch !== "function") {
|
|
1330
|
+
sendScreenCheckLine("fetch is not available in this runtime; skipping fetch.");
|
|
1331
|
+
screenListFetchStatus = "error";
|
|
1332
|
+
screenListFetchDetail = "fetch_unavailable";
|
|
1333
|
+
return [];
|
|
1334
|
+
}
|
|
1335
|
+
try {
|
|
1336
|
+
const response = await fetch(screenListUrl, {
|
|
1337
|
+
method: "GET",
|
|
1338
|
+
headers: { "Content-Type": "application/json" },
|
|
1339
|
+
});
|
|
1340
|
+
if (!response.ok) {
|
|
1341
|
+
let detail = "";
|
|
1342
|
+
try {
|
|
1343
|
+
detail = await response.text();
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
detail = "";
|
|
1346
|
+
}
|
|
1347
|
+
const detailSnippet = detail ? ` detail=${detail.slice(0, 200)}` : "";
|
|
1348
|
+
sendScreenCheckLine(
|
|
1349
|
+
`screen_list取得失敗: ${response.status} ${response.statusText} (${screenListUrl})${detailSnippet}`,
|
|
1350
|
+
);
|
|
1351
|
+
screenListFetchStatus = "error";
|
|
1352
|
+
screenListFetchDetail = `${response.status}`;
|
|
1353
|
+
return [];
|
|
1354
|
+
}
|
|
1355
|
+
const data = await response.json();
|
|
1356
|
+
if (data?.screen_list) {
|
|
1357
|
+
const raw = Array.isArray(data?.screens) ? data.screens : [];
|
|
1358
|
+
screenCandidatesCached = raw.map((item) =>
|
|
1359
|
+
typeof item === "object" && item !== null ? item.name || String(item) : String(item)
|
|
1360
|
+
);
|
|
1361
|
+
screenListFetchStatus = screenCandidatesCached.length
|
|
1362
|
+
? "ok"
|
|
1363
|
+
: "ok_empty";
|
|
1364
|
+
screenListFetchDetail = "";
|
|
1365
|
+
return screenCandidatesCached;
|
|
1366
|
+
}
|
|
1367
|
+
if (Array.isArray(data?.screens)) {
|
|
1368
|
+
screenCandidatesCached = data.screens.map((item) =>
|
|
1369
|
+
typeof item === "object" && item !== null ? item.name || String(item) : String(item)
|
|
1370
|
+
);
|
|
1371
|
+
screenListFetchStatus = screenCandidatesCached.length
|
|
1372
|
+
? "ok"
|
|
1373
|
+
: "ok_empty";
|
|
1374
|
+
screenListFetchDetail = "";
|
|
1375
|
+
return screenCandidatesCached;
|
|
1376
|
+
}
|
|
1377
|
+
screenListFetchStatus = "ok_empty";
|
|
1378
|
+
screenListFetchDetail = "";
|
|
1379
|
+
return [];
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
sendScreenCheckLine(`screen_list fetch error: ${error}`);
|
|
1382
|
+
screenListFetchStatus = "error";
|
|
1383
|
+
screenListFetchDetail = "exception";
|
|
791
1384
|
return [];
|
|
792
1385
|
}
|
|
793
1386
|
};
|
|
@@ -802,7 +1395,7 @@ const startFileTailer = (targetPath) => {
|
|
|
802
1395
|
fileOffset = stats.size;
|
|
803
1396
|
return true;
|
|
804
1397
|
} catch (error) {
|
|
805
|
-
log(`log-path not found: ${targetPath} (retrying...)
|
|
1398
|
+
log(`log-path not found: ${targetPath} (retrying...)`, "warn");
|
|
806
1399
|
return false;
|
|
807
1400
|
}
|
|
808
1401
|
};
|
|
@@ -821,7 +1414,7 @@ const startFileTailer = (targetPath) => {
|
|
|
821
1414
|
const readNewData = () => {
|
|
822
1415
|
fs.stat(targetPath, (error, stats) => {
|
|
823
1416
|
if (error) {
|
|
824
|
-
log(`log-path read error: ${error.message || String(error)}
|
|
1417
|
+
log(`log-path read error: ${error.message || String(error)}`, "warn");
|
|
825
1418
|
return;
|
|
826
1419
|
}
|
|
827
1420
|
if (stats.size < fileOffset) {
|
|
@@ -844,6 +1437,7 @@ const startFileTailer = (targetPath) => {
|
|
|
844
1437
|
`log-path stream error: ${
|
|
845
1438
|
streamError.message || String(streamError)
|
|
846
1439
|
}`,
|
|
1440
|
+
"warn",
|
|
847
1441
|
);
|
|
848
1442
|
});
|
|
849
1443
|
});
|
|
@@ -857,7 +1451,7 @@ const startFileTailer = (targetPath) => {
|
|
|
857
1451
|
}
|
|
858
1452
|
});
|
|
859
1453
|
} catch (error) {
|
|
860
|
-
log(`log-path watch error: ${error.message || String(error)}
|
|
1454
|
+
log(`log-path watch error: ${error.message || String(error)}`, "warn");
|
|
861
1455
|
}
|
|
862
1456
|
};
|
|
863
1457
|
|
|
@@ -874,21 +1468,23 @@ const startFileTailer = (targetPath) => {
|
|
|
874
1468
|
startWatcher();
|
|
875
1469
|
};
|
|
876
1470
|
|
|
877
|
-
if (
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1471
|
+
if (runMode !== "security") {
|
|
1472
|
+
if (logPath) {
|
|
1473
|
+
startFileTailer(logPath);
|
|
1474
|
+
} else {
|
|
1475
|
+
const rl = readline.createInterface({
|
|
1476
|
+
input: process.stdin,
|
|
1477
|
+
crlfDelay: Infinity,
|
|
1478
|
+
});
|
|
884
1479
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1480
|
+
rl.on("line", (line) => {
|
|
1481
|
+
handleLine(line);
|
|
1482
|
+
});
|
|
888
1483
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1484
|
+
rl.on("close", () => {
|
|
1485
|
+
if (socket) {
|
|
1486
|
+
socket.close();
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
894
1490
|
}
|