coderail-watch 0.1.8 → 0.1.12
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 +699 -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,8 +600,33 @@ const connect = () => {
|
|
|
456
600
|
? error.message
|
|
457
601
|
: String(error);
|
|
458
602
|
lastErrorMessage = message;
|
|
459
|
-
log(`error: ${message}
|
|
603
|
+
log(`error: ${message}`, "warn");
|
|
604
|
+
if (error && error.code) {
|
|
605
|
+
log(` code: ${error.code}`, "warn");
|
|
606
|
+
}
|
|
607
|
+
if (error && error.errno) {
|
|
608
|
+
log(` errno: ${error.errno}`, "warn");
|
|
609
|
+
}
|
|
460
610
|
});
|
|
611
|
+
|
|
612
|
+
// ws library emits 'unexpected-response' when server returns non-101 (e.g. 403)
|
|
613
|
+
if (typeof socket.on === "function") {
|
|
614
|
+
socket.on("unexpected-response", (req, res) => {
|
|
615
|
+
const status = res.statusCode;
|
|
616
|
+
let body = "";
|
|
617
|
+
res.on("data", (chunk) => { body += chunk.toString(); });
|
|
618
|
+
res.on("end", () => {
|
|
619
|
+
log(`server rejected WebSocket upgrade: HTTP ${status}`, "error");
|
|
620
|
+
if (body) {
|
|
621
|
+
log(` response body: ${body.slice(0, 500)}`, "error");
|
|
622
|
+
}
|
|
623
|
+
const headers = res.headers || {};
|
|
624
|
+
if (Object.keys(headers).length) {
|
|
625
|
+
log(` response headers: ${JSON.stringify(headers)}`, "error");
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
}
|
|
461
630
|
};
|
|
462
631
|
|
|
463
632
|
connect();
|
|
@@ -480,45 +649,96 @@ const enqueueOrSend = (payload) => {
|
|
|
480
649
|
queue.push(payload);
|
|
481
650
|
};
|
|
482
651
|
|
|
483
|
-
const
|
|
652
|
+
const flushLogBuffer = () => {
|
|
653
|
+
if (logFlushTimer) {
|
|
654
|
+
clearTimeout(logFlushTimer);
|
|
655
|
+
logFlushTimer = null;
|
|
656
|
+
}
|
|
657
|
+
if (!logBuffer.length) return;
|
|
658
|
+
const lines = logBuffer.splice(0, logBuffer.length);
|
|
484
659
|
const payload = JSON.stringify({
|
|
485
660
|
type: "log",
|
|
486
|
-
content:
|
|
661
|
+
content: lines.join("\n"),
|
|
487
662
|
timestamp: new Date().toISOString(),
|
|
488
663
|
});
|
|
489
664
|
enqueueOrSend(payload);
|
|
490
665
|
};
|
|
491
666
|
|
|
667
|
+
const enqueueLogLine = (line) => {
|
|
668
|
+
if (!line) return;
|
|
669
|
+
logBuffer.push(line);
|
|
670
|
+
if (logBatchMs === 0 || logBuffer.length >= logBatchMaxLines) {
|
|
671
|
+
flushLogBuffer();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (!logFlushTimer) {
|
|
675
|
+
logFlushTimer = setTimeout(flushLogBuffer, logBatchMs);
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const sendLogLine = (line) => {
|
|
680
|
+
enqueueLogLine(line);
|
|
681
|
+
};
|
|
682
|
+
|
|
492
683
|
const sendApiCheckLine = (line) => {
|
|
493
684
|
const tagged = `[api-check] ${line}`;
|
|
494
|
-
log(tagged);
|
|
685
|
+
log(tagged, "debug");
|
|
686
|
+
sendLogLine(tagged);
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const sendCodeAnalysisLine = (line) => {
|
|
690
|
+
const tagged = `[code-analysis] ${line}`;
|
|
691
|
+
log(tagged, "info");
|
|
495
692
|
sendLogLine(tagged);
|
|
496
693
|
};
|
|
497
694
|
|
|
498
695
|
const sendScreenCheckLine = (line) => {
|
|
499
696
|
const tagged = `[screen-check] ${line}`;
|
|
500
|
-
log(tagged);
|
|
697
|
+
log(tagged, "debug");
|
|
698
|
+
sendLogLine(tagged);
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const sendSecurityCheckLine = (line) => {
|
|
702
|
+
const tagged = `[security-check] ${line}`;
|
|
703
|
+
log(tagged, "info");
|
|
501
704
|
sendLogLine(tagged);
|
|
502
705
|
};
|
|
503
706
|
|
|
504
707
|
const logApiCheckStatus = () => {
|
|
505
708
|
const maskedKey = openaiApiKey ? `${openaiApiKey.slice(0, 6)}...` : "";
|
|
709
|
+
const statusLabel =
|
|
710
|
+
apiEndpointFetchStatus === "error"
|
|
711
|
+
? `NG(fetch_failed${apiEndpointFetchDetail ? `: ${apiEndpointFetchDetail}` : ""})`
|
|
712
|
+
: apiEndpointFetchStatus === "ok_empty"
|
|
713
|
+
? "NG(empty)"
|
|
714
|
+
: apiEndpointCandidatesCached.length
|
|
715
|
+
? "OK"
|
|
716
|
+
: "NG(unknown)";
|
|
506
717
|
sendApiCheckLine(
|
|
507
|
-
`必須チェック: api_endpoint_list=${
|
|
508
|
-
|
|
509
|
-
}
|
|
718
|
+
`必須チェック: api_endpoint_list=${statusLabel} openai_api_key=${
|
|
719
|
+
openaiApiKey ? "OK" : "NG"
|
|
720
|
+
} ${
|
|
510
721
|
maskedKey ? `(${maskedKey})` : ""
|
|
511
722
|
} openai_model=${openaiModel ? "OK" : "NG"}`,
|
|
512
723
|
);
|
|
513
724
|
};
|
|
514
725
|
|
|
726
|
+
const logScreenCheckStatus = () => {
|
|
727
|
+
const statusLabel =
|
|
728
|
+
screenListFetchStatus === "error"
|
|
729
|
+
? `NG(fetch_failed${screenListFetchDetail ? `: ${screenListFetchDetail}` : ""})`
|
|
730
|
+
: screenListFetchStatus === "ok_empty"
|
|
731
|
+
? "NG(empty)"
|
|
732
|
+
: screenCandidatesCached.length
|
|
733
|
+
? "OK"
|
|
734
|
+
: "NG(unknown)";
|
|
735
|
+
sendScreenCheckLine(
|
|
736
|
+
`必須チェック: screen_list=${statusLabel}`,
|
|
737
|
+
);
|
|
738
|
+
};
|
|
739
|
+
|
|
515
740
|
const handleLine = (line) => {
|
|
516
|
-
|
|
517
|
-
type: "log",
|
|
518
|
-
content: line,
|
|
519
|
-
timestamp: new Date().toISOString(),
|
|
520
|
-
});
|
|
521
|
-
enqueueOrSend(payload);
|
|
741
|
+
sendLogLine(line);
|
|
522
742
|
if (isErrorLine(line)) {
|
|
523
743
|
const alertPayload = JSON.stringify({
|
|
524
744
|
type: "alert",
|
|
@@ -538,11 +758,21 @@ const emitProjectTree = () => {
|
|
|
538
758
|
projectTreeMaxBytes,
|
|
539
759
|
);
|
|
540
760
|
lines.forEach((line) => {
|
|
541
|
-
log(line);
|
|
761
|
+
log(line, "debug");
|
|
542
762
|
sendLogLine(line);
|
|
543
763
|
});
|
|
544
764
|
};
|
|
545
765
|
|
|
766
|
+
process.on("SIGINT", () => {
|
|
767
|
+
flushLogBuffer();
|
|
768
|
+
process.exit(0);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
process.on("SIGTERM", () => {
|
|
772
|
+
flushLogBuffer();
|
|
773
|
+
process.exit(0);
|
|
774
|
+
});
|
|
775
|
+
|
|
546
776
|
const buildApiCheckPrompt = (endpoints, treeLines) => {
|
|
547
777
|
return [
|
|
548
778
|
"You are a senior backend engineer. Determine which API endpoints are NOT implemented in the codebase.",
|
|
@@ -569,19 +799,25 @@ const collectTreeForApiCheck = () => {
|
|
|
569
799
|
};
|
|
570
800
|
|
|
571
801
|
const runApiCheck = async () => {
|
|
572
|
-
|
|
573
|
-
|
|
802
|
+
const allMissingResult = () => {
|
|
803
|
+
const items = apiEndpointCandidatesCached.map(String);
|
|
804
|
+
return { api_missing_count: items.length, api_missing_items: items, api_implemented_count: 0, api_implemented_items: [] };
|
|
805
|
+
};
|
|
806
|
+
if (!enableApiCheck) return allMissingResult();
|
|
807
|
+
if (!apiEndpointCandidatesCached.length) {
|
|
808
|
+
return { api_missing_count: 0, api_missing_items: [], api_implemented_count: 0, api_implemented_items: [] };
|
|
809
|
+
}
|
|
574
810
|
if (!openaiApiKey) {
|
|
575
|
-
sendApiCheckLine("OPENAI_API_KEY is not set;
|
|
576
|
-
return;
|
|
811
|
+
sendApiCheckLine("OPENAI_API_KEY is not set; すべて未実装として扱います。");
|
|
812
|
+
return allMissingResult();
|
|
577
813
|
}
|
|
578
814
|
if (!openaiModel) {
|
|
579
|
-
sendApiCheckLine("OPENAI_MODEL is not set;
|
|
580
|
-
return;
|
|
815
|
+
sendApiCheckLine("OPENAI_MODEL is not set; すべて未実装として扱います。");
|
|
816
|
+
return allMissingResult();
|
|
581
817
|
}
|
|
582
818
|
if (typeof fetch !== "function") {
|
|
583
|
-
sendApiCheckLine("fetch is not available in this runtime;
|
|
584
|
-
return;
|
|
819
|
+
sendApiCheckLine("fetch is not available in this runtime; すべて未実装として扱います。");
|
|
820
|
+
return allMissingResult();
|
|
585
821
|
}
|
|
586
822
|
const treeLines = collectTreeForApiCheck();
|
|
587
823
|
const prompt = buildApiCheckPrompt(apiEndpointCandidatesCached, treeLines);
|
|
@@ -609,7 +845,7 @@ const runApiCheck = async () => {
|
|
|
609
845
|
});
|
|
610
846
|
} catch (error) {
|
|
611
847
|
sendApiCheckLine(`OpenAI request failed: ${error}`);
|
|
612
|
-
return;
|
|
848
|
+
return allMissingResult();
|
|
613
849
|
}
|
|
614
850
|
if (!response.ok) {
|
|
615
851
|
const text = await response.text();
|
|
@@ -617,19 +853,19 @@ const runApiCheck = async () => {
|
|
|
617
853
|
`OpenAI request failed: ${response.status} ${response.statusText}`,
|
|
618
854
|
);
|
|
619
855
|
sendApiCheckLine(text.slice(0, 1000));
|
|
620
|
-
return;
|
|
856
|
+
return allMissingResult();
|
|
621
857
|
}
|
|
622
858
|
let data = null;
|
|
623
859
|
try {
|
|
624
860
|
data = await response.json();
|
|
625
861
|
} catch (error) {
|
|
626
862
|
sendApiCheckLine(`OpenAI response parse failed: ${error}`);
|
|
627
|
-
return;
|
|
863
|
+
return allMissingResult();
|
|
628
864
|
}
|
|
629
865
|
const content = data?.choices?.[0]?.message?.content?.trim();
|
|
630
866
|
if (!content) {
|
|
631
867
|
sendApiCheckLine("OpenAI response was empty.");
|
|
632
|
-
return;
|
|
868
|
+
return allMissingResult();
|
|
633
869
|
}
|
|
634
870
|
try {
|
|
635
871
|
const parsed = JSON.parse(content);
|
|
@@ -641,38 +877,68 @@ const runApiCheck = async () => {
|
|
|
641
877
|
sendApiCheckLine(
|
|
642
878
|
`missing=${missing.length} unknown=${unknown.length} implemented=${implemented.length}`,
|
|
643
879
|
);
|
|
880
|
+
const missingItems = [];
|
|
644
881
|
missing.forEach((item) => {
|
|
645
882
|
if (item && typeof item === "object") {
|
|
883
|
+
const label = item.endpoint || "";
|
|
884
|
+
missingItems.push(label);
|
|
646
885
|
sendApiCheckLine(
|
|
647
|
-
`missing: ${
|
|
886
|
+
`missing: ${label} (${item.reason || "no reason"})`,
|
|
648
887
|
);
|
|
649
888
|
} else {
|
|
889
|
+
missingItems.push(String(item));
|
|
650
890
|
sendApiCheckLine(`missing: ${String(item)}`);
|
|
651
891
|
}
|
|
652
892
|
});
|
|
893
|
+
const implementedItems = [];
|
|
653
894
|
unknown.forEach((item) => sendApiCheckLine(`unknown: ${String(item)}`));
|
|
654
|
-
implemented.forEach((item) =>
|
|
655
|
-
|
|
656
|
-
|
|
895
|
+
implemented.forEach((item) => {
|
|
896
|
+
implementedItems.push(String(item));
|
|
897
|
+
sendApiCheckLine(`implemented: ${String(item)}`);
|
|
898
|
+
});
|
|
899
|
+
return {
|
|
900
|
+
api_missing_count: missing.length,
|
|
901
|
+
api_missing_items: missingItems,
|
|
902
|
+
api_implemented_count: implemented.length,
|
|
903
|
+
api_implemented_items: implementedItems,
|
|
904
|
+
};
|
|
657
905
|
} catch (error) {
|
|
658
906
|
sendApiCheckLine("OpenAI output was not valid JSON. Raw output:");
|
|
659
907
|
sendApiCheckLine(content.slice(0, 2000));
|
|
908
|
+
return allMissingResult();
|
|
660
909
|
}
|
|
661
910
|
};
|
|
662
911
|
|
|
663
912
|
const screenCheckUrl = `${normalizeBaseHttpUrl(baseUrl)}/api/v1/project_snapshot/check`;
|
|
664
913
|
|
|
665
914
|
const runScreenCheck = async () => {
|
|
915
|
+
const allMissingResult = () => {
|
|
916
|
+
const items = screenCandidatesCached.map(toScreenName);
|
|
917
|
+
return { screen_missing_count: items.length, screen_missing_items: items, screen_implemented_count: 0, screen_implemented_items: [] };
|
|
918
|
+
};
|
|
666
919
|
if (!projectKey) {
|
|
667
|
-
sendScreenCheckLine("project_key is missing;
|
|
668
|
-
return;
|
|
920
|
+
sendScreenCheckLine("project_key is missing; すべて未実装として扱います。");
|
|
921
|
+
return allMissingResult();
|
|
669
922
|
}
|
|
670
923
|
if (typeof fetch !== "function") {
|
|
671
|
-
sendScreenCheckLine("fetch is not available in this runtime;
|
|
672
|
-
return;
|
|
924
|
+
sendScreenCheckLine("fetch is not available in this runtime; すべて未実装として扱います。");
|
|
925
|
+
return allMissingResult();
|
|
673
926
|
}
|
|
674
|
-
const paths = collectSnapshotPaths(projectRoot, DEFAULT_SNAPSHOT_EXCLUDES)
|
|
927
|
+
const paths = collectSnapshotPaths(projectRoot, DEFAULT_SNAPSHOT_EXCLUDES).filter(
|
|
928
|
+
(item) => item.startsWith("frontend/"),
|
|
929
|
+
);
|
|
675
930
|
const limitedPaths = paths.slice(0, projectTreeMaxFiles);
|
|
931
|
+
if (!limitedPaths.length) {
|
|
932
|
+
sendScreenCheckLine("frontend/ 配下のファイルが見つからないため、すべて未実装として扱います。");
|
|
933
|
+
const allMissing = screenCandidatesCached.map(toScreenName);
|
|
934
|
+
allMissing.forEach((item) => sendScreenCheckLine(`missing: ${item}`));
|
|
935
|
+
return {
|
|
936
|
+
screen_missing_count: allMissing.length,
|
|
937
|
+
screen_missing_items: allMissing,
|
|
938
|
+
screen_implemented_count: 0,
|
|
939
|
+
screen_implemented_items: [],
|
|
940
|
+
};
|
|
941
|
+
}
|
|
676
942
|
const payload = {
|
|
677
943
|
projectPublicId: projectKey,
|
|
678
944
|
paths: limitedPaths,
|
|
@@ -686,7 +952,7 @@ const runScreenCheck = async () => {
|
|
|
686
952
|
});
|
|
687
953
|
} catch (error) {
|
|
688
954
|
sendScreenCheckLine(`screen check request failed: ${error}`);
|
|
689
|
-
return;
|
|
955
|
+
return allMissingResult();
|
|
690
956
|
}
|
|
691
957
|
if (!response.ok) {
|
|
692
958
|
const text = await response.text();
|
|
@@ -694,19 +960,24 @@ const runScreenCheck = async () => {
|
|
|
694
960
|
`screen check failed: ${response.status} ${response.statusText}`,
|
|
695
961
|
);
|
|
696
962
|
sendScreenCheckLine(text.slice(0, 1000));
|
|
697
|
-
return;
|
|
963
|
+
return allMissingResult();
|
|
698
964
|
}
|
|
699
965
|
let data = null;
|
|
700
966
|
try {
|
|
701
967
|
data = await response.json();
|
|
702
968
|
} catch (error) {
|
|
703
969
|
sendScreenCheckLine(`screen check response parse failed: ${error}`);
|
|
704
|
-
return;
|
|
970
|
+
return allMissingResult();
|
|
705
971
|
}
|
|
706
|
-
const expected =
|
|
707
|
-
|
|
972
|
+
const expected = screenCandidatesCached.length
|
|
973
|
+
? screenCandidatesCached.map(toScreenName)
|
|
974
|
+
: (Array.isArray(data?.expectedScreens) ? data.expectedScreens : []);
|
|
975
|
+
const rawMissing = Array.isArray(data?.check?.missingScreens)
|
|
708
976
|
? data.check.missingScreens
|
|
709
977
|
: [];
|
|
978
|
+
// expected をキャッシュに揃えたので、missing も expected に含まれるもののみに絞る
|
|
979
|
+
const expectedSet = new Set(expected.map(String));
|
|
980
|
+
const missing = rawMissing.filter((item) => expectedSet.has(String(item)));
|
|
710
981
|
const implemented = expected.filter((item) => !missing.includes(item));
|
|
711
982
|
sendScreenCheckLine(
|
|
712
983
|
`missing_screens=${missing.length} expected=${expected.length} implemented=${implemented.length}`,
|
|
@@ -718,10 +989,17 @@ const runScreenCheck = async () => {
|
|
|
718
989
|
if (data?.check?.notes) {
|
|
719
990
|
sendScreenCheckLine(`note: ${String(data.check.notes)}`);
|
|
720
991
|
}
|
|
992
|
+
return {
|
|
993
|
+
screen_missing_count: missing.length,
|
|
994
|
+
screen_missing_items: missing.map(String),
|
|
995
|
+
screen_implemented_count: implemented.length,
|
|
996
|
+
screen_implemented_items: implemented.map(String),
|
|
997
|
+
};
|
|
721
998
|
};
|
|
722
999
|
|
|
723
1000
|
const triggerApiCheck = async (reason) => {
|
|
724
|
-
|
|
1001
|
+
const emptyResult = { api_missing_count: 0, api_missing_items: [], api_implemented_count: 0, api_implemented_items: [] };
|
|
1002
|
+
if (apiCheckInFlight) return emptyResult;
|
|
725
1003
|
apiCheckInFlight = true;
|
|
726
1004
|
try {
|
|
727
1005
|
sendApiCheckLine(`api-check start (${reason})`);
|
|
@@ -731,27 +1009,292 @@ const triggerApiCheck = async (reason) => {
|
|
|
731
1009
|
logApiCheckStatus();
|
|
732
1010
|
if (!apiEndpointCandidatesCached.length) {
|
|
733
1011
|
sendApiCheckLine("api_endpoint_list is empty; skipping api-check.");
|
|
734
|
-
return;
|
|
1012
|
+
return emptyResult;
|
|
735
1013
|
}
|
|
736
|
-
await runApiCheck();
|
|
1014
|
+
return await runApiCheck();
|
|
737
1015
|
} finally {
|
|
738
1016
|
apiCheckInFlight = false;
|
|
739
1017
|
}
|
|
740
1018
|
};
|
|
741
1019
|
|
|
742
1020
|
const triggerScreenCheck = async (reason) => {
|
|
1021
|
+
const emptyResult = { screen_missing_count: 0, screen_missing_items: [], screen_implemented_count: 0, screen_implemented_items: [] };
|
|
743
1022
|
try {
|
|
744
1023
|
sendScreenCheckLine(`screen-check start (${reason})`);
|
|
745
|
-
|
|
1024
|
+
if (!screenCandidatesCached.length) {
|
|
1025
|
+
await fetchScreenList();
|
|
1026
|
+
}
|
|
1027
|
+
logScreenCheckStatus();
|
|
1028
|
+
if (!screenCandidatesCached.length) {
|
|
1029
|
+
sendScreenCheckLine("screen_list is empty; skipping screen-check.");
|
|
1030
|
+
return emptyResult;
|
|
1031
|
+
}
|
|
1032
|
+
return await runScreenCheck();
|
|
746
1033
|
} catch (error) {
|
|
747
1034
|
sendScreenCheckLine(`screen-check failed: ${error}`);
|
|
1035
|
+
return emptyResult;
|
|
748
1036
|
}
|
|
749
1037
|
};
|
|
750
1038
|
|
|
1039
|
+
// ---------------------------------------------------------------------------
|
|
1040
|
+
// Security Check (static regex-based)
|
|
1041
|
+
// ---------------------------------------------------------------------------
|
|
1042
|
+
|
|
1043
|
+
const SECURITY_RULES = [
|
|
1044
|
+
{
|
|
1045
|
+
id: "raw_sql",
|
|
1046
|
+
label: "SQLインジェクション",
|
|
1047
|
+
pattern: /(?:execute\s*\(\s*f["']|\.raw\s*\(|cursor\.execute\s*\(\s*f["'])/,
|
|
1048
|
+
extensions: new Set([".py", ".js", ".ts"]),
|
|
1049
|
+
},
|
|
1050
|
+
{
|
|
1051
|
+
id: "xss",
|
|
1052
|
+
label: "XSS",
|
|
1053
|
+
pattern: /(?:dangerouslySetInnerHTML|v-html)/,
|
|
1054
|
+
extensions: new Set([".js", ".jsx", ".ts", ".tsx", ".vue"]),
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
id: "hardcoded_secrets",
|
|
1058
|
+
label: "秘密情報ハードコード",
|
|
1059
|
+
pattern: /(?:api_key|apikey|secret_key|password|aws_secret)\s*=\s*["'][^"']{4,}["']/i,
|
|
1060
|
+
extensions: new Set([".py", ".js", ".ts", ".env", ".yaml", ".yml", ".json"]),
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
id: "stack_trace_leak",
|
|
1064
|
+
label: "スタックトレース漏洩",
|
|
1065
|
+
pattern: /(?:traceback\.format_exc|import\s+traceback)/,
|
|
1066
|
+
extensions: new Set([".py"]),
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
id: "log_secrets",
|
|
1070
|
+
label: "ログ秘密情報",
|
|
1071
|
+
pattern: /(?:logger\.\w+.*(?:password|token|secret)|print\s*\(.*(?:password|token|secret))/i,
|
|
1072
|
+
extensions: new Set([".py", ".js", ".ts"]),
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
id: "cors_wildcard",
|
|
1076
|
+
label: "CORSワイルドカード",
|
|
1077
|
+
pattern: /allow_origins\s*=\s*\[\s*["']\*["']\s*\]/,
|
|
1078
|
+
extensions: new Set([".py"]),
|
|
1079
|
+
},
|
|
1080
|
+
];
|
|
1081
|
+
|
|
1082
|
+
const SECURITY_EXTENSIONS = new Set([".py", ".js", ".jsx", ".ts", ".tsx", ".vue", ".yaml", ".yml", ".json"]);
|
|
1083
|
+
|
|
1084
|
+
const collectSecurityTargetPaths = (rootDir, excludes) => {
|
|
1085
|
+
const results = [];
|
|
1086
|
+
const queue = [rootDir];
|
|
1087
|
+
while (queue.length) {
|
|
1088
|
+
const current = queue.pop();
|
|
1089
|
+
if (!current) continue;
|
|
1090
|
+
let entries = [];
|
|
1091
|
+
try {
|
|
1092
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
for (const entry of entries) {
|
|
1097
|
+
const fullPath = path.join(current, entry.name);
|
|
1098
|
+
const relPath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
|
|
1099
|
+
if (isExcludedPath(relPath, entry.name, excludes)) continue;
|
|
1100
|
+
if (entry.isSymbolicLink && entry.isSymbolicLink()) continue;
|
|
1101
|
+
if (entry.isDirectory()) {
|
|
1102
|
+
queue.push(fullPath);
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
if (entry.isFile()) {
|
|
1106
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
1107
|
+
if (SECURITY_EXTENSIONS.has(ext)) {
|
|
1108
|
+
results.push({ relPath, fullPath, ext });
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return results;
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
const SECURITY_CHECK_STEP_DELAY_MS = 400;
|
|
1117
|
+
const SECURITY_DETAIL_MAX_MATCHES = 5;
|
|
1118
|
+
|
|
1119
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1120
|
+
|
|
1121
|
+
const collectRuleMatches = (rule, files) => {
|
|
1122
|
+
const matches = [];
|
|
1123
|
+
for (const file of files) {
|
|
1124
|
+
if (!rule.extensions.has(file.ext)) continue;
|
|
1125
|
+
let content = "";
|
|
1126
|
+
try {
|
|
1127
|
+
const buffer = fs.readFileSync(file.fullPath);
|
|
1128
|
+
if (isBinaryBuffer(buffer)) continue;
|
|
1129
|
+
content = buffer.toString("utf8");
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
const lines = content.split("\n");
|
|
1134
|
+
const re = new RegExp(rule.pattern.source, rule.pattern.flags);
|
|
1135
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1136
|
+
if (!re.test(lines[i])) continue;
|
|
1137
|
+
const contextStart = Math.max(0, i - 3);
|
|
1138
|
+
const contextEnd = Math.min(lines.length, i + 4);
|
|
1139
|
+
matches.push({
|
|
1140
|
+
file: file.relPath,
|
|
1141
|
+
line: i + 1,
|
|
1142
|
+
code: lines[i].trim(),
|
|
1143
|
+
context: lines.slice(contextStart, contextEnd).join("\n"),
|
|
1144
|
+
});
|
|
1145
|
+
if (matches.length >= SECURITY_DETAIL_MAX_MATCHES) return matches;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
return matches;
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
const runSecurityCheck = async () => {
|
|
1152
|
+
const files = collectSecurityTargetPaths(projectRoot, DEFAULT_SNAPSHOT_EXCLUDES);
|
|
1153
|
+
const issueMap = {}; // id -> count
|
|
1154
|
+
const passedIds = [];
|
|
1155
|
+
const issueDetails = []; // { id, label, count }
|
|
1156
|
+
const total = SECURITY_RULES.length + 1; // +1 for .env check
|
|
1157
|
+
|
|
1158
|
+
// Check each rule
|
|
1159
|
+
let ruleIndex = 0;
|
|
1160
|
+
for (const rule of SECURITY_RULES) {
|
|
1161
|
+
ruleIndex += 1;
|
|
1162
|
+
// Collect detailed match info
|
|
1163
|
+
const ruleMatches = collectRuleMatches(rule, files);
|
|
1164
|
+
const hitCount = ruleMatches.length;
|
|
1165
|
+
if (hitCount > 0) {
|
|
1166
|
+
issueMap[rule.id] = hitCount;
|
|
1167
|
+
issueDetails.push({ id: rule.id, label: rule.label, count: hitCount });
|
|
1168
|
+
sendSecurityCheckLine(`progress ${ruleIndex}/${total} ${rule.id} issue ${hitCount}`);
|
|
1169
|
+
// Send detail line with match locations
|
|
1170
|
+
sendSecurityCheckLine(`detail ${JSON.stringify({ rule_id: rule.id, label: rule.label, matches: ruleMatches })}`);
|
|
1171
|
+
} else {
|
|
1172
|
+
passedIds.push(rule.id);
|
|
1173
|
+
sendSecurityCheckLine(`progress ${ruleIndex}/${total} ${rule.id} passed`);
|
|
1174
|
+
}
|
|
1175
|
+
// 1項目ずつ送信してバルーン更新を可視化
|
|
1176
|
+
flushLogBuffer();
|
|
1177
|
+
await sleep(SECURITY_CHECK_STEP_DELAY_MS);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Check .env in .gitignore
|
|
1181
|
+
let envGitignored = false;
|
|
1182
|
+
try {
|
|
1183
|
+
const gitignore = fs.readFileSync(path.join(projectRoot, ".gitignore"), "utf8");
|
|
1184
|
+
envGitignored = /^\.env$/m.test(gitignore);
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
// no .gitignore
|
|
1187
|
+
}
|
|
1188
|
+
if (!envGitignored) {
|
|
1189
|
+
issueDetails.push({ id: "env_not_gitignored", label: ".env未gitignore", count: 1 });
|
|
1190
|
+
issueMap["env_not_gitignored"] = 1;
|
|
1191
|
+
sendSecurityCheckLine(`progress ${total}/${total} env_not_gitignored issue 1`);
|
|
1192
|
+
} else {
|
|
1193
|
+
passedIds.push("env_not_gitignored");
|
|
1194
|
+
sendSecurityCheckLine(`progress ${total}/${total} env_not_gitignored passed`);
|
|
1195
|
+
}
|
|
1196
|
+
flushLogBuffer();
|
|
1197
|
+
|
|
1198
|
+
return {
|
|
1199
|
+
security_issue_count: issueDetails.length,
|
|
1200
|
+
security_issue_items: issueDetails.map((d) => `${d.id} (${d.count}箇所)`),
|
|
1201
|
+
security_passed_count: passedIds.length,
|
|
1202
|
+
security_passed_items: passedIds,
|
|
1203
|
+
};
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
const triggerSecurityCheck = async (reason) => {
|
|
1207
|
+
sendSecurityCheckLine(`security-check start (${reason})`);
|
|
1208
|
+
flushLogBuffer();
|
|
1209
|
+
const result = await runSecurityCheck();
|
|
1210
|
+
|
|
1211
|
+
// Emit detail lines
|
|
1212
|
+
for (const item of result.security_issue_items) {
|
|
1213
|
+
sendSecurityCheckLine(`issue: ${item}`);
|
|
1214
|
+
}
|
|
1215
|
+
for (const id of result.security_passed_items) {
|
|
1216
|
+
sendSecurityCheckLine(`passed: ${id}`);
|
|
1217
|
+
}
|
|
1218
|
+
sendSecurityCheckLine(
|
|
1219
|
+
`issues=${result.security_issue_count} passed=${result.security_passed_count}`,
|
|
1220
|
+
);
|
|
1221
|
+
return result;
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
const triggerCodeAnalysis = async (reason, role = null) => {
|
|
1225
|
+
const runApi = !role || role === "api";
|
|
1226
|
+
const runScreen = !role || role === "screen";
|
|
1227
|
+
|
|
1228
|
+
const steps = [];
|
|
1229
|
+
if (runApi) {
|
|
1230
|
+
const apiCount = apiEndpointCandidatesCached.length;
|
|
1231
|
+
const apiSuffix = apiCount ? ` (対象: ${apiCount}件)` : "";
|
|
1232
|
+
steps.push({ label: `API実装チェック中...${apiSuffix}`, fn: () => triggerApiCheck(`code-analysis:${reason}`) });
|
|
1233
|
+
}
|
|
1234
|
+
if (runScreen && enableScreenCheck) {
|
|
1235
|
+
const screenCount = screenCandidatesCached.length;
|
|
1236
|
+
const screenSuffix = screenCount ? ` (対象: ${screenCount}件)` : "";
|
|
1237
|
+
steps.push({ label: `画面実装チェック中...${screenSuffix}`, fn: () => triggerScreenCheck(`code-analysis:${reason}`) });
|
|
1238
|
+
}
|
|
1239
|
+
// Security check is always included
|
|
1240
|
+
steps.push({ label: "セキュリティチェック中...", fn: async () => triggerSecurityCheck(`code-analysis:${reason}`) });
|
|
1241
|
+
|
|
1242
|
+
const totalSteps = steps.length;
|
|
1243
|
+
let currentStep = 0;
|
|
1244
|
+
|
|
1245
|
+
sendCodeAnalysisLine(`code-analysis start (${reason})`);
|
|
1246
|
+
|
|
1247
|
+
// 各ステップの結果を収集
|
|
1248
|
+
const summary = {
|
|
1249
|
+
api_missing_count: 0, api_missing_items: [], api_implemented_count: 0, api_implemented_items: [],
|
|
1250
|
+
screen_missing_count: 0, screen_missing_items: [], screen_implemented_count: 0, screen_implemented_items: [],
|
|
1251
|
+
security_issue_count: 0, security_issue_items: [], security_passed_count: 0, security_passed_items: [],
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
for (const step of steps) {
|
|
1255
|
+
currentStep += 1;
|
|
1256
|
+
sendCodeAnalysisLine(`[${currentStep}/${totalSteps}] ${step.label}`);
|
|
1257
|
+
const result = await step.fn();
|
|
1258
|
+
if (result) {
|
|
1259
|
+
Object.assign(summary, result);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const apiExpected = apiEndpointCandidatesCached.map(String);
|
|
1264
|
+
const screenExpected = screenCandidatesCached.map(toScreenName);
|
|
1265
|
+
const apiResultEmpty =
|
|
1266
|
+
summary.api_missing_count === 0 &&
|
|
1267
|
+
summary.api_implemented_count === 0 &&
|
|
1268
|
+
!summary.api_missing_items.length &&
|
|
1269
|
+
!summary.api_implemented_items.length;
|
|
1270
|
+
const screenResultEmpty =
|
|
1271
|
+
summary.screen_missing_count === 0 &&
|
|
1272
|
+
summary.screen_implemented_count === 0 &&
|
|
1273
|
+
!summary.screen_missing_items.length &&
|
|
1274
|
+
!summary.screen_implemented_items.length;
|
|
1275
|
+
|
|
1276
|
+
if (runApi && apiExpected.length && apiResultEmpty) {
|
|
1277
|
+
summary.api_missing_count = apiExpected.length;
|
|
1278
|
+
summary.api_missing_items = apiExpected;
|
|
1279
|
+
summary.api_check_inferred = true;
|
|
1280
|
+
}
|
|
1281
|
+
if (runScreen && enableScreenCheck && screenExpected.length && screenResultEmpty) {
|
|
1282
|
+
summary.screen_missing_count = screenExpected.length;
|
|
1283
|
+
summary.screen_missing_items = screenExpected;
|
|
1284
|
+
summary.screen_check_inferred = true;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// done 行にサマリー JSON を埋め込む(バックエンドが確実にパースできるように)
|
|
1288
|
+
flushLogBuffer();
|
|
1289
|
+
sendCodeAnalysisLine(`done ${JSON.stringify(summary)}`);
|
|
1290
|
+
};
|
|
1291
|
+
|
|
751
1292
|
const fetchApiEndpointList = async () => {
|
|
752
1293
|
if (!apiEndpointUrl) return [];
|
|
753
1294
|
if (typeof fetch !== "function") {
|
|
754
1295
|
sendApiCheckLine("fetch is not available in this runtime; skipping fetch.");
|
|
1296
|
+
apiEndpointFetchStatus = "error";
|
|
1297
|
+
apiEndpointFetchDetail = "fetch_unavailable";
|
|
755
1298
|
return [];
|
|
756
1299
|
}
|
|
757
1300
|
try {
|
|
@@ -770,6 +1313,8 @@ const fetchApiEndpointList = async () => {
|
|
|
770
1313
|
sendApiCheckLine(
|
|
771
1314
|
`api_endpoint_list取得失敗: ${response.status} ${response.statusText} (${apiEndpointUrl})${detailSnippet}`,
|
|
772
1315
|
);
|
|
1316
|
+
apiEndpointFetchStatus = "error";
|
|
1317
|
+
apiEndpointFetchDetail = `${response.status}`;
|
|
773
1318
|
return [];
|
|
774
1319
|
}
|
|
775
1320
|
const data = await response.json();
|
|
@@ -777,17 +1322,90 @@ const fetchApiEndpointList = async () => {
|
|
|
777
1322
|
apiEndpointCandidatesCached = extractApiEndpointItems(
|
|
778
1323
|
data.api_endpoint_list,
|
|
779
1324
|
);
|
|
1325
|
+
apiEndpointFetchStatus = apiEndpointCandidatesCached.length
|
|
1326
|
+
? "ok"
|
|
1327
|
+
: "ok_empty";
|
|
1328
|
+
apiEndpointFetchDetail = "";
|
|
780
1329
|
return apiEndpointCandidatesCached;
|
|
781
1330
|
}
|
|
782
1331
|
if (Array.isArray(data?.endpoints)) {
|
|
783
1332
|
apiEndpointCandidatesCached = extractApiEndpointItems(
|
|
784
1333
|
JSON.stringify({ endpoints: data.endpoints }),
|
|
785
1334
|
);
|
|
1335
|
+
apiEndpointFetchStatus = apiEndpointCandidatesCached.length
|
|
1336
|
+
? "ok"
|
|
1337
|
+
: "ok_empty";
|
|
1338
|
+
apiEndpointFetchDetail = "";
|
|
786
1339
|
return apiEndpointCandidatesCached;
|
|
787
1340
|
}
|
|
1341
|
+
apiEndpointFetchStatus = "ok_empty";
|
|
1342
|
+
apiEndpointFetchDetail = "";
|
|
788
1343
|
return [];
|
|
789
1344
|
} catch (error) {
|
|
790
1345
|
sendApiCheckLine(`api_endpoint_list fetch error: ${error}`);
|
|
1346
|
+
apiEndpointFetchStatus = "error";
|
|
1347
|
+
apiEndpointFetchDetail = "exception";
|
|
1348
|
+
return [];
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
const fetchScreenList = async () => {
|
|
1353
|
+
if (!screenListUrl) return [];
|
|
1354
|
+
if (typeof fetch !== "function") {
|
|
1355
|
+
sendScreenCheckLine("fetch is not available in this runtime; skipping fetch.");
|
|
1356
|
+
screenListFetchStatus = "error";
|
|
1357
|
+
screenListFetchDetail = "fetch_unavailable";
|
|
1358
|
+
return [];
|
|
1359
|
+
}
|
|
1360
|
+
try {
|
|
1361
|
+
const response = await fetch(screenListUrl, {
|
|
1362
|
+
method: "GET",
|
|
1363
|
+
headers: { "Content-Type": "application/json" },
|
|
1364
|
+
});
|
|
1365
|
+
if (!response.ok) {
|
|
1366
|
+
let detail = "";
|
|
1367
|
+
try {
|
|
1368
|
+
detail = await response.text();
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
detail = "";
|
|
1371
|
+
}
|
|
1372
|
+
const detailSnippet = detail ? ` detail=${detail.slice(0, 200)}` : "";
|
|
1373
|
+
sendScreenCheckLine(
|
|
1374
|
+
`screen_list取得失敗: ${response.status} ${response.statusText} (${screenListUrl})${detailSnippet}`,
|
|
1375
|
+
);
|
|
1376
|
+
screenListFetchStatus = "error";
|
|
1377
|
+
screenListFetchDetail = `${response.status}`;
|
|
1378
|
+
return [];
|
|
1379
|
+
}
|
|
1380
|
+
const data = await response.json();
|
|
1381
|
+
if (data?.screen_list) {
|
|
1382
|
+
const raw = Array.isArray(data?.screens) ? data.screens : [];
|
|
1383
|
+
screenCandidatesCached = raw.map((item) =>
|
|
1384
|
+
typeof item === "object" && item !== null ? item.name || String(item) : String(item)
|
|
1385
|
+
);
|
|
1386
|
+
screenListFetchStatus = screenCandidatesCached.length
|
|
1387
|
+
? "ok"
|
|
1388
|
+
: "ok_empty";
|
|
1389
|
+
screenListFetchDetail = "";
|
|
1390
|
+
return screenCandidatesCached;
|
|
1391
|
+
}
|
|
1392
|
+
if (Array.isArray(data?.screens)) {
|
|
1393
|
+
screenCandidatesCached = data.screens.map((item) =>
|
|
1394
|
+
typeof item === "object" && item !== null ? item.name || String(item) : String(item)
|
|
1395
|
+
);
|
|
1396
|
+
screenListFetchStatus = screenCandidatesCached.length
|
|
1397
|
+
? "ok"
|
|
1398
|
+
: "ok_empty";
|
|
1399
|
+
screenListFetchDetail = "";
|
|
1400
|
+
return screenCandidatesCached;
|
|
1401
|
+
}
|
|
1402
|
+
screenListFetchStatus = "ok_empty";
|
|
1403
|
+
screenListFetchDetail = "";
|
|
1404
|
+
return [];
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
sendScreenCheckLine(`screen_list fetch error: ${error}`);
|
|
1407
|
+
screenListFetchStatus = "error";
|
|
1408
|
+
screenListFetchDetail = "exception";
|
|
791
1409
|
return [];
|
|
792
1410
|
}
|
|
793
1411
|
};
|
|
@@ -802,7 +1420,7 @@ const startFileTailer = (targetPath) => {
|
|
|
802
1420
|
fileOffset = stats.size;
|
|
803
1421
|
return true;
|
|
804
1422
|
} catch (error) {
|
|
805
|
-
log(`log-path not found: ${targetPath} (retrying...)
|
|
1423
|
+
log(`log-path not found: ${targetPath} (retrying...)`, "warn");
|
|
806
1424
|
return false;
|
|
807
1425
|
}
|
|
808
1426
|
};
|
|
@@ -821,7 +1439,7 @@ const startFileTailer = (targetPath) => {
|
|
|
821
1439
|
const readNewData = () => {
|
|
822
1440
|
fs.stat(targetPath, (error, stats) => {
|
|
823
1441
|
if (error) {
|
|
824
|
-
log(`log-path read error: ${error.message || String(error)}
|
|
1442
|
+
log(`log-path read error: ${error.message || String(error)}`, "warn");
|
|
825
1443
|
return;
|
|
826
1444
|
}
|
|
827
1445
|
if (stats.size < fileOffset) {
|
|
@@ -844,6 +1462,7 @@ const startFileTailer = (targetPath) => {
|
|
|
844
1462
|
`log-path stream error: ${
|
|
845
1463
|
streamError.message || String(streamError)
|
|
846
1464
|
}`,
|
|
1465
|
+
"warn",
|
|
847
1466
|
);
|
|
848
1467
|
});
|
|
849
1468
|
});
|
|
@@ -857,7 +1476,7 @@ const startFileTailer = (targetPath) => {
|
|
|
857
1476
|
}
|
|
858
1477
|
});
|
|
859
1478
|
} catch (error) {
|
|
860
|
-
log(`log-path watch error: ${error.message || String(error)}
|
|
1479
|
+
log(`log-path watch error: ${error.message || String(error)}`, "warn");
|
|
861
1480
|
}
|
|
862
1481
|
};
|
|
863
1482
|
|
|
@@ -874,21 +1493,23 @@ const startFileTailer = (targetPath) => {
|
|
|
874
1493
|
startWatcher();
|
|
875
1494
|
};
|
|
876
1495
|
|
|
877
|
-
if (
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1496
|
+
if (runMode !== "security") {
|
|
1497
|
+
if (logPath) {
|
|
1498
|
+
startFileTailer(logPath);
|
|
1499
|
+
} else {
|
|
1500
|
+
const rl = readline.createInterface({
|
|
1501
|
+
input: process.stdin,
|
|
1502
|
+
crlfDelay: Infinity,
|
|
1503
|
+
});
|
|
884
1504
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1505
|
+
rl.on("line", (line) => {
|
|
1506
|
+
handleLine(line);
|
|
1507
|
+
});
|
|
888
1508
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1509
|
+
rl.on("close", () => {
|
|
1510
|
+
if (socket) {
|
|
1511
|
+
socket.close();
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
894
1515
|
}
|