@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,638 @@
1
+ // Data-quality checks (second half).
2
+ // Extracted from setup-doctor-quality for file-size limits; content is unchanged.
3
+
4
+ import { createRequire } from "node:module";
5
+ import { resolve } from "node:path";
6
+ import {
7
+ COMMON_ENV_KEY_TYPOS,
8
+ PLACEHOLDER_ENV_VALUES,
9
+ REQUIRED_PROJECT_KEY_ENV_NAMES,
10
+ SENSITIVE_FIELD_PATTERNS,
11
+ TEST_DIR_PATTERNS,
12
+ VALID_SDK_PACKAGES,
13
+ VENDORED_DIR_PATTERNS,
14
+ WRONG_SDK_PACKAGE_PATTERNS,
15
+ allInsertionSitesOf,
16
+ buildCheck,
17
+ buildErrorMessage,
18
+ errorPass,
19
+ identifySitesOf,
20
+ initSitesOf,
21
+ isPlainObject,
22
+ isString,
23
+ matchesAny,
24
+ readSiteFile,
25
+ resetSitesOf,
26
+ warnPass,
27
+ } from "./setup-doctor-quality-shared.mjs";
28
+
29
+ export const checkC6 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
30
+ const id = "C6";
31
+ const sites = identifySitesOf(discoveries);
32
+ if (sites.length === 0) return errorPass(id);
33
+ const missing = [];
34
+ for (const site of sites) {
35
+ if (!site?.file) continue;
36
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
37
+ if (content === null || !/cluebase\.identify\s*\(/.test(content)) {
38
+ missing.push({ file: site.file, line: site.line });
39
+ }
40
+ }
41
+ if (missing.length === 0) return errorPass(id);
42
+ const first = missing[0];
43
+ return buildCheck({
44
+ id,
45
+ severity: "error",
46
+ passed: false,
47
+ message: buildErrorMessage({
48
+ detected: `discoveries.json の identify_sites のうち ${missing.length} 件に cluebase.identify 挿入なし`,
49
+ location: `${first.file}:${first.line}`,
50
+ impact:
51
+ "認識すべきユーザー login flow の一部が漏れ、 そのルート経由の顧客が分析対象から欠落します。",
52
+ actions: [
53
+ "/cluebase-implement を再実行して全 site に挿入を完了させてください",
54
+ "manual で挿入する場合は全 identify_sites を確認してください",
55
+ ],
56
+ }),
57
+ details: { missing },
58
+ });
59
+ };
60
+
61
+ // C7: reset_sites と identify_sites の挿入件数が大きく mismatch
62
+ export const checkC7 = (discoveries) => {
63
+ const id = "C7";
64
+ const identifyCount = identifySitesOf(discoveries).length;
65
+ const resetCount = resetSitesOf(discoveries).length;
66
+ if (identifyCount === 0 && resetCount === 0) return warnPass(id);
67
+ const diff = Math.abs(identifyCount - resetCount);
68
+ // identify がある場合は logout も同程度あるべき。 0 でも ratio 50% 以上の diff は warn。
69
+ const threshold = Math.max(1, Math.floor(Math.max(identifyCount, resetCount) / 2));
70
+ if (diff <= threshold) return warnPass(id);
71
+ return buildCheck({
72
+ id,
73
+ severity: "warn",
74
+ passed: false,
75
+ message: `WARN: identify_sites (${identifyCount}) と reset_sites (${resetCount}) の件数が大きく mismatch しています。 login と logout を対称に挿入すべきです。`,
76
+ details: { identifyCount, resetCount, diff, threshold },
77
+ });
78
+ };
79
+
80
+ // C18: 挿入 site 0 件 (= identify_sites あるのに 1 件も挿入されていない)
81
+ export const checkC18 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
82
+ const id = "C18";
83
+ const sites = identifySitesOf(discoveries);
84
+ if (sites.length === 0) return errorPass(id);
85
+ let insertedCount = 0;
86
+ for (const site of sites) {
87
+ if (!site?.file) continue;
88
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
89
+ if (content !== null && /cluebase\.identify\s*\(/.test(content)) {
90
+ insertedCount += 1;
91
+ }
92
+ }
93
+ if (insertedCount > 0) return errorPass(id);
94
+ return buildCheck({
95
+ id,
96
+ severity: "error",
97
+ passed: false,
98
+ message: buildErrorMessage({
99
+ detected: `discoveries.json に identify_sites が ${sites.length} 件あるのに、 cluebase.identify 挿入が 1 件も検出できません`,
100
+ location: "(複数 file)",
101
+ impact:
102
+ "全ての login flow に挿入が漏れているため、 顧客識別が一切できません。 setup は実質失敗しています。",
103
+ actions: [
104
+ "/cluebase-implement を実行して挿入を完了させてください",
105
+ "挿入後 /cluebase-doctor を re-run してください",
106
+ ],
107
+ }),
108
+ details: { totalSites: sites.length, insertedCount },
109
+ });
110
+ };
111
+
112
+ // C19: 挿入 file が test/ / __tests__/ / spec/ 配下
113
+ export const checkC19 = (discoveries) => {
114
+ const id = "C19";
115
+ const sites = allInsertionSitesOf(discoveries);
116
+ const offenders = sites.filter(
117
+ (site) => isString(site?.file) && matchesAny(site.file, TEST_DIR_PATTERNS),
118
+ );
119
+ if (offenders.length === 0) return errorPass(id);
120
+ const first = offenders[0];
121
+ return buildCheck({
122
+ id,
123
+ severity: "error",
124
+ passed: false,
125
+ message: buildErrorMessage({
126
+ detected: `挿入 site が test 配下: ${first.file}`,
127
+ location: `${first.file}:${first.line ?? "?"}`,
128
+ impact:
129
+ "test ファイルに挿入された SDK 呼び出しは本番経路に届きません。 顧客イベントが収集されません。",
130
+ actions: [
131
+ "test 配下の site を discoveries.json から除外してください",
132
+ "本番経路 (= production code) の boundary に挿入し直してください",
133
+ ],
134
+ }),
135
+ details: { offenders: offenders.map((s) => ({ file: s.file, line: s.line })) },
136
+ });
137
+ };
138
+
139
+ // C20: 挿入 file が node_modules / vendored / .next / dist 配下
140
+ export const checkC20 = (discoveries) => {
141
+ const id = "C20";
142
+ const sites = allInsertionSitesOf(discoveries);
143
+ const offenders = sites.filter(
144
+ (site) => isString(site?.file) && matchesAny(site.file, VENDORED_DIR_PATTERNS),
145
+ );
146
+ if (offenders.length === 0) return errorPass(id);
147
+ const first = offenders[0];
148
+ return buildCheck({
149
+ id,
150
+ severity: "error",
151
+ passed: false,
152
+ message: buildErrorMessage({
153
+ detected: `挿入 site が vendored / build 配下: ${first.file}`,
154
+ location: `${first.file}:${first.line ?? "?"}`,
155
+ impact:
156
+ "node_modules / .next / dist / build 配下は build 成果物のため、 次回 install / build で上書き消失します。 本番経路には届きません。",
157
+ actions: [
158
+ "source code 配下 (= src / app / lib 等) の boundary に挿入し直してください",
159
+ "/cluebase-discover を再実行して正しい boundary を探索してください",
160
+ ],
161
+ }),
162
+ details: { offenders: offenders.map((s) => ({ file: s.file, line: s.line })) },
163
+ });
164
+ };
165
+
166
+ // ============================================================
167
+ // env file 関連
168
+ // ============================================================
169
+
170
+ // C25: env file に project key が書かれていない
171
+ export const checkC25 = ({ envFiles }) => {
172
+ const id = "C25";
173
+ if (!isPlainObject(envFiles) || Object.keys(envFiles).length === 0) {
174
+ return errorPass(id); // env file 未提供時は skip
175
+ }
176
+ const found = [];
177
+ for (const [path, content] of Object.entries(envFiles)) {
178
+ if (typeof content !== "string") continue;
179
+ for (const envName of REQUIRED_PROJECT_KEY_ENV_NAMES) {
180
+ const pattern = new RegExp(`^\\s*${envName}\\s*=`, "m");
181
+ if (pattern.test(content)) {
182
+ found.push({ path, envName });
183
+ break;
184
+ }
185
+ }
186
+ }
187
+ if (found.length > 0) return errorPass(id);
188
+ return buildCheck({
189
+ id,
190
+ severity: "error",
191
+ passed: false,
192
+ message: buildErrorMessage({
193
+ detected: `どの env file にも ${REQUIRED_PROJECT_KEY_ENV_NAMES.join(" / ")} が書かれていません`,
194
+ location: Object.keys(envFiles).join(", "),
195
+ impact:
196
+ "起動時に projectKey が undefined となり、 全イベントが送信失敗します。",
197
+ actions: [
198
+ "server runtime では CLUEBASE_PROJECT_KEY=pk_dev_xxx を追加してください",
199
+ "frontend SDK では framework に合う public env 名を使ってください",
200
+ ],
201
+ }),
202
+ details: { searchedFiles: Object.keys(envFiles), expected: [...REQUIRED_PROJECT_KEY_ENV_NAMES] },
203
+ });
204
+ };
205
+
206
+ // C26: env var key の typo
207
+ export const checkC26 = ({ envFiles }) => {
208
+ const id = "C26";
209
+ if (!isPlainObject(envFiles) || Object.keys(envFiles).length === 0) {
210
+ return errorPass(id);
211
+ }
212
+ const offenders = [];
213
+ for (const [path, content] of Object.entries(envFiles)) {
214
+ if (typeof content !== "string") continue;
215
+ for (const [typo, correct] of Object.entries(COMMON_ENV_KEY_TYPOS)) {
216
+ const pattern = new RegExp(`^\\s*${typo}\\s*=`, "m");
217
+ if (pattern.test(content)) {
218
+ offenders.push({ path, typo, correct });
219
+ }
220
+ }
221
+ }
222
+ if (offenders.length === 0) return errorPass(id);
223
+ const first = offenders[0];
224
+ return buildCheck({
225
+ id,
226
+ severity: "error",
227
+ passed: false,
228
+ message: buildErrorMessage({
229
+ detected: `env var key に typo: ${first.typo} (正しくは ${first.correct})`,
230
+ location: first.path,
231
+ impact:
232
+ "typo した env var は読み込まれず、 SDK 初期化が undefined で失敗します。",
233
+ actions: [`${first.typo} を ${first.correct} に修正してください`],
234
+ }),
235
+ details: { offenders },
236
+ });
237
+ };
238
+
239
+ // C27: env var の value が placeholder
240
+ export const checkC27 = ({ envFiles }) => {
241
+ const id = "C27";
242
+ if (!isPlainObject(envFiles) || Object.keys(envFiles).length === 0) {
243
+ return errorPass(id);
244
+ }
245
+ const offenders = [];
246
+ for (const [path, content] of Object.entries(envFiles)) {
247
+ if (typeof content !== "string") continue;
248
+ const lines = content.split(/\r?\n/);
249
+ for (const line of lines) {
250
+ const match = line.match(/^\s*((?:[A-Z_][A-Z0-9_]*_)?CLUEBASE_[A-Z_]+)\s*=\s*(.*)$/);
251
+ if (!match) continue;
252
+ const [, key, rawValue] = match;
253
+ const value = rawValue.trim().replace(/^["']|["']$/g, "");
254
+ if (PLACEHOLDER_ENV_VALUES.includes(value.toLowerCase().trim())) {
255
+ offenders.push({ path, key, value });
256
+ }
257
+ }
258
+ }
259
+ if (offenders.length === 0) return errorPass(id);
260
+ const first = offenders[0];
261
+ return buildCheck({
262
+ id,
263
+ severity: "error",
264
+ passed: false,
265
+ message: buildErrorMessage({
266
+ detected: `env var に placeholder value: ${first.key}=${first.value || "(空)"}`,
267
+ location: first.path,
268
+ impact:
269
+ "placeholder のままだと SDK 認証が失敗し、 イベントが 1 件も送信されません。",
270
+ actions: [
271
+ `dashboard から実際の ${first.key} を取得して書き込んでください`,
272
+ "cluebase.ai dashboard → Project Settings → Keys",
273
+ ],
274
+ }),
275
+ details: { offenders },
276
+ });
277
+ };
278
+
279
+ // ============================================================
280
+ // SDK load 関連
281
+ // ============================================================
282
+
283
+ // C5: SDK package が installed (= package.json 記載 + import が解決できる)
284
+
285
+ // 知りたいのは「顧客の repo から import が解決できるか」。file が読めるかは別の
286
+ // 問いで、代用すると 2 通りに外れる。(1) pnpm は node_modules を symlink で組む
287
+ // ので、正しく install されていても file としては辿れない。(2) 逆に file が在って
288
+ // も exports の指定次第で import は解決できない。解決器が正本なので、問いをその
289
+ // まま Node へ聞く。
290
+ const isSdkResolvableFrom = (repoRoot, specifier) => {
291
+ const requireFromRepo = createRequire(resolve(repoRoot, "package.json"));
292
+ try {
293
+ requireFromRepo.resolve(specifier);
294
+ return true;
295
+ } catch (error) {
296
+ // 「入っていない」と言えるのは package 自体が見つからなかった時だけ。
297
+ // ERR_PACKAGE_PATH_NOT_EXPORTED は package を見つけた後に exports を見て
298
+ // 断る error なので、install はされている。
299
+ return error?.code !== "MODULE_NOT_FOUND";
300
+ }
301
+ };
302
+
303
+ export const checkC5 = async ({ packageJson, repoRoot }) => {
304
+ const id = "C5";
305
+ if (!isPlainObject(packageJson)) {
306
+ return errorPass(id); // package.json 未提供時は skip
307
+ }
308
+ const allDeps = {
309
+ ...(packageJson.dependencies ?? {}),
310
+ ...(packageJson.devDependencies ?? {}),
311
+ };
312
+ const installedSdks = VALID_SDK_PACKAGES.filter((pkg) => allDeps[pkg]);
313
+ if (installedSdks.length === 0) {
314
+ return buildCheck({
315
+ id,
316
+ severity: "error",
317
+ passed: false,
318
+ message: buildErrorMessage({
319
+ detected: `package.json に Cluebase SDK package が記載されていません (${VALID_SDK_PACKAGES.join(" / ")} のいずれも見つからない)`,
320
+ location: "package.json",
321
+ impact:
322
+ "SDK 未 install のため、 import が失敗します。 setup 全体が起動しません。",
323
+ actions: [
324
+ "frontend なら: pnpm add @genn-inc/cluebase-frontend-sdk",
325
+ "backend (Node) なら: pnpm add @genn-inc/cluebase-backend-sdk",
326
+ "backend (Python) なら: pip install cluebase-backend-sdk",
327
+ ],
328
+ }),
329
+ details: { availableDeps: Object.keys(allDeps).slice(0, 20) },
330
+ });
331
+ }
332
+ // install 済み check (= node-only SDK)
333
+ const missingInstalled = installedSdks.filter(
334
+ (sdk) =>
335
+ !sdk.startsWith("cluebase-backend-sdk") && // python は node の解決対象外
336
+ !isSdkResolvableFrom(repoRoot, sdk),
337
+ );
338
+ if (missingInstalled.length > 0) {
339
+ return buildCheck({
340
+ id,
341
+ severity: "error",
342
+ passed: false,
343
+ message: buildErrorMessage({
344
+ detected: `package.json に Cluebase SDK が記載されているが、 node_modules に install されていない: ${missingInstalled.join(", ")}`,
345
+ location: "node_modules/",
346
+ impact: "import 解決時に MODULE_NOT_FOUND で起動失敗します。",
347
+ actions: ["pnpm install (または npm install / yarn install) を実行してください"],
348
+ }),
349
+ details: { missingInstalled },
350
+ });
351
+ }
352
+ return errorPass(id);
353
+ };
354
+
355
+ // C28: import statement が間違った package を指す
356
+ export const checkC28 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
357
+ const id = "C28";
358
+ const sites = allInsertionSitesOf(discoveries);
359
+ if (sites.length === 0) return errorPass(id);
360
+ const offenders = [];
361
+ for (const site of sites) {
362
+ if (!site?.file) continue;
363
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
364
+ if (content === null) continue;
365
+ // import / require 文に間違った SDK package が現れていないか
366
+ const importLines = content.match(
367
+ /(?:import\s+[^"'`]+from\s+["'`][^"'`]+["'`])|(?:require\s*\(\s*["'`][^"'`]+["'`]\s*\))/g,
368
+ );
369
+ if (!importLines) continue;
370
+ for (const line of importLines) {
371
+ // cluebase 関連の import のみを対象に絞る (= 顧客 own code は除外)
372
+ if (!/cluebase/i.test(line)) continue;
373
+ for (const pattern of WRONG_SDK_PACKAGE_PATTERNS) {
374
+ if (pattern.test(line)) {
375
+ offenders.push({ file: site.file, statement: line.trim() });
376
+ }
377
+ }
378
+ }
379
+ }
380
+ if (offenders.length === 0) return errorPass(id);
381
+ const first = offenders[0];
382
+ return buildCheck({
383
+ id,
384
+ severity: "error",
385
+ passed: false,
386
+ message: buildErrorMessage({
387
+ detected: `cluebase 関連 import 文が間違った package を指している: ${first.statement}`,
388
+ location: first.file,
389
+ impact:
390
+ "@genn-inc/cluebase-cli は CLI 専用 package で、 SDK としては機能しません。 runtime で `cluebase.identify is not a function` 等の error が出ます。",
391
+ actions: [
392
+ "frontend では @genn-inc/cluebase-frontend-sdk を import してください",
393
+ "backend (Node) では @genn-inc/cluebase-backend-sdk を import してください",
394
+ ],
395
+ }),
396
+ details: { offenders },
397
+ });
398
+ };
399
+
400
+ // ============================================================
401
+ // consent / privacy 関連
402
+ // ============================================================
403
+
404
+ // C8: setConsent 挿入時、 banner UI と接続されている
405
+ export const checkC8 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
406
+ const id = "C8";
407
+ const allSites = allInsertionSitesOf(discoveries);
408
+ let foundSetConsent = false;
409
+ let hasBannerHookup = false;
410
+ for (const site of allSites) {
411
+ if (!site?.file) continue;
412
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
413
+ if (content === null) continue;
414
+ if (/cluebase\.setConsent\s*\(/.test(content)) {
415
+ foundSetConsent = true;
416
+ // banner UI と接続されているか (= onClick handler / event listener / form submit)
417
+ if (
418
+ /(?:onClick|on:click|addEventListener|onChange|onSubmit|form\s+on)/i.test(
419
+ content,
420
+ )
421
+ ) {
422
+ hasBannerHookup = true;
423
+ }
424
+ }
425
+ }
426
+ if (!foundSetConsent) return warnPass(id);
427
+ if (hasBannerHookup) return warnPass(id);
428
+ return buildCheck({
429
+ id,
430
+ severity: "warn",
431
+ passed: false,
432
+ message:
433
+ "WARN: setConsent が挿入されているが、 banner UI の handler 内に置かれていない可能性があります。 顧客同意 UI と SDK consent state が連動していないと、 GDPR / CCPA compliance が崩れます。",
434
+ details: {},
435
+ });
436
+ };
437
+
438
+ // C21: privacy.deniedKeys 未設定で password / token 等の field 名を含む event 発火可能性
439
+ export const checkC21 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
440
+ const id = "C21";
441
+ const initSites = initSitesOf(discoveries);
442
+ if (initSites.length === 0) return warnPass(id);
443
+ let hasDeniedKeys = false;
444
+ let hasSensitiveTrack = false;
445
+ const allSites = allInsertionSitesOf(discoveries);
446
+ const candidateFiles = new Set();
447
+ for (const site of [...initSites, ...allSites]) {
448
+ if (isString(site?.file)) candidateFiles.add(site.file);
449
+ }
450
+ for (const file of candidateFiles) {
451
+ const content = await readSiteFile({ repoRoot, file, signal, sourceFiles });
452
+ if (content === null) continue;
453
+ if (/privacy\s*:\s*\{[^}]*deniedKeys/.test(content)) hasDeniedKeys = true;
454
+ if (/cluebase\.track\s*\(/.test(content)) {
455
+ const trackMatches = content.matchAll(/cluebase\.track\s*\(([^)]+)\)/g);
456
+ for (const match of trackMatches) {
457
+ if (matchesAny(match[1], SENSITIVE_FIELD_PATTERNS)) {
458
+ hasSensitiveTrack = true;
459
+ }
460
+ }
461
+ }
462
+ }
463
+ if (hasDeniedKeys || !hasSensitiveTrack) return warnPass(id);
464
+ return buildCheck({
465
+ id,
466
+ severity: "warn",
467
+ passed: false,
468
+ message:
469
+ "WARN: track 呼び出しに password / token / secret 等の field 名が含まれているのに、 init で privacy.deniedKeys が未設定です。 機密情報が誤って収集される可能性があります。",
470
+ details: {},
471
+ });
472
+ };
473
+
474
+ // C22: setConsent 挿入したのに init で autocapture=true 設定なし (= 矛盾)
475
+ export const checkC22 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
476
+ const id = "C22";
477
+ const allSites = allInsertionSitesOf(discoveries);
478
+ const initSites = initSitesOf(discoveries);
479
+ let hasSetConsent = false;
480
+ let hasExplicitAutocaptureTrue = false;
481
+ let hasAutocaptureFalse = false;
482
+ for (const site of allSites) {
483
+ if (!site?.file) continue;
484
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
485
+ if (content === null) continue;
486
+ if (/cluebase\.setConsent\s*\(/.test(content)) hasSetConsent = true;
487
+ }
488
+ for (const site of initSites) {
489
+ if (!site?.file) continue;
490
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
491
+ if (content === null) continue;
492
+ if (/autocapture\s*:\s*true/.test(content)) hasExplicitAutocaptureTrue = true;
493
+ if (/autocapture\s*:\s*false/.test(content)) hasAutocaptureFalse = true;
494
+ }
495
+ if (!hasSetConsent) return warnPass(id);
496
+ if (hasExplicitAutocaptureTrue && !hasAutocaptureFalse) return warnPass(id);
497
+ if (hasAutocaptureFalse) {
498
+ return buildCheck({
499
+ id,
500
+ severity: "warn",
501
+ passed: false,
502
+ message:
503
+ "WARN: setConsent を挿入しているのに init で autocapture: false が指定されています。 同意 UI を出したのに自動収集を止めるのは矛盾です。 顧客 UX を確認してください。",
504
+ details: {},
505
+ });
506
+ }
507
+ return warnPass(id);
508
+ };
509
+
510
+ // ============================================================
511
+ // autocapture 関連
512
+ // ============================================================
513
+
514
+ // C23: autocapture: false 設定
515
+ export const checkC23 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
516
+ const id = "C23";
517
+ const initSites = initSitesOf(discoveries);
518
+ if (initSites.length === 0) return warnPass(id);
519
+ const offenders = [];
520
+ for (const site of initSites) {
521
+ if (!site?.file) continue;
522
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
523
+ if (content === null) continue;
524
+ if (/autocapture\s*:\s*false/.test(content)) {
525
+ offenders.push({ file: site.file });
526
+ }
527
+ }
528
+ if (offenders.length === 0) return warnPass(id);
529
+ return buildCheck({
530
+ id,
531
+ severity: "warn",
532
+ passed: false,
533
+ message: `WARN: cluebase.init に autocapture: false が設定されています (${offenders.length} 箇所)。 自動収集を無効化すると、 page view / click / form 送信等の大半の signal が取れません。 意図的でない場合は true (= default) に戻してください。`,
534
+ details: { offenders },
535
+ });
536
+ };
537
+
538
+ // C24: captureErrors: false 設定
539
+ export const checkC24 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
540
+ const id = "C24";
541
+ const initSites = initSitesOf(discoveries);
542
+ if (initSites.length === 0) return warnPass(id);
543
+ const offenders = [];
544
+ for (const site of initSites) {
545
+ if (!site?.file) continue;
546
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
547
+ if (content === null) continue;
548
+ if (/captureErrors\s*:\s*false/.test(content)) {
549
+ offenders.push({ file: site.file });
550
+ }
551
+ }
552
+ if (offenders.length === 0) return warnPass(id);
553
+ return buildCheck({
554
+ id,
555
+ severity: "warn",
556
+ passed: false,
557
+ message: `WARN: cluebase.init に captureErrors: false が設定されています (${offenders.length} 箇所)。 error の自動収集が止まり、 障害検知が遅れます。 意図的でない場合は true (= default) に戻してください。`,
558
+ details: { offenders },
559
+ });
560
+ };
561
+
562
+ // ============================================================
563
+ // multi-call (= 重複) 関連
564
+ // ============================================================
565
+
566
+ // C29: 同じ login flow で identify が複数回呼ばれている
567
+ export const checkC29 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
568
+ const id = "C29";
569
+ const sites = identifySitesOf(discoveries);
570
+ if (sites.length === 0) return warnPass(id);
571
+ const offenders = [];
572
+ for (const site of sites) {
573
+ if (!site?.file) continue;
574
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
575
+ if (content === null) continue;
576
+ const count = (content.match(/cluebase\.identify\s*\(/g) ?? []).length;
577
+ if (count > 1) {
578
+ offenders.push({ file: site.file, count });
579
+ }
580
+ }
581
+ if (offenders.length === 0) return warnPass(id);
582
+ return buildCheck({
583
+ id,
584
+ severity: "warn",
585
+ passed: false,
586
+ message: `WARN: 同 file 内で cluebase.identify が複数回呼ばれています (${offenders.length} file)。 同じ login flow で 2 回以上 identify を呼ぶと、 顧客状態が上書きされ snapshot が不安定になります。`,
587
+ details: { offenders },
588
+ });
589
+ };
590
+
591
+ // Event-name patterns already covered by autocapture (click / page view / form
592
+ // submit / input change / button click). A cluebase.track for any of these is a
593
+ // duplicate of an autocaptured signal. Exported so the business-event catalogue
594
+ // (setup-step-builders-events.mjs) can be machine-checked to never overlap —
595
+ // business value milestones must not collide with autocapture-covered names.
596
+ export const AUTOCAPTURE_COVERED_PATTERNS = [
597
+ /click/i,
598
+ /page[_-]?view/i,
599
+ /pageview/i,
600
+ /form[_-]?submit/i,
601
+ /input[_-]?change/i,
602
+ /button[_-]?click/i,
603
+ ];
604
+
605
+ // C30: autocapture でカバー済みの event を track で重複送信
606
+ export const checkC30 = async ({ discoveries, repoRoot, signal, sourceFiles = null }) => {
607
+ const id = "C30";
608
+ const allSites = allInsertionSitesOf(discoveries);
609
+ if (allSites.length === 0) return warnPass(id);
610
+ const autocaptureCovered = AUTOCAPTURE_COVERED_PATTERNS;
611
+ const offenders = [];
612
+ for (const site of allSites) {
613
+ if (!site?.file) continue;
614
+ const content = await readSiteFile({ repoRoot, file: site.file, signal, sourceFiles });
615
+ if (content === null) continue;
616
+ const trackMatches = content.matchAll(
617
+ /cluebase\.track\s*\(\s*["'`]([^"'`]+)["'`]/g,
618
+ );
619
+ for (const match of trackMatches) {
620
+ const eventName = match[1];
621
+ if (matchesAny(eventName, autocaptureCovered)) {
622
+ offenders.push({ file: site.file, eventName });
623
+ }
624
+ }
625
+ }
626
+ if (offenders.length === 0) return warnPass(id);
627
+ return buildCheck({
628
+ id,
629
+ severity: "warn",
630
+ passed: false,
631
+ message: `WARN: autocapture が拾う event 名 (例: ${offenders[0].eventName}) を cluebase.track で手動送信しているのを検出 (${offenders.length} 件)。 同じ event が二重送信され、 集計が膨らみます。`,
632
+ details: { offenders: offenders.slice(0, 5) },
633
+ });
634
+ };
635
+
636
+ // ============================================================
637
+ // ヘルパー関数 (= discoveries.json 構造アクセス)
638
+ // ============================================================