@genn-inc/cluebase-cli 0.0.1

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.
Files changed (38) hide show
  1. package/README.md +101 -0
  2. package/bin/cluebase-cli.mjs +11 -0
  3. package/package.json +17 -0
  4. package/src/cli-command.mjs +515 -0
  5. package/src/cli-invocation.mjs +17 -0
  6. package/src/code-evidence-analyzer.mjs +2041 -0
  7. package/src/contracts.mjs +36 -0
  8. package/src/generated-code-evidence-contract.mjs +22 -0
  9. package/src/generated-sdk-version-contract.mjs +5 -0
  10. package/src/generated-source-path-policy.mjs +20 -0
  11. package/src/lifecycle-guard.mjs +202 -0
  12. package/src/path-policy.mjs +81 -0
  13. package/src/setup-ai-contract.mjs +221 -0
  14. package/src/setup-check-constants.mjs +110 -0
  15. package/src/setup-check-scan-a.mjs +849 -0
  16. package/src/setup-check-scan-b.mjs +994 -0
  17. package/src/setup-check.mjs +575 -0
  18. package/src/setup-discover-check.mjs +755 -0
  19. package/src/setup-doctor-deadline.mjs +221 -0
  20. package/src/setup-doctor-env.mjs +331 -0
  21. package/src/setup-doctor-file-boundary.mjs +426 -0
  22. package/src/setup-doctor-probe.mjs +719 -0
  23. package/src/setup-doctor-quality-checks-a.mjs +593 -0
  24. package/src/setup-doctor-quality-checks-b.mjs +638 -0
  25. package/src/setup-doctor-quality-shared.mjs +382 -0
  26. package/src/setup-doctor-quality.mjs +209 -0
  27. package/src/setup-doctor-route-scan.mjs +160 -0
  28. package/src/setup-doctor-sdk-probe.mjs +340 -0
  29. package/src/setup-doctor.mjs +545 -0
  30. package/src/setup-documents.mjs +112 -0
  31. package/src/setup-help.mjs +130 -0
  32. package/src/setup-prepare.mjs +360 -0
  33. package/src/setup-repository-discovery.mjs +764 -0
  34. package/src/setup-step-builders-discover.mjs +701 -0
  35. package/src/setup-step-builders-events.mjs +229 -0
  36. package/src/setup-step-builders-implement.mjs +710 -0
  37. package/src/setup-step-commands.mjs +427 -0
  38. package/src/setup-tool.mjs +27 -0
@@ -0,0 +1,593 @@
1
+ // Data-quality checks (first half).
2
+ // Extracted from setup-doctor-quality for file-size limits; content is unchanged.
3
+
4
+ import {
5
+ ALLOWED_GROUP_TYPES,
6
+ DEV_HOST_PATTERNS,
7
+ FAKE_PLACEHOLDER_NAMES,
8
+ buildCheck,
9
+ buildErrorMessage,
10
+ countCluebaseInitCalls,
11
+ errorPass,
12
+ findDuplicateGroupOwnerViolations,
13
+ findHardcodedIdentifyLiterals,
14
+ findInitCalls,
15
+ groupSitesOf,
16
+ hasHardcodedKeys,
17
+ identifySitesOf,
18
+ initSitesOf,
19
+ isPlainObject,
20
+ isString,
21
+ matchesAny,
22
+ readSiteFile,
23
+ stringOrNull,
24
+ warnPass,
25
+ } from "./setup-doctor-quality-shared.mjs";
26
+
27
+ export const checkC1 = (discoveries) => {
28
+ const id = "C1";
29
+ const sites = identifySitesOf(discoveries);
30
+ if (sites.length === 0) return errorPass(id);
31
+ const offenders = [];
32
+ for (const site of sites) {
33
+ const fields = site?.available_fields;
34
+ if (!isPlainObject(fields)) continue;
35
+ const idPath = stringOrNull(fields.id);
36
+ const namePath = stringOrNull(fields.name);
37
+ if (idPath && namePath && idPath === namePath) {
38
+ offenders.push({ file: site.file, line: site.line, idPath, namePath });
39
+ }
40
+ }
41
+ if (offenders.length === 0) return errorPass(id);
42
+ const firstOffender = offenders[0];
43
+ return buildCheck({
44
+ id,
45
+ severity: "error",
46
+ passed: false,
47
+ message: buildErrorMessage({
48
+ detected: `cluebase.identify(${firstOffender.idPath}, { name: ${firstOffender.namePath} }) を検出`,
49
+ location: `${firstOffender.file}:${firstOffender.line}`,
50
+ impact:
51
+ "分析側で「ユーザー X さん」 と表示されるべき箇所が全部 raw ID 文字列 「user_a1b2c3d4」 並びになります。",
52
+ actions: [
53
+ "user model に name field を追加してください、 または",
54
+ "/cluebase-discover を再実行して email 代用に切り替えてください",
55
+ ],
56
+ }),
57
+ details: { offenders },
58
+ });
59
+ };
60
+
61
+ // C2: name path が null / undefined / 空文字 (= 表示名を取得できない環境)。
62
+ // STEP 5 の「name path が無ければ omit して続行 (name: id は禁止)」と整合させ、
63
+ // hard STOP ではなく WARN で通す。表示名なしでも identify / 分析は動作する。
64
+ // 名前偽装 (email や id を name に流用) は doctrine 違反なので remediation でも
65
+ // 指示しない。name == id の代入検出は別 check が ERROR として担う。
66
+ export const checkC2 = (discoveries) => {
67
+ const id = "C2";
68
+ const sites = identifySitesOf(discoveries);
69
+ if (sites.length === 0) return warnPass(id);
70
+ const offenders = [];
71
+ for (const site of sites) {
72
+ const fields = site?.available_fields;
73
+ if (!isPlainObject(fields)) continue;
74
+ const namePath = fields.name;
75
+ if (
76
+ namePath === null ||
77
+ namePath === undefined ||
78
+ (typeof namePath === "string" && namePath.trim() === "")
79
+ ) {
80
+ offenders.push({ file: site.file, line: site.line, namePath });
81
+ }
82
+ }
83
+ if (offenders.length === 0) return warnPass(id);
84
+ const first = offenders[0];
85
+ return buildCheck({
86
+ id,
87
+ severity: "warn",
88
+ passed: false,
89
+ message: `WARN: 表示名 (name) を取得できない identify site があります (offender: ${first.file}:${first.line})。name を省いて続行します — 名前なしでも identify / 分析は動作します。後から表示名を出したい場合は /cluebase-discover を再実行し、実在する表示名 field を name に指定してください (email や id を name に流用しないでください)。`,
90
+ details: { offenders },
91
+ });
92
+ };
93
+
94
+ // C9: identify が hardcoded literal で呼ばれている
95
+ export const checkC9 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
96
+ const id = "C9";
97
+ const sites = identifySitesOf(discoveries);
98
+ if (sites.length === 0) return errorPass(id);
99
+ const offenders = [];
100
+ for (const site of sites) {
101
+ if (!site?.file) continue;
102
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
103
+ if (content === null) continue;
104
+ const literalCalls = findHardcodedIdentifyLiterals(content);
105
+ for (const call of literalCalls) {
106
+ offenders.push({ file: site.file, line: site.line, literal: call });
107
+ }
108
+ }
109
+ if (offenders.length === 0) return errorPass(id);
110
+ const first = offenders[0];
111
+ return buildCheck({
112
+ id,
113
+ severity: "error",
114
+ passed: false,
115
+ message: buildErrorMessage({
116
+ detected: `cluebase.identify が hardcoded literal で呼ばれているのを検出 (例: ${first.literal})`,
117
+ location: first.file,
118
+ impact:
119
+ "全てのユーザーが同じ ID として記録され、 ユーザー識別が機能しません。 分析・顧客理解が崩壊します。",
120
+ actions: [
121
+ "literal を user.id 等の dynamic な path に差し替えてください",
122
+ "/cluebase-discover を再実行して available_fields.id を再検出してください",
123
+ ],
124
+ }),
125
+ details: { offenders },
126
+ });
127
+ };
128
+
129
+ // C10: name に fake placeholder
130
+ export const checkC10 = (discoveries) => {
131
+ const id = "C10";
132
+ const sites = identifySitesOf(discoveries);
133
+ if (sites.length === 0) return errorPass(id);
134
+ const offenders = [];
135
+ for (const site of sites) {
136
+ const fields = site?.available_fields;
137
+ if (!isPlainObject(fields)) continue;
138
+ const namePath = fields.name;
139
+ if (typeof namePath !== "string") continue;
140
+ const namePathLower = namePath.toLowerCase().trim();
141
+ // path 自体が literal string ("...") の場合に placeholder と判定
142
+ const literalMatch = namePath.match(/^["'`]([^"'`]+)["'`]$/);
143
+ if (literalMatch) {
144
+ const literalValue = literalMatch[1].toLowerCase().trim();
145
+ if (FAKE_PLACEHOLDER_NAMES.includes(literalValue)) {
146
+ offenders.push({ file: site.file, line: site.line, namePath });
147
+ continue;
148
+ }
149
+ }
150
+ // path に含まれる literal string 部分
151
+ if (
152
+ FAKE_PLACEHOLDER_NAMES.some((placeholder) =>
153
+ namePathLower.includes(`"${placeholder}"`) ||
154
+ namePathLower.includes(`'${placeholder}'`),
155
+ )
156
+ ) {
157
+ offenders.push({ file: site.file, line: site.line, namePath });
158
+ }
159
+ }
160
+ if (offenders.length === 0) return errorPass(id);
161
+ const first = offenders[0];
162
+ return buildCheck({
163
+ id,
164
+ severity: "error",
165
+ passed: false,
166
+ message: buildErrorMessage({
167
+ detected: `cluebase.identify の name に fake placeholder (例: ${first.namePath}) を検出`,
168
+ location: `${first.file}:${first.line}`,
169
+ impact:
170
+ "全ユーザーが同じ placeholder 名で識別され、 分析側で個別ユーザーの行動が混ざります。",
171
+ actions: [
172
+ "user model から実際の display 名を取得する path に差し替えてください",
173
+ "name が null になる可能性がある場合は email 等の代替 field を使ってください",
174
+ ],
175
+ }),
176
+ details: { offenders },
177
+ });
178
+ };
179
+
180
+ // C11: name path が email path と同じ (= 表示で email 重複露出)
181
+ export const checkC11 = (discoveries) => {
182
+ const id = "C11";
183
+ const sites = identifySitesOf(discoveries);
184
+ if (sites.length === 0) return warnPass(id);
185
+ const offenders = [];
186
+ for (const site of sites) {
187
+ const fields = site?.available_fields;
188
+ if (!isPlainObject(fields)) continue;
189
+ const namePath = stringOrNull(fields.name);
190
+ const emailPath = stringOrNull(fields.email);
191
+ if (namePath && emailPath && namePath === emailPath) {
192
+ offenders.push({ file: site.file, line: site.line, namePath, emailPath });
193
+ }
194
+ }
195
+ if (offenders.length === 0) return warnPass(id);
196
+ return buildCheck({
197
+ id,
198
+ severity: "warn",
199
+ passed: false,
200
+ message:
201
+ "WARN: cluebase.identify の name path が email path と一致しています。 顧客 UI で email が name 欄に重複露出します。 user.display_name 等の別 field 採用を検討してください。",
202
+ details: { offenders },
203
+ });
204
+ };
205
+
206
+ // ============================================================
207
+ // group (= 組織所属) 関連
208
+ // ============================================================
209
+
210
+ // C3: cluebase.group の name path が groupKey path と完全一致
211
+ export const checkC3 = (discoveries) => {
212
+ const id = "C3";
213
+ const sites = groupSitesOf(discoveries);
214
+ if (sites.length === 0) return errorPass(id);
215
+ const offenders = [];
216
+ for (const site of sites) {
217
+ const fields = site?.available_fields;
218
+ if (!isPlainObject(fields)) continue;
219
+ const keyPath =
220
+ stringOrNull(fields.groupKey) ??
221
+ stringOrNull(fields.group_key) ??
222
+ stringOrNull(fields.id);
223
+ const namePath = stringOrNull(fields.name);
224
+ if (keyPath && namePath && keyPath === namePath) {
225
+ offenders.push({ file: site.file, line: site.line, keyPath, namePath });
226
+ }
227
+ }
228
+ if (offenders.length === 0) return errorPass(id);
229
+ const first = offenders[0];
230
+ return buildCheck({
231
+ id,
232
+ severity: "error",
233
+ passed: false,
234
+ message: buildErrorMessage({
235
+ detected: `cluebase.group の name path (${first.namePath}) が groupKey path と一致を検出`,
236
+ location: `${first.file}:${first.line}`,
237
+ impact:
238
+ "組織名が raw ID 文字列で表示され、 workspace 会話で組織を識別できなくなります。",
239
+ actions: [
240
+ "group model に name / title field を追加してください、 または",
241
+ "/cluebase-discover を再実行して name 用の semantic path を再検出してください",
242
+ ],
243
+ }),
244
+ details: { offenders },
245
+ });
246
+ };
247
+
248
+ // C12: groupType literal が MVP public contract 以外
249
+ export const checkC12 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
250
+ const id = "C12";
251
+ const sites = groupSitesOf(discoveries);
252
+ if (sites.length === 0) return errorPass(id);
253
+ const offenders = [];
254
+ for (const site of sites) {
255
+ const fields = site?.available_fields;
256
+ if (isPlainObject(fields) && isString(fields.groupType)) {
257
+ const literal = fields.groupType.replace(/^["'`]|["'`]$/g, "").trim();
258
+ if (literal && !ALLOWED_GROUP_TYPES.includes(literal)) {
259
+ offenders.push({ file: site.file, line: site.line, groupType: literal });
260
+ }
261
+ }
262
+ // source code 上で cluebase.group("xxx", <key>, ...) も拾う
263
+ if (site?.file) {
264
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
265
+ if (content === null) continue;
266
+ const matches = content.matchAll(
267
+ /cluebase\.group\s*\(\s*["'`]([^"'`]+)["'`]/g,
268
+ );
269
+ for (const match of matches) {
270
+ const literal = match[1].trim();
271
+ if (literal && !ALLOWED_GROUP_TYPES.includes(literal)) {
272
+ offenders.push({ file: site.file, line: site.line, groupType: literal });
273
+ }
274
+ }
275
+ }
276
+ }
277
+ if (offenders.length === 0) return errorPass(id);
278
+ const first = offenders[0];
279
+ return buildCheck({
280
+ id,
281
+ severity: "error",
282
+ passed: false,
283
+ message: buildErrorMessage({
284
+ detected: `cluebase.group の groupType に許可外の literal を検出 (offender: "${first.groupType}")`,
285
+ location: `${first.file}:${first.line}`,
286
+ impact: `MVP の groupType は ${ALLOWED_GROUP_TYPES.join(" / ")} のみです。account/workspace は将来導入予定であり、今使うと Cluebase の会社+ユーザー evidence が分散します。`,
287
+ actions: [
288
+ `groupType を ${ALLOWED_GROUP_TYPES.join(" / ")} に修正してください`,
289
+ ],
290
+ }),
291
+ details: { offenders, allowed: [...ALLOWED_GROUP_TYPES] },
292
+ });
293
+ };
294
+
295
+ // C13: group が identify 未挿入の boundary で呼ばれている (= orphan group)
296
+ //
297
+ // identify call が application 全体に 1 件でも存在すれば、 boundary
298
+ // は「同 app 内の login flow 経由」 と認識して pass。 identify 0 件 + group
299
+ // 1+ 件のみを warn (= 真の orphan, identify を入れる必要あり)。 production
300
+ // reach への影響なし + 偽陽性を排除する。
301
+ export const checkC13 = (discoveries) => {
302
+ const id = "C13";
303
+ const groupSites = groupSitesOf(discoveries);
304
+ if (groupSites.length === 0) return warnPass(id);
305
+ const duplicateOwnerViolations = findDuplicateGroupOwnerViolations(groupSites);
306
+ if (duplicateOwnerViolations.length > 0) {
307
+ const first = duplicateOwnerViolations[0];
308
+ return buildCheck({
309
+ id,
310
+ severity: "error",
311
+ passed: false,
312
+ message: buildErrorMessage({
313
+ detected:
314
+ "cluebase.group の active-context owner と create/join/switch handler の重複配置を検出",
315
+ location: `${first.activeOwner.file}:${first.activeOwner.line ?? "?"}`,
316
+ impact:
317
+ "同じ active organization に対して organization_associated が二重送信されます。SDK 側で隠すのではなく、setup の呼び出し位置を一箇所に統一する必要があります。",
318
+ actions: [
319
+ "active-context owner を唯一の cluebase.group 呼び出し元にしてください",
320
+ "create / join / switch handler 側の cluebase.group を削除し、active state 更新だけにしてください",
321
+ "/cluebase-discover-review を再実行して group_sites を de-dupe してください",
322
+ ],
323
+ }),
324
+ details: { violations: duplicateOwnerViolations },
325
+ });
326
+ }
327
+ const identifyCount = identifySitesOf(discoveries).filter((site) =>
328
+ isString(site?.file),
329
+ ).length;
330
+ if (identifyCount > 0) return warnPass(id);
331
+ // identify が application に 1 件もない + group が 1+ 件存在する = 真の
332
+ // orphan。 ユーザー識別前に組織所属が記録されると、 顧客行動が「組織のみ・
333
+ // ユーザー不在」 として残るため warn。
334
+ return buildCheck({
335
+ id,
336
+ severity: "warn",
337
+ passed: false,
338
+ message: `WARN: identify 挿入が無いまま group が呼ばれている (${groupSites.length} 件)。 application 全体で identify call site が見つかりません。 ユーザー識別前に組織所属が記録されると、 顧客行動が「組織のみ・ユーザー不在」 として残ります。 login boundary に identify を追加してください。`,
339
+ details: { group_sites: groupSites.slice(0, 5) },
340
+ });
341
+ };
342
+
343
+ // C14: MVP で deferred の account/workspace context が漏れていないか
344
+ export const checkC14 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
345
+ const id = "C14";
346
+ const sites = groupSitesOf(discoveries);
347
+ if (sites.length === 0) return errorPass(id);
348
+ const offenders = [];
349
+ for (const site of sites) {
350
+ if (!site?.file) continue;
351
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
352
+ if (content === null) continue;
353
+ // cluebase.group("account" | "workspace", ...) と workspace/account trait leak を検出。
354
+ const deferredGroupType = content.match(
355
+ /cluebase\.group\s*\(\s*["'`](account|workspace)["'`]/i,
356
+ );
357
+ const accountWithWorkspace = content.match(
358
+ /cluebase\.group\s*\(\s*["'`]account["'`][^)]*[{,\s"']workspace[_-]?id\s*[:=]/i,
359
+ );
360
+ const workspaceWithAccount = content.match(
361
+ /cluebase\.group\s*\(\s*["'`]workspace["'`][^)]*[{,\s"']account[_-]?id\s*[:=]/i,
362
+ );
363
+ const deferredIdentityTrait = content.match(
364
+ /cluebase\.group\s*\([^)]*{[^)]*["'`]?(?:account|workspace)[_-]?id["'`]?\s*[:=]/i,
365
+ );
366
+ if (deferredGroupType) {
367
+ offenders.push({
368
+ file: site.file,
369
+ line: site.line,
370
+ issue: `MVP 対象外 groupType ${deferredGroupType[1]} を使っている`,
371
+ });
372
+ }
373
+ if (accountWithWorkspace) {
374
+ offenders.push({
375
+ file: site.file,
376
+ line: site.line,
377
+ issue: "MVP 対象外の account/workspace trait を渡している",
378
+ });
379
+ }
380
+ if (deferredIdentityTrait) {
381
+ offenders.push({
382
+ file: site.file,
383
+ line: site.line,
384
+ issue: "MVP 対象外の account/workspace trait を渡している",
385
+ });
386
+ }
387
+ if (workspaceWithAccount) {
388
+ offenders.push({
389
+ file: site.file,
390
+ line: site.line,
391
+ issue: "MVP 対象外の workspace/account trait を渡している",
392
+ });
393
+ }
394
+ }
395
+ if (offenders.length === 0) return errorPass(id);
396
+ const first = offenders[0];
397
+ return buildCheck({
398
+ id,
399
+ severity: "error",
400
+ passed: false,
401
+ message: buildErrorMessage({
402
+ detected: `MVP 対象外の group context を検出: ${first.issue}`,
403
+ location: `${first.file}:${first.line}`,
404
+ impact:
405
+ "MVP では会社+ユーザーだけを Cluebase の public identity contract にするため、account/workspace を混ぜると同じ会社の evidence が分散します。",
406
+ actions: [
407
+ 'cluebase.group("organization", organizationId, traits) に揃えてください',
408
+ "account/workspace は将来導入時に改めて contract を決めます",
409
+ ],
410
+ }),
411
+ details: { offenders },
412
+ });
413
+ };
414
+
415
+ // ============================================================
416
+ // init (= bootstrap) 関連
417
+ // ============================================================
418
+
419
+ // C4: cluebase.init の projectKey が env から読まれていない
420
+ export const checkC4 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
421
+ const id = "C4";
422
+ const initSites = initSitesOf(discoveries);
423
+ if (initSites.length === 0) return errorPass(id);
424
+ const offenders = [];
425
+ for (const site of initSites) {
426
+ if (!site?.file) continue;
427
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
428
+ if (content === null) continue;
429
+ const initCalls = findInitCalls(content);
430
+ for (const call of initCalls) {
431
+ if (hasHardcodedKeys(call)) {
432
+ offenders.push({ file: site.file, snippet: call });
433
+ }
434
+ }
435
+ }
436
+ if (offenders.length === 0) return errorPass(id);
437
+ const first = offenders[0];
438
+ return buildCheck({
439
+ id,
440
+ severity: "error",
441
+ passed: false,
442
+ message: buildErrorMessage({
443
+ detected: `cluebase.init の projectKey が hardcoded で書かれているのを検出`,
444
+ location: first.file,
445
+ impact:
446
+ "projectKey が repository に commit されると、環境別 (dev / prod) の切替が不可能になります。",
447
+ actions: [
448
+ "backend は server env、browser code は framework に合う public env / runtime config から projectKey を読むよう変更してください",
449
+ ],
450
+ }),
451
+ details: { offenders },
452
+ });
453
+ };
454
+
455
+ // C15: endpoint が localhost / 127.0.0.1 / dev URL に hardcode
456
+ export const checkC15 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
457
+ const id = "C15";
458
+ const initSites = initSitesOf(discoveries);
459
+ if (initSites.length === 0) return errorPass(id);
460
+ const offenders = [];
461
+ for (const site of initSites) {
462
+ if (!site?.file) continue;
463
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
464
+ if (content === null) continue;
465
+ const initCalls = findInitCalls(content);
466
+ for (const call of initCalls) {
467
+ const endpointMatch = call.match(/endpoint\s*[:=]\s*["'`]([^"'`]+)["'`]/);
468
+ if (endpointMatch) {
469
+ const endpoint = endpointMatch[1];
470
+ if (matchesAny(endpoint, DEV_HOST_PATTERNS)) {
471
+ offenders.push({ file: site.file, endpoint });
472
+ }
473
+ }
474
+ }
475
+ }
476
+ if (offenders.length === 0) return errorPass(id);
477
+ const first = offenders[0];
478
+ return buildCheck({
479
+ id,
480
+ severity: "error",
481
+ passed: false,
482
+ message: buildErrorMessage({
483
+ detected: `cluebase.init endpoint に dev URL hardcoded: ${first.endpoint}`,
484
+ location: first.file,
485
+ impact:
486
+ "production deploy 時に dev endpoint へ送信され、 顧客イベントが失われます。",
487
+ actions: [
488
+ "frontend cluebase.init は framework ごとの public Cluebase API base URL env から endpoint を読むよう変更してください",
489
+ "backend cluebase.init は CLUEBASE_INGEST_ENDPOINT から endpoint を読むよう変更してください",
490
+ ],
491
+ }),
492
+ details: { offenders },
493
+ });
494
+ };
495
+
496
+ const backendInitSitesOf = (discoveries) => {
497
+ if (!isPlainObject(discoveries)) return [];
498
+ const entry = discoveries.cluebase_init_backend;
499
+ return isPlainObject(entry) && isString(entry.file) ? [entry] : [];
500
+ };
501
+
502
+ // C16: backend projectKey と serviceKey が同じ value (= 取り違え)
503
+ export const checkC16 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
504
+ const id = "C16";
505
+ const initSites = backendInitSitesOf(discoveries);
506
+ if (initSites.length === 0) return errorPass(id);
507
+ const offenders = [];
508
+ for (const site of initSites) {
509
+ if (!site?.file) continue;
510
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
511
+ if (content === null) continue;
512
+ const initCalls = findInitCalls(content);
513
+ for (const call of initCalls) {
514
+ const projectKeyMatch = call.match(/projectKey\s*[:=]\s*([^,}\n]+)/);
515
+ const serviceKeyMatch = call.match(/serviceKey\s*[:=]\s*([^,}\n]+)/);
516
+ if (projectKeyMatch && serviceKeyMatch) {
517
+ const pk = projectKeyMatch[1].trim().replace(/[,;]$/, "").trim();
518
+ const sk = serviceKeyMatch[1].trim().replace(/[,;]$/, "").trim();
519
+ if (pk === sk) {
520
+ offenders.push({ file: site.file, value: pk });
521
+ }
522
+ }
523
+ }
524
+ }
525
+ if (offenders.length === 0) return errorPass(id);
526
+ const first = offenders[0];
527
+ return buildCheck({
528
+ id,
529
+ severity: "error",
530
+ passed: false,
531
+ message: buildErrorMessage({
532
+ detected: `backend cluebase.init で projectKey と serviceKey に同じ値 (${first.value}) を指定`,
533
+ location: first.file,
534
+ impact:
535
+ "project と backend service の役割が混同され、event attribution が不正になります。",
536
+ actions: [
537
+ "projectKey は pk_dev_xxx / pk_prod_xxx 形式 (project 全体の識別)",
538
+ "backend serviceKey には projectKey とは別の値を指定してください",
539
+ "別々の値を指定してください",
540
+ ],
541
+ }),
542
+ details: { offenders },
543
+ });
544
+ };
545
+
546
+ // C17: init が同 surface (= frontend / backend) 内で複数回呼ばれている
547
+ // frontend/backend は別 process のため、各 surface 1 init は normal (= WARN
548
+ // しない)。同 surface 内で 2+ の時だけ WARN。
549
+ export const checkC17 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
550
+ const id = "C17";
551
+ const sitesByPurpose = {
552
+ frontend: isPlainObject(discoveries?.cluebase_init_frontend) &&
553
+ isString(discoveries.cluebase_init_frontend.file)
554
+ ? discoveries.cluebase_init_frontend
555
+ : null,
556
+ backend: isPlainObject(discoveries?.cluebase_init_backend) &&
557
+ isString(discoveries.cluebase_init_backend.file)
558
+ ? discoveries.cluebase_init_backend
559
+ : null,
560
+ };
561
+ const violations = [];
562
+ for (const [purpose, site] of Object.entries(sitesByPurpose)) {
563
+ if (!site?.file) continue;
564
+ const content = await readSiteFile({
565
+ repoRoot,
566
+ file: site.file,
567
+ signal,
568
+ sourceFiles,
569
+ });
570
+ if (content === null) continue;
571
+ const callCount = countCluebaseInitCalls(content);
572
+ if (callCount > 1) {
573
+ violations.push({ purpose, file: site.file, callCount });
574
+ }
575
+ }
576
+ if (violations.length === 0) return warnPass(id);
577
+ const messages = violations.map(
578
+ (v) => `${v.purpose} (${v.file}) で ${v.callCount} 回`,
579
+ );
580
+ return buildCheck({
581
+ id,
582
+ severity: "warn",
583
+ passed: false,
584
+ message: `WARN: cluebase.init が同 surface 内で複数回呼ばれています: ${messages.join(", ")}。 frontend / backend は別 process なので各 surface 1 init は normal、 同 surface 内 2+ のみ問題です。`,
585
+ details: { violations },
586
+ });
587
+ };
588
+
589
+ // ============================================================
590
+ // insertion site 関連
591
+ // ============================================================
592
+
593
+ // C6: discoveries.json の identify_sites 全件に挿入完了