coderail-watch 0.1.7 → 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.
@@ -2,17 +2,41 @@
2
2
  "use strict";
3
3
 
4
4
  const fs = require("fs");
5
+ const path = require("path");
5
6
  const readline = require("readline");
6
7
 
7
- const DEFAULT_BASE_URL = "http://localhost:8000";
8
+ const DEFAULT_BASE_URL = "https://api.dev-coderail.local";
8
9
  const ENV_BASE_URLS = {
9
- local: "http://localhost:8000",
10
+ local: "https://api.dev-coderail.local",
10
11
  staging: "https://ai-tutor-backend-stg-673992211852.asia-northeast1.run.app",
11
12
  production: "https://api.coderail.local",
12
13
  };
13
14
  const DEFAULT_RETRY_WAIT_MS = 2000;
15
+ const DEFAULT_LOG_BATCH_MS = 300;
16
+ const DEFAULT_LOG_BATCH_MAX_LINES = 50;
14
17
  const ALERT_PREFIX = "[[CODERAIL_ERROR]] ";
15
18
  const ERROR_PATTERN = /(^|[\s:])(?:error|exception|traceback|panic|fatal)(?=[\s:])/i;
19
+ const DEFAULT_PROJECT_TREE_MAX_FILES = 1000;
20
+ const DEFAULT_PROJECT_TREE_MAX_BYTES = 4096;
21
+ const PROJECT_TREE_EXTENSIONS = new Set([".py", ".js"]);
22
+ const OPENAI_CHAT_API_URL = "https://api.openai.com/v1/chat/completions";
23
+ const DEFAULT_OPENAI_MODEL = "gpt-4o-mini";
24
+ const DEFAULT_SNAPSHOT_EXCLUDES = new Set([
25
+ ".git",
26
+ "node_modules",
27
+ ".venv",
28
+ "venv",
29
+ ".next",
30
+ "dist",
31
+ "build",
32
+ "tests",
33
+ ]);
34
+ const DEFAULT_SNAPSHOT_EXCLUDE_PREFIXES = new Set([
35
+ "frontend/.next",
36
+ "backend/.venv",
37
+ "backend/.pytest_cache",
38
+ ]);
39
+ const SNAPSHOT_EXCLUDE_PREFIXES = Array.from(DEFAULT_SNAPSHOT_EXCLUDE_PREFIXES);
16
40
 
17
41
  const isErrorLine = (line) => ERROR_PATTERN.test(line);
18
42
 
@@ -33,7 +57,21 @@ const parseArgs = (argv) => {
33
57
  return args;
34
58
  };
35
59
 
36
- const log = (message) => {
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;
37
75
  process.stderr.write(`[coderail-watch] ${message}\n`);
38
76
  };
39
77
 
@@ -44,18 +82,28 @@ if (!WebSocketImpl) {
44
82
  } catch (error) {
45
83
  log(
46
84
  "Missing dependency 'ws'. Run `cd tools/coderail-watch && npm install` or use the published package via `npx coderail-watch@latest ...`.",
85
+ "error",
47
86
  );
48
87
  process.exit(1);
49
88
  }
50
89
  }
51
90
  const WebSocket = WebSocketImpl;
52
91
 
53
- const buildWsUrl = (baseUrl, sessionId, projectKey, token) => {
92
+ const normalizeHttpBaseUrl = (baseUrl) => {
54
93
  let normalized = baseUrl.trim();
55
94
  if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
56
95
  normalized = `http://${normalized}`;
57
96
  }
58
- const url = new URL(normalized);
97
+ return normalized;
98
+ };
99
+
100
+ const normalizeBaseHttpUrl = (baseUrl) => {
101
+ const normalized = normalizeHttpBaseUrl(baseUrl);
102
+ return normalized.replace(/\/$/, "");
103
+ };
104
+
105
+ const buildWsUrl = (baseUrl, sessionId, projectKey, token, flowId) => {
106
+ const url = new URL(normalizeHttpBaseUrl(baseUrl));
59
107
  const wsProtocol = url.protocol === "https:" ? "wss:" : "ws:";
60
108
  url.protocol = wsProtocol;
61
109
  url.pathname = `${url.pathname.replace(/\/$/, "")}/api/v1/terminal_watch/ws`;
@@ -65,38 +113,296 @@ const buildWsUrl = (baseUrl, sessionId, projectKey, token) => {
65
113
  if (token) {
66
114
  url.searchParams.set("token", token);
67
115
  }
116
+ if (flowId) {
117
+ url.searchParams.set("flow_id", flowId);
118
+ }
68
119
  return url.toString();
69
120
  };
70
121
 
122
+ const normalizeBoolArg = (value, defaultValue) => {
123
+ if (value === undefined) return defaultValue;
124
+ return value !== "false";
125
+ };
126
+
127
+ const normalizeTrueArg = (value) => value === "true";
128
+
129
+ const normalizeIntArg = (value, fallback, minValue) => {
130
+ const parsed = Number(value);
131
+ if (!Number.isFinite(parsed) || parsed < minValue) return fallback;
132
+ return Math.floor(parsed);
133
+ };
134
+
135
+ const resolveBaseUrl = (args) => {
136
+ const env = args["env"];
137
+ if (env && !ENV_BASE_URLS[env]) {
138
+ throw new Error(
139
+ `Unknown env '${env}'. Use one of: ${Object.keys(ENV_BASE_URLS).join(", ")}`,
140
+ );
141
+ }
142
+ return (
143
+ args["base-url"] ||
144
+ (env ? ENV_BASE_URLS[env] : null) ||
145
+ DEFAULT_BASE_URL
146
+ );
147
+ return normalizeHttpBaseUrl(rawBaseUrl);
148
+ };
149
+
150
+ const isExcludedPath = (relPath, entryName, excludes) => {
151
+ if (!relPath) return true;
152
+ if (excludes.has(entryName)) return true;
153
+ if (entryName.startsWith(".")) return true;
154
+ if (DEFAULT_SNAPSHOT_EXCLUDE_PREFIXES.has(relPath)) return true;
155
+ if (SNAPSHOT_EXCLUDE_PREFIXES.some((prefix) => relPath.startsWith(`${prefix}/`))) {
156
+ return true;
157
+ }
158
+ return false;
159
+ };
160
+
161
+ const collectSnapshotPaths = (rootDir, excludes) => {
162
+ const results = [];
163
+ const queue = [rootDir];
164
+ while (queue.length) {
165
+ const current = queue.pop();
166
+ if (!current) continue;
167
+ let entries = [];
168
+ try {
169
+ entries = fs.readdirSync(current, { withFileTypes: true });
170
+ } catch (error) {
171
+ log(`snapshot read failed: ${current} (${error})`, "warn");
172
+ continue;
173
+ }
174
+ for (const entry of entries) {
175
+ const fullPath = path.join(current, entry.name);
176
+ const relPath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
177
+ if (isExcludedPath(relPath, entry.name, excludes)) continue;
178
+ if (entry.isSymbolicLink && entry.isSymbolicLink()) continue;
179
+ if (entry.isDirectory()) {
180
+ queue.push(fullPath);
181
+ continue;
182
+ }
183
+ if (entry.isFile()) {
184
+ const ext = path.extname(entry.name).toLowerCase();
185
+ if (PROJECT_TREE_EXTENSIONS.has(ext)) {
186
+ results.push(relPath);
187
+ }
188
+ }
189
+ }
190
+ }
191
+ return results.sort();
192
+ };
193
+
194
+ const extractApiEndpointItems = (jsonText) => {
195
+ if (!jsonText) return [];
196
+ let parsed = null;
197
+ try {
198
+ parsed = JSON.parse(jsonText);
199
+ } catch (error) {
200
+ return [];
201
+ }
202
+ const raw = Array.isArray(parsed) ? parsed : parsed?.endpoints;
203
+ if (!Array.isArray(raw)) return [];
204
+ const endpoints = [];
205
+ for (const item of raw) {
206
+ if (typeof item === "string") {
207
+ const trimmed = item.trim();
208
+ if (trimmed) endpoints.push(trimmed);
209
+ continue;
210
+ }
211
+ if (!item || typeof item !== "object") continue;
212
+ const method = String(item.method || "").trim().toUpperCase();
213
+ const pathValue = String(item.path || "").trim();
214
+ if (!pathValue) continue;
215
+ endpoints.push(method ? `${method} ${pathValue}` : pathValue);
216
+ }
217
+ return endpoints;
218
+ };
219
+
220
+ const isBinaryBuffer = (buffer) => buffer.includes(0);
221
+
222
+ const buildProjectTreeLines = (
223
+ rootDir,
224
+ excludes,
225
+ maxFiles,
226
+ includeContents,
227
+ maxBytes,
228
+ ) => {
229
+ const paths = collectSnapshotPaths(rootDir, excludes);
230
+ const limited = paths.slice(0, maxFiles);
231
+ const lines = [
232
+ "[project-tree] listing files",
233
+ `[project-tree] root=${rootDir}`,
234
+ `[project-tree] total=${paths.length} shown=${limited.length}`,
235
+ ];
236
+ for (const relPath of limited) {
237
+ lines.push(`- ${relPath}`);
238
+ if (!includeContents) {
239
+ continue;
240
+ }
241
+ const fullPath = path.join(rootDir, relPath);
242
+ let content = "";
243
+ try {
244
+ const buffer = fs.readFileSync(fullPath);
245
+ if (isBinaryBuffer(buffer)) {
246
+ lines.push(`(binary skipped) ${relPath}`);
247
+ continue;
248
+ }
249
+ content = buffer.toString("utf8");
250
+ } catch (error) {
251
+ lines.push(`(read failed) ${relPath}: ${error}`);
252
+ continue;
253
+ }
254
+ if (!content) {
255
+ lines.push(`(empty) ${relPath}`);
256
+ continue;
257
+ }
258
+ const slice = content.slice(0, maxBytes);
259
+ lines.push(`----- ${relPath} (first ${slice.length} chars) -----`);
260
+ lines.push(slice);
261
+ if (content.length > maxBytes) {
262
+ lines.push(`----- ${relPath} (truncated) -----`);
263
+ }
264
+ }
265
+ if (paths.length > limited.length) {
266
+ lines.push(`[project-tree] truncated: ${paths.length - limited.length} more`);
267
+ }
268
+ return lines;
269
+ };
270
+
71
271
  const args = parseArgs(process.argv.slice(2));
72
272
  const sessionId = args["session-id"];
73
273
  const projectKey = args["project-key"];
74
- const env = args["env"];
75
- const baseUrl =
76
- args["base-url"] ||
77
- (env && ENV_BASE_URLS[env] ? ENV_BASE_URLS[env] : DEFAULT_BASE_URL);
274
+ let baseUrl = "";
275
+ try {
276
+ baseUrl = resolveBaseUrl(args);
277
+ } catch (error) {
278
+ log(error.message || String(error), "error");
279
+ process.exit(1);
280
+ }
78
281
  const token = args["token"];
79
- const retryWaitMs = Number(args["retry-wait"] || DEFAULT_RETRY_WAIT_MS);
282
+ const retryWaitMs = normalizeIntArg(
283
+ args["retry-wait"],
284
+ DEFAULT_RETRY_WAIT_MS,
285
+ 0,
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
+ );
80
297
  const logPath = args["log-path"];
298
+ const projectRoot = path.resolve(args["project-root"] || process.cwd());
299
+ const sendProjectTree = normalizeBoolArg(args["project-tree"], true);
300
+ const includeProjectTreeContents = normalizeTrueArg(
301
+ args["project-tree-contents"],
302
+ );
303
+ const projectTreeMaxFiles = normalizeIntArg(
304
+ args["project-tree-max-files"],
305
+ DEFAULT_PROJECT_TREE_MAX_FILES,
306
+ 1,
307
+ );
308
+ const projectTreeMaxBytes = normalizeIntArg(
309
+ args["project-tree-max-bytes"],
310
+ DEFAULT_PROJECT_TREE_MAX_BYTES,
311
+ 1,
312
+ );
313
+ const runMode = args["mode"] || "default";
314
+ const enableApiCheck = true;
315
+ const enableScreenCheck = normalizeBoolArg(args["screen-check"], true);
316
+ const dotenvPath = args["env-file"] || path.join(projectRoot, ".env");
317
+ const loadEnvFile = (filePath) => {
318
+ try {
319
+ const raw = fs.readFileSync(filePath, "utf8");
320
+ const entries = {};
321
+ raw.split(/\r?\n/).forEach((line) => {
322
+ const trimmed = line.trim();
323
+ if (!trimmed || trimmed.startsWith("#")) return;
324
+ const idx = trimmed.indexOf("=");
325
+ if (idx <= 0) return;
326
+ const key = trimmed.slice(0, idx).trim();
327
+ let value = trimmed.slice(idx + 1).trim();
328
+ if (
329
+ (value.startsWith('"') && value.endsWith('"')) ||
330
+ (value.startsWith("'") && value.endsWith("'"))
331
+ ) {
332
+ value = value.slice(1, -1);
333
+ }
334
+ entries[key] = value;
335
+ });
336
+ return entries;
337
+ } catch (error) {
338
+ return {};
339
+ }
340
+ };
341
+ const envFromFile = loadEnvFile(dotenvPath);
342
+ const openaiApiKey =
343
+ args["openai-api-key"] ||
344
+ process.env.OPENAI_API_KEY ||
345
+ envFromFile.OPENAI_API_KEY ||
346
+ "";
347
+ const openaiModel =
348
+ args["openai-model"] ||
349
+ process.env.OPENAI_MODEL ||
350
+ envFromFile.OPENAI_MODEL ||
351
+ DEFAULT_OPENAI_MODEL;
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`;
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 = "";
81
375
 
82
376
  if (!sessionId || !projectKey) {
83
- log("Missing required args: --session-id and --project-key");
377
+ log("Missing required args: --session-id and --project-key", "error");
84
378
  process.exit(1);
85
379
  }
86
380
 
87
- if (env && !ENV_BASE_URLS[env]) {
88
- log(
89
- `Unknown env '${env}'. Use one of: ${Object.keys(ENV_BASE_URLS).join(", ")}`,
90
- );
91
- process.exit(1);
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;
92
393
  }
93
394
 
94
- const wsUrl = buildWsUrl(baseUrl, sessionId, projectKey, token);
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);
95
398
  let socket = null;
96
399
  let isOpen = false;
97
400
  const queue = [];
401
+ const logBuffer = [];
402
+ let logFlushTimer = null;
98
403
  let retryCount = 0;
99
404
  let lastErrorMessage = "";
405
+ let apiCheckInFlight = false;
100
406
 
101
407
  const attachHandler = (target, event, handler) => {
102
408
  if (typeof target.on === "function") {
@@ -116,22 +422,89 @@ const isSocketOpen = () => {
116
422
  return isOpen;
117
423
  };
118
424
 
425
+ const CONNECT_TIMEOUT_MS = 10000;
426
+
119
427
  const connect = () => {
120
428
  retryCount += 1;
121
- log(`connecting (#${retryCount})...`);
429
+ const attempt = retryCount;
430
+ log(`connecting to ${wsUrl} (#${attempt})...`, "warn");
122
431
  try {
123
- socket = new WebSocket(wsUrl);
432
+ const wsOptions = wsUrl.startsWith("wss://")
433
+ ? { rejectUnauthorized: false }
434
+ : undefined;
435
+ socket = new WebSocket(wsUrl, wsOptions);
124
436
  } catch (error) {
125
437
  lastErrorMessage = String(error);
126
- log(`connect failed: ${lastErrorMessage}`);
438
+ log(`connect failed: ${lastErrorMessage}`, "error");
127
439
  setTimeout(connect, retryWaitMs);
128
440
  return;
129
441
  }
130
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
+
131
451
  attachHandler(socket, "open", () => {
452
+ clearTimeout(connectTimer);
132
453
  isOpen = true;
133
454
  retryCount = 0;
134
- 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
+
486
+ if (sendProjectTree) {
487
+ emitProjectTree();
488
+ }
489
+ if (enableApiCheck) {
490
+ void (async () => {
491
+ if (!apiEndpointCandidatesCached.length) {
492
+ await fetchApiEndpointList();
493
+ }
494
+ logApiCheckStatus();
495
+ if (apiEndpointCandidatesCached.length) {
496
+ await runApiCheck();
497
+ }
498
+ })();
499
+ }
500
+ if (enableScreenCheck) {
501
+ void (async () => {
502
+ if (!screenCandidatesCached.length) {
503
+ await fetchScreenList();
504
+ }
505
+ logScreenCheckStatus();
506
+ })();
507
+ }
135
508
  while (queue.length) {
136
509
  const payload = queue.shift();
137
510
  if (!isSocketOpen()) {
@@ -147,7 +520,52 @@ const connect = () => {
147
520
  }
148
521
  });
149
522
 
523
+ attachHandler(socket, "message", (event) => {
524
+ const raw =
525
+ event && typeof event === "object" && "data" in event ? event.data : event;
526
+ if (!raw) return;
527
+ const text =
528
+ typeof raw === "string"
529
+ ? raw
530
+ : typeof raw.toString === "function"
531
+ ? raw.toString()
532
+ : "";
533
+ if (!text || !text.trim().startsWith("{")) return;
534
+ let payload = null;
535
+ try {
536
+ payload = JSON.parse(text);
537
+ } catch (error) {
538
+ return;
539
+ }
540
+ if (!payload || typeof payload !== "object") return;
541
+ if (payload.type !== "control") return;
542
+ log(`[control] received action=${payload.action}`, "info");
543
+ if (payload.action === "api_check") {
544
+ log("[control] triggering api_check", "info");
545
+ void triggerApiCheck("control");
546
+ return;
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
+ }
554
+ if (payload.action === "screen_check") {
555
+ if (enableScreenCheck) {
556
+ void triggerScreenCheck("control");
557
+ } else {
558
+ sendScreenCheckLine("screen check disabled; skipping.");
559
+ }
560
+ }
561
+ if (payload.action === "security_check") {
562
+ log("[control] triggering security_check", "info");
563
+ void triggerSecurityCheck("control");
564
+ }
565
+ });
566
+
150
567
  attachHandler(socket, "close", (event) => {
568
+ clearTimeout(connectTimer);
151
569
  isOpen = false;
152
570
  const code =
153
571
  event && typeof event === "object" && "code" in event ? event.code : null;
@@ -155,13 +573,23 @@ const connect = () => {
155
573
  event && typeof event === "object" && "reason" in event
156
574
  ? event.reason
157
575
  : "";
158
- const detail = [code ? `code=${code}` : null, reason || null]
576
+ const reasonStr = reason ? String(reason) : "";
577
+ const detail = [code ? `code=${code}` : null, reasonStr || null]
159
578
  .filter(Boolean)
160
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
+ }
161
588
  log(
162
589
  `disconnected; retrying in ${retryWaitMs}ms${
163
590
  detail ? ` (${detail})` : ""
164
591
  }`,
592
+ "warn",
165
593
  );
166
594
  setTimeout(connect, retryWaitMs);
167
595
  });
@@ -172,7 +600,7 @@ const connect = () => {
172
600
  ? error.message
173
601
  : String(error);
174
602
  lastErrorMessage = message;
175
- log(`error: ${message}`);
603
+ log(`error: ${message}`, "warn");
176
604
  });
177
605
  };
178
606
 
@@ -196,13 +624,96 @@ const enqueueOrSend = (payload) => {
196
624
  queue.push(payload);
197
625
  };
198
626
 
199
- const handleLine = (line) => {
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);
200
634
  const payload = JSON.stringify({
201
635
  type: "log",
202
- content: line,
636
+ content: lines.join("\n"),
203
637
  timestamp: new Date().toISOString(),
204
638
  });
205
639
  enqueueOrSend(payload);
640
+ };
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
+
658
+ const sendApiCheckLine = (line) => {
659
+ const tagged = `[api-check] ${line}`;
660
+ log(tagged, "debug");
661
+ sendLogLine(tagged);
662
+ };
663
+
664
+ const sendCodeAnalysisLine = (line) => {
665
+ const tagged = `[code-analysis] ${line}`;
666
+ log(tagged, "info");
667
+ sendLogLine(tagged);
668
+ };
669
+
670
+ const sendScreenCheckLine = (line) => {
671
+ const tagged = `[screen-check] ${line}`;
672
+ log(tagged, "debug");
673
+ sendLogLine(tagged);
674
+ };
675
+
676
+ const sendSecurityCheckLine = (line) => {
677
+ const tagged = `[security-check] ${line}`;
678
+ log(tagged, "info");
679
+ sendLogLine(tagged);
680
+ };
681
+
682
+ const logApiCheckStatus = () => {
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)";
692
+ sendApiCheckLine(
693
+ `必須チェック: api_endpoint_list=${statusLabel} openai_api_key=${
694
+ openaiApiKey ? "OK" : "NG"
695
+ } ${
696
+ maskedKey ? `(${maskedKey})` : ""
697
+ } openai_model=${openaiModel ? "OK" : "NG"}`,
698
+ );
699
+ };
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
+
715
+ const handleLine = (line) => {
716
+ sendLogLine(line);
206
717
  if (isErrorLine(line)) {
207
718
  const alertPayload = JSON.stringify({
208
719
  type: "alert",
@@ -213,6 +724,667 @@ const handleLine = (line) => {
213
724
  }
214
725
  };
215
726
 
727
+ const emitProjectTree = () => {
728
+ const lines = buildProjectTreeLines(
729
+ projectRoot,
730
+ DEFAULT_SNAPSHOT_EXCLUDES,
731
+ projectTreeMaxFiles,
732
+ includeProjectTreeContents,
733
+ projectTreeMaxBytes,
734
+ );
735
+ lines.forEach((line) => {
736
+ log(line, "debug");
737
+ sendLogLine(line);
738
+ });
739
+ };
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
+
751
+ const buildApiCheckPrompt = (endpoints, treeLines) => {
752
+ return [
753
+ "You are a senior backend engineer. Determine which API endpoints are NOT implemented in the codebase.",
754
+ "Use the file tree (and optional file excerpts) to infer whether each endpoint is implemented.",
755
+ "If unsure, mark as 'unknown'.",
756
+ "Return JSON only: {\"missing\": [{\"endpoint\": \"METHOD /path\", \"reason\": \"...\", \"confidence\": 0-1}], \"unknown\": [\"METHOD /path\"], \"implemented\": [\"METHOD /path\"]}",
757
+ "",
758
+ "API endpoints:",
759
+ ...endpoints.map((item) => `- ${item}`),
760
+ "",
761
+ "Project files:",
762
+ ...treeLines,
763
+ ].join("\n");
764
+ };
765
+
766
+ const collectTreeForApiCheck = () => {
767
+ return buildProjectTreeLines(
768
+ projectRoot,
769
+ DEFAULT_SNAPSHOT_EXCLUDES,
770
+ projectTreeMaxFiles,
771
+ includeProjectTreeContents,
772
+ projectTreeMaxBytes,
773
+ );
774
+ };
775
+
776
+ const runApiCheck = async () => {
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
+ }
785
+ if (!openaiApiKey) {
786
+ sendApiCheckLine("OPENAI_API_KEY is not set; すべて未実装として扱います。");
787
+ return allMissingResult();
788
+ }
789
+ if (!openaiModel) {
790
+ sendApiCheckLine("OPENAI_MODEL is not set; すべて未実装として扱います。");
791
+ return allMissingResult();
792
+ }
793
+ if (typeof fetch !== "function") {
794
+ sendApiCheckLine("fetch is not available in this runtime; すべて未実装として扱います。");
795
+ return allMissingResult();
796
+ }
797
+ const treeLines = collectTreeForApiCheck();
798
+ const prompt = buildApiCheckPrompt(apiEndpointCandidatesCached, treeLines);
799
+ const payload = {
800
+ model: openaiModel,
801
+ messages: [
802
+ {
803
+ role: "system",
804
+ content:
805
+ "You return strictly valid JSON. Do not wrap in markdown or add commentary.",
806
+ },
807
+ { role: "user", content: prompt },
808
+ ],
809
+ temperature: 0.2,
810
+ };
811
+ let response = null;
812
+ try {
813
+ response = await fetch(OPENAI_CHAT_API_URL, {
814
+ method: "POST",
815
+ headers: {
816
+ Authorization: `Bearer ${openaiApiKey}`,
817
+ "Content-Type": "application/json",
818
+ },
819
+ body: JSON.stringify(payload),
820
+ });
821
+ } catch (error) {
822
+ sendApiCheckLine(`OpenAI request failed: ${error}`);
823
+ return allMissingResult();
824
+ }
825
+ if (!response.ok) {
826
+ const text = await response.text();
827
+ sendApiCheckLine(
828
+ `OpenAI request failed: ${response.status} ${response.statusText}`,
829
+ );
830
+ sendApiCheckLine(text.slice(0, 1000));
831
+ return allMissingResult();
832
+ }
833
+ let data = null;
834
+ try {
835
+ data = await response.json();
836
+ } catch (error) {
837
+ sendApiCheckLine(`OpenAI response parse failed: ${error}`);
838
+ return allMissingResult();
839
+ }
840
+ const content = data?.choices?.[0]?.message?.content?.trim();
841
+ if (!content) {
842
+ sendApiCheckLine("OpenAI response was empty.");
843
+ return allMissingResult();
844
+ }
845
+ try {
846
+ const parsed = JSON.parse(content);
847
+ const missing = Array.isArray(parsed?.missing) ? parsed.missing : [];
848
+ const unknown = Array.isArray(parsed?.unknown) ? parsed.unknown : [];
849
+ const implemented = Array.isArray(parsed?.implemented)
850
+ ? parsed.implemented
851
+ : [];
852
+ sendApiCheckLine(
853
+ `missing=${missing.length} unknown=${unknown.length} implemented=${implemented.length}`,
854
+ );
855
+ const missingItems = [];
856
+ missing.forEach((item) => {
857
+ if (item && typeof item === "object") {
858
+ const label = item.endpoint || "";
859
+ missingItems.push(label);
860
+ sendApiCheckLine(
861
+ `missing: ${label} (${item.reason || "no reason"})`,
862
+ );
863
+ } else {
864
+ missingItems.push(String(item));
865
+ sendApiCheckLine(`missing: ${String(item)}`);
866
+ }
867
+ });
868
+ const implementedItems = [];
869
+ unknown.forEach((item) => sendApiCheckLine(`unknown: ${String(item)}`));
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
+ };
880
+ } catch (error) {
881
+ sendApiCheckLine("OpenAI output was not valid JSON. Raw output:");
882
+ sendApiCheckLine(content.slice(0, 2000));
883
+ return allMissingResult();
884
+ }
885
+ };
886
+
887
+ const screenCheckUrl = `${normalizeBaseHttpUrl(baseUrl)}/api/v1/project_snapshot/check`;
888
+
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
+ };
894
+ if (!projectKey) {
895
+ sendScreenCheckLine("project_key is missing; すべて未実装として扱います。");
896
+ return allMissingResult();
897
+ }
898
+ if (typeof fetch !== "function") {
899
+ sendScreenCheckLine("fetch is not available in this runtime; すべて未実装として扱います。");
900
+ return allMissingResult();
901
+ }
902
+ const paths = collectSnapshotPaths(projectRoot, DEFAULT_SNAPSHOT_EXCLUDES).filter(
903
+ (item) => item.startsWith("frontend/"),
904
+ );
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
+ }
917
+ const payload = {
918
+ projectPublicId: projectKey,
919
+ paths: limitedPaths,
920
+ };
921
+ let response = null;
922
+ try {
923
+ response = await fetch(screenCheckUrl, {
924
+ method: "POST",
925
+ headers: { "Content-Type": "application/json" },
926
+ body: JSON.stringify(payload),
927
+ });
928
+ } catch (error) {
929
+ sendScreenCheckLine(`screen check request failed: ${error}`);
930
+ return allMissingResult();
931
+ }
932
+ if (!response.ok) {
933
+ const text = await response.text();
934
+ sendScreenCheckLine(
935
+ `screen check failed: ${response.status} ${response.statusText}`,
936
+ );
937
+ sendScreenCheckLine(text.slice(0, 1000));
938
+ return allMissingResult();
939
+ }
940
+ let data = null;
941
+ try {
942
+ data = await response.json();
943
+ } catch (error) {
944
+ sendScreenCheckLine(`screen check response parse failed: ${error}`);
945
+ return allMissingResult();
946
+ }
947
+ const expected = screenCandidatesCached.length
948
+ ? screenCandidatesCached.map(toScreenName)
949
+ : (Array.isArray(data?.expectedScreens) ? data.expectedScreens : []);
950
+ const rawMissing = Array.isArray(data?.check?.missingScreens)
951
+ ? data.check.missingScreens
952
+ : [];
953
+ // expected をキャッシュに揃えたので、missing も expected に含まれるもののみに絞る
954
+ const expectedSet = new Set(expected.map(String));
955
+ const missing = rawMissing.filter((item) => expectedSet.has(String(item)));
956
+ const implemented = expected.filter((item) => !missing.includes(item));
957
+ sendScreenCheckLine(
958
+ `missing_screens=${missing.length} expected=${expected.length} implemented=${implemented.length}`,
959
+ );
960
+ missing.forEach((item) => sendScreenCheckLine(`missing: ${String(item)}`));
961
+ implemented.forEach((item) =>
962
+ sendScreenCheckLine(`implemented: ${String(item)}`),
963
+ );
964
+ if (data?.check?.notes) {
965
+ sendScreenCheckLine(`note: ${String(data.check.notes)}`);
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
+ };
973
+ };
974
+
975
+ const triggerApiCheck = async (reason) => {
976
+ const emptyResult = { api_missing_count: 0, api_missing_items: [], api_implemented_count: 0, api_implemented_items: [] };
977
+ if (apiCheckInFlight) return emptyResult;
978
+ apiCheckInFlight = true;
979
+ try {
980
+ sendApiCheckLine(`api-check start (${reason})`);
981
+ if (!apiEndpointCandidatesCached.length) {
982
+ await fetchApiEndpointList();
983
+ }
984
+ logApiCheckStatus();
985
+ if (!apiEndpointCandidatesCached.length) {
986
+ sendApiCheckLine("api_endpoint_list is empty; skipping api-check.");
987
+ return emptyResult;
988
+ }
989
+ return await runApiCheck();
990
+ } finally {
991
+ apiCheckInFlight = false;
992
+ }
993
+ };
994
+
995
+ const triggerScreenCheck = async (reason) => {
996
+ const emptyResult = { screen_missing_count: 0, screen_missing_items: [], screen_implemented_count: 0, screen_implemented_items: [] };
997
+ try {
998
+ sendScreenCheckLine(`screen-check start (${reason})`);
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();
1008
+ } catch (error) {
1009
+ sendScreenCheckLine(`screen-check failed: ${error}`);
1010
+ return emptyResult;
1011
+ }
1012
+ };
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
+
1267
+ const fetchApiEndpointList = async () => {
1268
+ if (!apiEndpointUrl) return [];
1269
+ if (typeof fetch !== "function") {
1270
+ sendApiCheckLine("fetch is not available in this runtime; skipping fetch.");
1271
+ apiEndpointFetchStatus = "error";
1272
+ apiEndpointFetchDetail = "fetch_unavailable";
1273
+ return [];
1274
+ }
1275
+ try {
1276
+ const response = await fetch(apiEndpointUrl, {
1277
+ method: "GET",
1278
+ headers: { "Content-Type": "application/json" },
1279
+ });
1280
+ if (!response.ok) {
1281
+ let detail = "";
1282
+ try {
1283
+ detail = await response.text();
1284
+ } catch (error) {
1285
+ detail = "";
1286
+ }
1287
+ const detailSnippet = detail ? ` detail=${detail.slice(0, 200)}` : "";
1288
+ sendApiCheckLine(
1289
+ `api_endpoint_list取得失敗: ${response.status} ${response.statusText} (${apiEndpointUrl})${detailSnippet}`,
1290
+ );
1291
+ apiEndpointFetchStatus = "error";
1292
+ apiEndpointFetchDetail = `${response.status}`;
1293
+ return [];
1294
+ }
1295
+ const data = await response.json();
1296
+ if (data?.api_endpoint_list) {
1297
+ apiEndpointCandidatesCached = extractApiEndpointItems(
1298
+ data.api_endpoint_list,
1299
+ );
1300
+ apiEndpointFetchStatus = apiEndpointCandidatesCached.length
1301
+ ? "ok"
1302
+ : "ok_empty";
1303
+ apiEndpointFetchDetail = "";
1304
+ return apiEndpointCandidatesCached;
1305
+ }
1306
+ if (Array.isArray(data?.endpoints)) {
1307
+ apiEndpointCandidatesCached = extractApiEndpointItems(
1308
+ JSON.stringify({ endpoints: data.endpoints }),
1309
+ );
1310
+ apiEndpointFetchStatus = apiEndpointCandidatesCached.length
1311
+ ? "ok"
1312
+ : "ok_empty";
1313
+ apiEndpointFetchDetail = "";
1314
+ return apiEndpointCandidatesCached;
1315
+ }
1316
+ apiEndpointFetchStatus = "ok_empty";
1317
+ apiEndpointFetchDetail = "";
1318
+ return [];
1319
+ } catch (error) {
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";
1384
+ return [];
1385
+ }
1386
+ };
1387
+
216
1388
  const startFileTailer = (targetPath) => {
217
1389
  let fileOffset = 0;
218
1390
  let remainder = "";
@@ -223,7 +1395,7 @@ const startFileTailer = (targetPath) => {
223
1395
  fileOffset = stats.size;
224
1396
  return true;
225
1397
  } catch (error) {
226
- log(`log-path not found: ${targetPath} (retrying...)`);
1398
+ log(`log-path not found: ${targetPath} (retrying...)`, "warn");
227
1399
  return false;
228
1400
  }
229
1401
  };
@@ -242,7 +1414,7 @@ const startFileTailer = (targetPath) => {
242
1414
  const readNewData = () => {
243
1415
  fs.stat(targetPath, (error, stats) => {
244
1416
  if (error) {
245
- log(`log-path read error: ${error.message || String(error)}`);
1417
+ log(`log-path read error: ${error.message || String(error)}`, "warn");
246
1418
  return;
247
1419
  }
248
1420
  if (stats.size < fileOffset) {
@@ -265,6 +1437,7 @@ const startFileTailer = (targetPath) => {
265
1437
  `log-path stream error: ${
266
1438
  streamError.message || String(streamError)
267
1439
  }`,
1440
+ "warn",
268
1441
  );
269
1442
  });
270
1443
  });
@@ -278,7 +1451,7 @@ const startFileTailer = (targetPath) => {
278
1451
  }
279
1452
  });
280
1453
  } catch (error) {
281
- log(`log-path watch error: ${error.message || String(error)}`);
1454
+ log(`log-path watch error: ${error.message || String(error)}`, "warn");
282
1455
  }
283
1456
  };
284
1457
 
@@ -295,21 +1468,23 @@ const startFileTailer = (targetPath) => {
295
1468
  startWatcher();
296
1469
  };
297
1470
 
298
- if (logPath) {
299
- startFileTailer(logPath);
300
- } else {
301
- const rl = readline.createInterface({
302
- input: process.stdin,
303
- crlfDelay: Infinity,
304
- });
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
+ });
305
1479
 
306
- rl.on("line", (line) => {
307
- handleLine(line);
308
- });
1480
+ rl.on("line", (line) => {
1481
+ handleLine(line);
1482
+ });
309
1483
 
310
- rl.on("close", () => {
311
- if (socket) {
312
- socket.close();
313
- }
314
- });
1484
+ rl.on("close", () => {
1485
+ if (socket) {
1486
+ socket.close();
1487
+ }
1488
+ });
1489
+ }
315
1490
  }