@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,994 @@
1
+ // setup-check lifecycle/find detectors (part 2). Extracted for file-size
2
+ // limits; content is unchanged.
3
+
4
+ import { dirname, join } from "node:path";
5
+ import {
6
+ stripSourceNoise,
7
+ } from "./lifecycle-guard.mjs";
8
+ import {
9
+ escapeRegex,
10
+ nextPackageRoots,
11
+ sourceIsUnderAnyRoot,
12
+ hasUseClientDirective,
13
+ sourceImportsFrontendSdk,
14
+ } from "./setup-check-scan-a.mjs";
15
+
16
+ export const findNextLifecycleMissingUseClientFiles = ({
17
+ dependencySources,
18
+ frontendSources,
19
+ }) => {
20
+ const roots = nextPackageRoots(dependencySources);
21
+ if (roots.length === 0) return [];
22
+ return frontendSources
23
+ .filter((source) => sourceIsUnderAnyRoot(source, roots))
24
+ .filter(
25
+ (source) =>
26
+ sourceImportsFrontendSdk(source) ||
27
+ /\bcluebase\.init\s*\(/.test(
28
+ stripSourceNoise(source.text, { stripStrings: true }),
29
+ ),
30
+ )
31
+ .filter((source) => !hasUseClientDirective(source.text))
32
+ .map((source) => source.file_path);
33
+ };
34
+
35
+ export const findCluebaseInitCallEmptyEnvFallbackFiles = (frontendSources) =>
36
+ frontendSources
37
+ .filter((source) => sourceImportsFrontendSdk(source))
38
+ .filter((source) =>
39
+ /(?:process\.env\.(?:NEXT_PUBLIC_|PUBLIC_|NUXT_PUBLIC_|REACT_APP_)?CLUEBASE_[A-Z0-9_]+|import\.meta\.env\.VITE_CLUEBASE_[A-Z0-9_]+)\s*(?:\?\?|\|\|)\s*(["'])\1/.test(
40
+ stripSourceNoise(source.text),
41
+ ),
42
+ )
43
+ .map((source) => source.file_path);
44
+
45
+ export const findFrontendInitBeforeCluebaseInitCallFiles = (frontendSources) =>
46
+ frontendSources
47
+ .filter((source) => sourceImportsFrontendSdk(source))
48
+ .filter((source) => {
49
+ const text = stripSourceNoise(source.text, { stripStrings: true });
50
+ const initializedIndex = text.search(/\binitialized\s*=\s*true\b/);
51
+ const cluebaseInitIndex = text.search(/\bcluebase\.init\s*\(/);
52
+ return (
53
+ initializedIndex >= 0 &&
54
+ cluebaseInitIndex >= 0 &&
55
+ initializedIndex < cluebaseInitIndex
56
+ );
57
+ })
58
+ .map((source) => source.file_path);
59
+
60
+ export const findFrontendBackendTracePropagationMissingFiles = ({
61
+ backendPresent,
62
+ frontendSources,
63
+ }) => {
64
+ if (!backendPresent) return [];
65
+ return frontendSources
66
+ .filter((source) =>
67
+ /\bcluebase\.init\s*\(/.test(
68
+ stripSourceNoise(source.text, { stripStrings: true }),
69
+ ),
70
+ )
71
+ .filter(
72
+ (source) =>
73
+ !/\btracePropagationOrigins\b/.test(
74
+ stripSourceNoise(source.text, { stripStrings: true }),
75
+ ),
76
+ )
77
+ .map((source) => source.file_path);
78
+ };
79
+
80
+ export const nextRouteApiPathFromFile = (source) => {
81
+ const normalized = source.file_path.replaceAll("\\", "/");
82
+ const match = /(?:^|\/)(?:src\/)?app\/(.+)\/route\.(?:ts|tsx|js|jsx)$/.exec(
83
+ normalized,
84
+ );
85
+ if (!match) return null;
86
+ const routeSegments = match[1]
87
+ .split("/")
88
+ .filter((segment) => segment && !/^\(.+\)$/.test(segment));
89
+ if (routeSegments[0] !== "api") return null;
90
+ return `/${routeSegments.join("/")}`.replace(/\/+/g, "/").toLowerCase();
91
+ };
92
+
93
+ export const findBackendSdkInitMissingServiceKeyFiles = ({
94
+ backendSources,
95
+ framework,
96
+ }) => {
97
+ if (framework !== "fastapi") return [];
98
+ return backendSources
99
+ .filter((source) =>
100
+ /\b(?:cluebase\.init|cluebase_init_fastapi)\s*\(/.test(
101
+ stripSourceNoise(source.text, { stripStrings: true }),
102
+ ),
103
+ )
104
+ .filter(
105
+ (source) =>
106
+ !/\bcluebase\.init\s*\(\s*\{[\s\S]{0,1600}["']service_key["']\s*:/.test(
107
+ stripSourceNoise(source.text),
108
+ ) &&
109
+ !/\bcluebase_init_fastapi\s*\([\s\S]{0,1600}\bservice_key\s*=/.test(
110
+ stripSourceNoise(source.text),
111
+ ),
112
+ )
113
+ .map((source) => source.file_path);
114
+ };
115
+
116
+ export const findFastapiMissingMiddlewareFiles = ({ backendSources, framework }) => {
117
+ if (framework !== "fastapi") return [];
118
+ const combined = backendSources
119
+ .map((source) => stripSourceNoise(source.text, { stripStrings: true }))
120
+ .join("\n");
121
+ if (
122
+ /\bcluebase_init_fastapi\s*\(/.test(combined) ||
123
+ /\bCluebaseFastApiMiddleware\b/.test(combined)
124
+ ) {
125
+ return [];
126
+ }
127
+ return backendSources
128
+ .filter((source) =>
129
+ /\bFastAPI\s*\(/.test(
130
+ stripSourceNoise(source.text, { stripStrings: true }),
131
+ ),
132
+ )
133
+ .map((source) => source.file_path);
134
+ };
135
+
136
+ export const findBackendCluebaseEnvRequiredIndexFiles = (backendSources) =>
137
+ backendSources
138
+ .filter((source) =>
139
+ /os\.environ\s*\[\s*["']CLUEBASE_[A-Z0-9_]+["']\s*\]/.test(
140
+ stripSourceNoise(source.text),
141
+ ),
142
+ )
143
+ .map((source) => source.file_path);
144
+
145
+ export const executableLifecycleCallSnippets = (text) => {
146
+ const snippets = [];
147
+ let index = 0;
148
+ while (index < text.length) {
149
+ const char = text[index];
150
+ const next = text[index + 1];
151
+ if (char === "/" && next === "/") {
152
+ while (index < text.length && text[index] !== "\n") index += 1;
153
+ continue;
154
+ }
155
+ if (char === "#") {
156
+ while (index < text.length && text[index] !== "\n") index += 1;
157
+ continue;
158
+ }
159
+ if (char === "/" && next === "*") {
160
+ index += 2;
161
+ while (
162
+ index < text.length &&
163
+ !(text[index] === "*" && text[index + 1] === "/")
164
+ ) {
165
+ index += 1;
166
+ }
167
+ index += index < text.length ? 2 : 0;
168
+ continue;
169
+ }
170
+ if (char === "'" || char === '"' || char === "`") {
171
+ const quote = char;
172
+ index += 1;
173
+ while (index < text.length) {
174
+ if (quote !== "`" && text[index] === "\\") {
175
+ index += 2;
176
+ continue;
177
+ }
178
+ if (text[index] === quote) {
179
+ index += 1;
180
+ break;
181
+ }
182
+ index += 1;
183
+ }
184
+ continue;
185
+ }
186
+ const match = /^(cluebase\.identify|cluebase\.group)\b/.exec(text.slice(index));
187
+ if (!match || /[A-Za-z0-9_$]/.test(text[index - 1] ?? "")) {
188
+ index += 1;
189
+ continue;
190
+ }
191
+ let cursor = index + match[1].length;
192
+ while (/\s/.test(text[cursor] ?? "")) cursor += 1;
193
+ if (text[cursor] !== "(") {
194
+ index += 1;
195
+ continue;
196
+ }
197
+ let depth = 0;
198
+ let end = cursor;
199
+ while (end < text.length) {
200
+ const current = text[end];
201
+ if (current === "'" || current === '"' || current === "`") {
202
+ const quote = current;
203
+ end += 1;
204
+ while (end < text.length) {
205
+ if (quote !== "`" && text[end] === "\\") {
206
+ end += 2;
207
+ continue;
208
+ }
209
+ if (text[end] === quote) {
210
+ end += 1;
211
+ break;
212
+ }
213
+ end += 1;
214
+ }
215
+ continue;
216
+ }
217
+ if (current === "(") depth += 1;
218
+ if (current === ")") {
219
+ depth -= 1;
220
+ if (depth === 0) {
221
+ end += 1;
222
+ break;
223
+ }
224
+ }
225
+ end += 1;
226
+ }
227
+ snippets.push(text.slice(index, end));
228
+ index = end;
229
+ }
230
+ return snippets;
231
+ };
232
+
233
+ export const sourceHasHardcodedLifecycleIdentity = (source) => {
234
+ const text = stripSourceNoise(source.text);
235
+ const lifecycleCalls = executableLifecycleCallSnippets(source.text);
236
+ // cluebase.identify(<arg1>): arg1 が hardcoded string なら NG
237
+ // cluebase.group(<arg1>, <arg2>): arg1 は groupType literal で OK、 arg2 が hardcoded string なら NG
238
+ if (
239
+ lifecycleCalls.some((call) =>
240
+ /\bcluebase\.identify\s*\(\s*(?:["'][^"']+["']|`[^`$]+`)/.test(call),
241
+ )
242
+ ) {
243
+ return true;
244
+ }
245
+ if (
246
+ lifecycleCalls.some((call) =>
247
+ // cluebase.group の第 2 引数 (= groupKey) が hardcoded string なら NG
248
+ // 第 1 引数 (= type literal "organization") は spec 通り
249
+ /\bcluebase\.group\s*\(\s*(?:["'][^"']+["']|`[^`$]+`)\s*,\s*(?:["'][^"']+["']|`[^`$]+`)/.test(
250
+ call,
251
+ ),
252
+ )
253
+ ) {
254
+ return true;
255
+ }
256
+ if (
257
+ lifecycleCalls.some((call) =>
258
+ /\b(?:cluebase\.identify|cluebase\.group)\s*\(\s*\{[\s\S]{0,500}\b(?:id|userId|user_id|accountId|account_id|workspaceId|workspace_id)\b\s*:\s*(?:["'][^"']+["']|`[^`$]+`)/.test(
259
+ call,
260
+ ),
261
+ )
262
+ ) {
263
+ return true;
264
+ }
265
+ const hardcodedIdentifiers = [
266
+ ...text.matchAll(
267
+ /(?:^|[;\n])\s*(?:(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*(?:["'][^"']+["']|`[^`$]+`)/g,
268
+ ),
269
+ ].map((match) => match[1]);
270
+ if (
271
+ hardcodedIdentifiers.some((name) =>
272
+ lifecycleCalls.some((call) =>
273
+ // identify は第 1 引数、 group は第 2 引数 (= groupKey) が対象
274
+ new RegExp(
275
+ `\\b(?:cluebase\\.identify\\s*\\(\\s*${name}\\b|cluebase\\.group\\s*\\(\\s*(?:["'][^"']+["']|\`[^\`$]+\`)\\s*,\\s*${name}\\b)`,
276
+ ).test(call),
277
+ ),
278
+ )
279
+ ) {
280
+ return true;
281
+ }
282
+ const hardcodedObjectNames = [
283
+ ...text.matchAll(
284
+ /(?:^|[;\n])\s*(?:(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*\{[\s\S]{0,800}:\s*["'][^"']+["'][\s\S]{0,800}\}/g,
285
+ ),
286
+ ].map((match) => match[1]);
287
+ return hardcodedObjectNames.some((name) =>
288
+ lifecycleCalls.some((call) =>
289
+ new RegExp(
290
+ `\\b(?:cluebase\\.identify\\s*\\(\\s*${name}\\s*(?:\\.|\\[)|cluebase\\.group\\s*\\(\\s*(?:["'][^"']+["']|\`[^\`$]+\`)\\s*,\\s*${name}\\s*(?:\\.|\\[))`,
291
+ ).test(call),
292
+ ),
293
+ );
294
+ };
295
+
296
+ export const findHardcodedLifecycleIdentityFiles = (sources) =>
297
+ sources
298
+ .filter(sourceHasHardcodedLifecycleIdentity)
299
+ .map((source) => source.file_path);
300
+
301
+ export const READ_SIDE_EFFECT_LIFECYCLE_PATTERN =
302
+ /\b(?:cluebase\.init|cluebase\.identify|cluebase\.group|cluebase\.reset)\s*\(/;
303
+
304
+ export const frontendQueryFunctionBlocks = (text) => {
305
+ const stripped = stripSourceNoise(text, { stripStrings: true });
306
+ const lines = stripped.split(/\r?\n/);
307
+ const blocks = [];
308
+ for (const match of stripped.matchAll(
309
+ /\b(?:useQuery|useSuspenseQuery|useInfiniteQuery|queryOptions)\s*\(/g,
310
+ )) {
311
+ blocks.push(
312
+ balancedBlockFrom({
313
+ close: ")",
314
+ open: "(",
315
+ startIndex: match.index,
316
+ text: stripped,
317
+ }),
318
+ );
319
+ }
320
+ for (let index = 0; index < lines.length; index += 1) {
321
+ const line = lines[index];
322
+ if (!/\bqueryFn\b/.test(line)) continue;
323
+ const block = [line];
324
+ if (line.includes("{")) {
325
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
326
+ block.push(lines[cursor]);
327
+ if (/^\s*}\s*,?\s*$/.test(lines[cursor])) break;
328
+ }
329
+ }
330
+ blocks.push(block.join("\n"));
331
+ }
332
+ for (const match of stripped.matchAll(
333
+ /\bqueryFn\s*(?::\s*([A-Za-z_$][\w$]*)\b)?/g,
334
+ )) {
335
+ const name = match[1] ?? "queryFn";
336
+ const declaration = new RegExp(
337
+ `(?:(?:export\\s+)?function\\s+${escapeRegex(name)}\\s*\\([^)]*\\)|(?:export\\s+)?(?:const|let|var)\\s+${escapeRegex(name)}\\s*=\\s*(?:async\\s*)?(?:\\([^)]*\\)|[A-Za-z_$][\\w$]*)\\s*=>)[\\s\\S]{0,1600}`,
338
+ "g",
339
+ );
340
+ blocks.push(
341
+ ...[...stripped.matchAll(declaration)].map((entry) => entry[0]),
342
+ );
343
+ }
344
+ return blocks;
345
+ };
346
+
347
+ export const frontendLifecycleExportedFunctionNames = (source) => {
348
+ const stripped = stripSourceNoise(source.text, { stripStrings: true });
349
+ const names = [];
350
+ const declarations = [
351
+ ...stripped.matchAll(
352
+ /\bexport\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{[\s\S]{0,1600}/g,
353
+ ),
354
+ ...stripped.matchAll(
355
+ /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>[\s\S]{0,1600}/g,
356
+ ),
357
+ ];
358
+ for (const declaration of declarations) {
359
+ if (READ_SIDE_EFFECT_LIFECYCLE_PATTERN.test(declaration[0])) {
360
+ names.push(declaration[1]);
361
+ }
362
+ }
363
+ return names;
364
+ };
365
+
366
+ export const sourceHasDefaultLifecycleExport = (source) => {
367
+ const stripped = stripSourceNoise(source.text, { stripStrings: true });
368
+ for (const match of stripped.matchAll(
369
+ /\bexport\s+default\s+(?:async\s+)?function\b[^{]*\{/g,
370
+ )) {
371
+ if (
372
+ READ_SIDE_EFFECT_LIFECYCLE_PATTERN.test(
373
+ balancedBlockFrom({
374
+ close: "}",
375
+ open: "{",
376
+ startIndex: match.index,
377
+ text: stripped,
378
+ }),
379
+ )
380
+ ) {
381
+ return true;
382
+ }
383
+ }
384
+ for (const match of stripped.matchAll(
385
+ /\bexport\s+default\s+(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>\s*\{/g,
386
+ )) {
387
+ if (
388
+ READ_SIDE_EFFECT_LIFECYCLE_PATTERN.test(
389
+ balancedBlockFrom({
390
+ close: "}",
391
+ open: "{",
392
+ startIndex: match.index,
393
+ text: stripped,
394
+ }),
395
+ )
396
+ ) {
397
+ return true;
398
+ }
399
+ }
400
+ for (const match of stripped.matchAll(
401
+ /\bexport\s+default\s+([A-Za-z_$][\w$]*)\b/g,
402
+ )) {
403
+ const name = match[1];
404
+ const declaration = new RegExp(
405
+ `(?:(?:export\\s+)?function\\s+${escapeRegex(name)}\\s*\\([^)]*\\)\\s*\\{|(?:export\\s+)?(?:const|let|var)\\s+${escapeRegex(name)}\\s*=\\s*(?:async\\s*)?(?:\\([^)]*\\)|[A-Za-z_$][\\w$]*)\\s*=>\\s*\\{)`,
406
+ "g",
407
+ );
408
+ for (const declarationMatch of stripped.matchAll(declaration)) {
409
+ if (
410
+ READ_SIDE_EFFECT_LIFECYCLE_PATTERN.test(
411
+ balancedBlockFrom({
412
+ close: "}",
413
+ open: "{",
414
+ startIndex: declarationMatch.index,
415
+ text: stripped,
416
+ }),
417
+ )
418
+ ) {
419
+ return true;
420
+ }
421
+ }
422
+ }
423
+ return false;
424
+ };
425
+
426
+ export const frontendImportCandidates = ({ importerPath, specifier }) => {
427
+ let base = null;
428
+ if (specifier.startsWith(".")) {
429
+ base = join(
430
+ dirname(importerPath),
431
+ specifier.startsWith("./") || specifier.startsWith("../")
432
+ ? specifier
433
+ : specifier.replace(/^\.+/, ""),
434
+ ).replaceAll("\\", "/");
435
+ } else if (specifier.startsWith("@/") || specifier.startsWith("~/")) {
436
+ const pathParts = importerPath.replaceAll("\\", "/").split("/");
437
+ const srcIndex = pathParts.lastIndexOf("src");
438
+ const aliasBase =
439
+ srcIndex >= 0
440
+ ? pathParts.slice(0, srcIndex + 1).join("/")
441
+ : pathParts.slice(0, 1).join("/");
442
+ base = join(aliasBase, specifier.slice(2)).replaceAll("\\", "/");
443
+ }
444
+ if (!base) return [];
445
+ return [
446
+ base,
447
+ `${base}.ts`,
448
+ `${base}.tsx`,
449
+ `${base}.js`,
450
+ `${base}.jsx`,
451
+ join(base, "index.ts").replaceAll("\\", "/"),
452
+ join(base, "index.tsx").replaceAll("\\", "/"),
453
+ join(base, "index.js").replaceAll("\\", "/"),
454
+ join(base, "index.jsx").replaceAll("\\", "/"),
455
+ ];
456
+ };
457
+
458
+ export const frontendQueryFnLifecycleAliasNames = ({
459
+ frontendSources,
460
+ querySource,
461
+ }) => {
462
+ const sourceByPath = new Map(
463
+ frontendSources.map((source) => [source.file_path, source]),
464
+ );
465
+ const aliases = new Set();
466
+ const text = stripSourceNoise(querySource.text);
467
+ for (const match of text.matchAll(
468
+ /\bimport\s+([^;]+?)\s+from\s+["']([^"']+)["']/g,
469
+ )) {
470
+ const clause = match[1].trim();
471
+ const importedSource = frontendImportCandidates({
472
+ importerPath: querySource.file_path,
473
+ specifier: match[2],
474
+ })
475
+ .map((candidate) => sourceByPath.get(candidate))
476
+ .find(Boolean);
477
+ if (!importedSource) continue;
478
+ const exportedLifecycleNames = new Set(
479
+ frontendLifecycleExportedFunctionNames(importedSource),
480
+ );
481
+ const namedImport = /\{([\s\S]*)\}/.exec(clause);
482
+ if (namedImport) {
483
+ for (const rawPart of namedImport[1].split(",")) {
484
+ const part = rawPart.trim();
485
+ if (!part) continue;
486
+ const [exported, local = exported] = part
487
+ .split(/\s+as\s+/i)
488
+ .map((entry) => entry.trim());
489
+ if (exportedLifecycleNames.has(exported)) {
490
+ aliases.add(local);
491
+ }
492
+ }
493
+ }
494
+ const defaultImport = clause
495
+ .replace(/\{[\s\S]*\}/, "")
496
+ .split(",")[0]
497
+ .trim();
498
+ if (
499
+ defaultImport &&
500
+ /^[A-Za-z_$][\w$]*$/.test(defaultImport) &&
501
+ sourceHasDefaultLifecycleExport(importedSource)
502
+ ) {
503
+ aliases.add(defaultImport);
504
+ }
505
+ }
506
+ return aliases;
507
+ };
508
+
509
+ export const findFrontendLifecycleReadSideEffectFiles = (frontendSources) => {
510
+ return frontendSources
511
+ .filter((source) => {
512
+ const text = stripSourceNoise(source.text, { stripStrings: true });
513
+ const hasQueryReadPath =
514
+ /\b(?:useQuery|useSuspenseQuery|useInfiniteQuery|queryOptions)\s*\(/.test(
515
+ text,
516
+ ) || /\bqueryFn\b/.test(text);
517
+ if (!hasQueryReadPath) {
518
+ return false;
519
+ }
520
+ if (
521
+ frontendQueryFunctionBlocks(source.text).some((block) =>
522
+ READ_SIDE_EFFECT_LIFECYCLE_PATTERN.test(block),
523
+ )
524
+ ) {
525
+ return true;
526
+ }
527
+ const sameFileLifecycleHelpers = lifecycleHelperNamesInSource(source);
528
+ const queryFnBlocks = frontendQueryFunctionBlocks(source.text);
529
+ if (
530
+ queryFnBlocks.some((block) =>
531
+ blockCallsLifecycleHelper(block, sameFileLifecycleHelpers),
532
+ )
533
+ ) {
534
+ return true;
535
+ }
536
+ const lifecycleAliases = frontendQueryFnLifecycleAliasNames({
537
+ frontendSources,
538
+ querySource: source,
539
+ });
540
+ return [...lifecycleAliases].some(
541
+ (alias) =>
542
+ [
543
+ new RegExp(`\\bqueryFn\\s*:\\s*${escapeRegex(alias)}\\b`),
544
+ ...(alias === "queryFn" ? [/\bqueryFn\b/] : []),
545
+ new RegExp(
546
+ `\\bqueryFn\\s*:[\\s\\S]{0,600}\\b${escapeRegex(alias)}\\s*\\(`,
547
+ ),
548
+ ].some((pattern) => pattern.test(text)) ||
549
+ queryFnBlocks.some((block) =>
550
+ new RegExp(`\\b${escapeRegex(alias)}\\b`).test(block),
551
+ ),
552
+ );
553
+ })
554
+ .map((source) => source.file_path);
555
+ };
556
+
557
+ export const REPEATED_IDENTITY_HELPER_PATTERN =
558
+ /\b(?:\w*refresh\w*|tokenRefresh|refresh_token|\w*current\w*|current_user|use(?:Current|User|Session)\w*|get(?:Current|Me|User|Session)\w*|fetch(?:Current|Me|User|Session)\w*|load(?:Current|Me|User|Session)\w*|\w*poll\w*|sessionPolling|requestExchange|queryFn|readHook)\b|\/me\b|current-user/i;
559
+
560
+ export const repeatedIdentityHelperContext = ({ matchIndex, text }) => {
561
+ const before = text.slice(0, matchIndex);
562
+ const starts = [
563
+ ...before.matchAll(
564
+ /\b(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/g,
565
+ ),
566
+ ...before.matchAll(
567
+ /\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>\s*\{/g,
568
+ ),
569
+ ...before.matchAll(
570
+ // Match `def` and `async def`, and stop at the opening paren so
571
+ // multi-line / return-annotated signatures still yield the enclosing
572
+ // function name. Without `async` support the enclosing `async def`
573
+ // handler is missed and the code falls back to a raw text window,
574
+ // which false-matches ORM calls like `db.refresh(user)`.
575
+ /^[ \t]*(?:async[ \t]+)?def\s+([A-Za-z_][\w]*)\s*\(/gm,
576
+ ),
577
+ ].sort((left, right) => left.index - right.index);
578
+ const start = starts.at(-1);
579
+ if (!start) {
580
+ return text.slice(Math.max(0, matchIndex - 220), matchIndex);
581
+ }
582
+ return start[1] ?? "";
583
+ };
584
+
585
+ export const findRepeatedIdentityHelperFiles = (sources) =>
586
+ sources
587
+ .filter((source) => {
588
+ const text = stripSourceNoise(source.text, { stripStrings: true });
589
+ for (const match of text.matchAll(/\bcluebase\.identify\s*\(/g)) {
590
+ const context = repeatedIdentityHelperContext({
591
+ matchIndex: match.index,
592
+ text,
593
+ });
594
+ if (REPEATED_IDENTITY_HELPER_PATTERN.test(context)) {
595
+ return true;
596
+ }
597
+ }
598
+ return false;
599
+ })
600
+ .map((source) => source.file_path);
601
+
602
+ export const pythonGetEndpointBlocks = (text) => {
603
+ const lines = stripSourceNoise(text).split(/\r?\n/);
604
+ const blocks = [];
605
+ for (let index = 0; index < lines.length; index += 1) {
606
+ const line = lines[index];
607
+ if (!/^\s*@/.test(line)) continue;
608
+ const decoratorLines = [line];
609
+ let decoratorCursor = index + 1;
610
+ let parenBalance =
611
+ (line.match(/\(/g) ?? []).length - (line.match(/\)/g) ?? []).length;
612
+ while (decoratorCursor < lines.length && parenBalance > 0) {
613
+ decoratorLines.push(lines[decoratorCursor]);
614
+ parenBalance +=
615
+ (lines[decoratorCursor].match(/\(/g) ?? []).length -
616
+ (lines[decoratorCursor].match(/\)/g) ?? []).length;
617
+ decoratorCursor += 1;
618
+ }
619
+ const decoratorText = decoratorLines.join("\n");
620
+ const isGetDecorator =
621
+ /^\s*@(app|router|[\w.]+)\.get\s*\(/.test(decoratorText) ||
622
+ (/^\s*@(app|router|[\w.]+)\.api_route\s*\(/.test(decoratorText) &&
623
+ /\bmethods\s*=\s*\[[\s\S]*GET[\s\S]*\]/i.test(decoratorText));
624
+ if (!isGetDecorator) continue;
625
+ const block = [...decoratorLines];
626
+ let cursor = decoratorCursor;
627
+ while (cursor < lines.length && /^\s*@/.test(lines[cursor])) {
628
+ block.push(lines[cursor]);
629
+ cursor += 1;
630
+ }
631
+ const defLine = lines[cursor] ?? "";
632
+ if (!/^\s*(?:async\s+)?def\s+/.test(defLine)) continue;
633
+ const defIndent = defLine.match(/^\s*/)?.[0].length ?? 0;
634
+ block.push(defLine);
635
+ cursor += 1;
636
+ while (cursor < lines.length) {
637
+ const current = lines[cursor];
638
+ const indent = current.match(/^\s*/)?.[0].length ?? 0;
639
+ if (current.trim() && indent <= defIndent) break;
640
+ block.push(current);
641
+ cursor += 1;
642
+ }
643
+ blocks.push(block.join("\n"));
644
+ }
645
+ return blocks;
646
+ };
647
+
648
+ export const balancedBlockFrom = ({ close, open, startIndex, text }) => {
649
+ let depth = 0;
650
+ let started = false;
651
+ for (let index = startIndex; index < text.length; index += 1) {
652
+ const char = text[index];
653
+ if (char === open) {
654
+ depth += 1;
655
+ started = true;
656
+ } else if (char === close) {
657
+ depth -= 1;
658
+ if (started && depth === 0) {
659
+ return text.slice(startIndex, index + 1);
660
+ }
661
+ }
662
+ }
663
+ return text.slice(startIndex, Math.min(text.length, startIndex + 1600));
664
+ };
665
+
666
+ export const typescriptGetEndpointBlocks = (text) => {
667
+ const stripped = stripSourceNoise(text, { stripStrings: true });
668
+ const blocks = [
669
+ ...stripped.matchAll(
670
+ /(?:^|\n)export\s+(?:async\s+)?function\s+GET\s*\([^)]*\)\s*(?::[^{]+)?\{/g,
671
+ ),
672
+ ].map((match) =>
673
+ balancedBlockFrom({
674
+ close: "}",
675
+ open: "{",
676
+ startIndex: match.index,
677
+ text: stripped,
678
+ }),
679
+ );
680
+ for (const match of stripped.matchAll(
681
+ /(?:^|\n)export\s+const\s+GET\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)(?:\s*:[^=]+)?\s*=>\s*\{/g,
682
+ )) {
683
+ blocks.push(
684
+ balancedBlockFrom({
685
+ close: "}",
686
+ open: "{",
687
+ startIndex: match.index,
688
+ text: stripped,
689
+ }),
690
+ );
691
+ }
692
+ for (const match of stripped.matchAll(/(?:^|\n)\s*@Get\s*\([^)]*\)/g)) {
693
+ const nextDecoratorIndex = stripped
694
+ .slice(match.index + match[0].length)
695
+ .search(/\n\s*@[A-Za-z]/);
696
+ const sliceEnd =
697
+ nextDecoratorIndex >= 0
698
+ ? match.index + match[0].length + nextDecoratorIndex
699
+ : stripped.length;
700
+ const methodSlice = stripped.slice(match.index, sliceEnd);
701
+ const bodyIndex = methodSlice.indexOf("{");
702
+ blocks.push(
703
+ bodyIndex >= 0
704
+ ? balancedBlockFrom({
705
+ close: "}",
706
+ open: "{",
707
+ startIndex: match.index + bodyIndex,
708
+ text: stripped,
709
+ })
710
+ : methodSlice,
711
+ );
712
+ }
713
+ for (const match of stripped.matchAll(/\b(?:app|router)\.get\s*\(/g)) {
714
+ blocks.push(
715
+ balancedBlockFrom({
716
+ close: ")",
717
+ open: "(",
718
+ startIndex: match.index,
719
+ text: stripped,
720
+ }),
721
+ );
722
+ }
723
+ for (const match of stripped.matchAll(
724
+ /\b[A-Za-z_$][\w$]*\.get\s*\(\s*["'`][^"'`]*["'`]\s*,/g,
725
+ )) {
726
+ blocks.push(
727
+ balancedBlockFrom({
728
+ close: ")",
729
+ open: "(",
730
+ startIndex: match.index,
731
+ text: stripped,
732
+ }),
733
+ );
734
+ }
735
+ return blocks;
736
+ };
737
+
738
+ export const pythonFunctionBlockFrom = ({ startIndex, text }) => {
739
+ const startLineIndex = text.lastIndexOf("\n", startIndex) + 1;
740
+ const lines = text.slice(startLineIndex).split(/\r?\n/);
741
+ const firstLine = lines[0] ?? "";
742
+ const defIndent = firstLine.match(/^\s*/)?.[0].length ?? 0;
743
+ const block = [firstLine];
744
+ for (let index = 1; index < lines.length; index += 1) {
745
+ const line = lines[index];
746
+ const indent = line.match(/^\s*/)?.[0].length ?? 0;
747
+ if (line.trim() && indent <= defIndent) break;
748
+ block.push(line);
749
+ }
750
+ return block.join("\n");
751
+ };
752
+
753
+ export const lifecycleHelperNamesInSource = (source) => {
754
+ const stripped = stripSourceNoise(source.text, { stripStrings: true });
755
+ const names = [];
756
+ for (const match of stripped.matchAll(
757
+ /(?:^|\n)\s*(?:async\s+)?def\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*:/g,
758
+ )) {
759
+ const block = pythonFunctionBlockFrom({
760
+ startIndex: match.index,
761
+ text: stripped,
762
+ });
763
+ if (READ_SIDE_EFFECT_LIFECYCLE_PATTERN.test(block)) {
764
+ names.push(match[1]);
765
+ }
766
+ }
767
+ for (const match of stripped.matchAll(
768
+ /(?:^|\n)(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/g,
769
+ )) {
770
+ const block = balancedBlockFrom({
771
+ close: "}",
772
+ open: "{",
773
+ startIndex: match.index,
774
+ text: stripped,
775
+ });
776
+ if (READ_SIDE_EFFECT_LIFECYCLE_PATTERN.test(block)) {
777
+ names.push(match[1]);
778
+ }
779
+ }
780
+ for (const match of stripped.matchAll(
781
+ /(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>\s*\{/g,
782
+ )) {
783
+ const block = balancedBlockFrom({
784
+ close: "}",
785
+ open: "{",
786
+ startIndex: match.index,
787
+ text: stripped,
788
+ });
789
+ if (READ_SIDE_EFFECT_LIFECYCLE_PATTERN.test(block)) {
790
+ names.push(match[1]);
791
+ }
792
+ }
793
+ return [...new Set(names)];
794
+ };
795
+
796
+ export const blockCallsLifecycleHelper = (block, helperNames) =>
797
+ helperNames.some((name) =>
798
+ new RegExp(`\\b${escapeRegex(name)}\\s*\\(`).test(block),
799
+ );
800
+
801
+ export const backendImportCandidates = ({ importerPath, specifier }) => {
802
+ let base = null;
803
+ if (specifier.startsWith(".")) {
804
+ base = join(dirname(importerPath), specifier).replaceAll("\\", "/");
805
+ } else if (/^[A-Za-z_$][\w$.]*$/.test(specifier)) {
806
+ base = join(
807
+ dirname(importerPath),
808
+ specifier.replaceAll(".", "/"),
809
+ ).replaceAll("\\", "/");
810
+ }
811
+ if (!base) return [];
812
+ return [
813
+ base,
814
+ `${base}.py`,
815
+ `${base}.ts`,
816
+ `${base}.tsx`,
817
+ `${base}.js`,
818
+ `${base}.jsx`,
819
+ join(base, "__init__.py").replaceAll("\\", "/"),
820
+ join(base, "index.ts").replaceAll("\\", "/"),
821
+ join(base, "index.tsx").replaceAll("\\", "/"),
822
+ join(base, "index.js").replaceAll("\\", "/"),
823
+ join(base, "index.jsx").replaceAll("\\", "/"),
824
+ ];
825
+ };
826
+
827
+ export const backendImportedLifecycleAliasNames = ({ backendSources, source }) => {
828
+ const sourceByPath = new Map(
829
+ backendSources.map((entry) => [entry.file_path, entry]),
830
+ );
831
+ const aliases = new Set();
832
+ const text = stripSourceNoise(source.text);
833
+
834
+ for (const match of text.matchAll(
835
+ /\bimport\s+([^;]+?)\s+from\s+["']([^"']+)["']/g,
836
+ )) {
837
+ const importedSource = backendImportCandidates({
838
+ importerPath: source.file_path,
839
+ specifier: match[2],
840
+ })
841
+ .map((candidate) => sourceByPath.get(candidate))
842
+ .find(Boolean);
843
+ if (!importedSource) continue;
844
+ const exportedLifecycleNames = new Set(
845
+ lifecycleHelperNamesInSource(importedSource),
846
+ );
847
+ const importedSourceHasLifecycle = READ_SIDE_EFFECT_LIFECYCLE_PATTERN.test(
848
+ stripSourceNoise(importedSource.text, { stripStrings: true }),
849
+ );
850
+ const namedImport = /\{([\s\S]*)\}/.exec(match[1].trim());
851
+ if (!namedImport) continue;
852
+ for (const rawPart of namedImport[1].split(",")) {
853
+ const part = rawPart.trim();
854
+ if (!part) continue;
855
+ const [exported, local = exported] = part
856
+ .split(/\s+as\s+/i)
857
+ .map((entry) => entry.trim());
858
+ if (exportedLifecycleNames.has(exported) || importedSourceHasLifecycle) {
859
+ aliases.add(local);
860
+ }
861
+ }
862
+ }
863
+
864
+ for (const match of text.matchAll(
865
+ /\bfrom\s+([A-Za-z_$][\w$.]*|\.[A-Za-z_$][\w$.]*)\s+import\s+([^\n]+)/g,
866
+ )) {
867
+ const importedSource = backendImportCandidates({
868
+ importerPath: source.file_path,
869
+ specifier: match[1],
870
+ })
871
+ .map((candidate) => sourceByPath.get(candidate))
872
+ .find(Boolean);
873
+ if (!importedSource) continue;
874
+ const exportedLifecycleNames = new Set(
875
+ lifecycleHelperNamesInSource(importedSource),
876
+ );
877
+ const importedSourceHasLifecycle = READ_SIDE_EFFECT_LIFECYCLE_PATTERN.test(
878
+ stripSourceNoise(importedSource.text, { stripStrings: true }),
879
+ );
880
+ for (const rawPart of match[2].split(",")) {
881
+ const part = rawPart.trim();
882
+ if (!part || part === "*") continue;
883
+ const [exported, local = exported] = part
884
+ .split(/\s+as\s+/i)
885
+ .map((entry) => entry.trim());
886
+ if (exportedLifecycleNames.has(exported) || importedSourceHasLifecycle) {
887
+ aliases.add(local);
888
+ }
889
+ }
890
+ }
891
+
892
+ return [...aliases];
893
+ };
894
+
895
+ export const importedLocalNamesInSource = (source) => {
896
+ const text = stripSourceNoise(source.text);
897
+ const names = [];
898
+ for (const match of text.matchAll(
899
+ /\bimport\s+([^;]+?)\s+from\s+["'][^"']+["']/g,
900
+ )) {
901
+ const namedImport = /\{([\s\S]*)\}/.exec(match[1].trim());
902
+ if (!namedImport) continue;
903
+ for (const rawPart of namedImport[1].split(",")) {
904
+ const part = rawPart.trim();
905
+ if (!part) continue;
906
+ const [, local = part] = part
907
+ .split(/\s+as\s+/i)
908
+ .map((entry) => entry.trim());
909
+ names.push(local);
910
+ }
911
+ }
912
+ for (const match of text.matchAll(
913
+ /\bfrom\s+[A-Za-z_$.\w$]+\s+import\s+([^\n]+)/g,
914
+ )) {
915
+ for (const rawPart of match[1].split(",")) {
916
+ const part = rawPart.trim();
917
+ if (!part || part === "*") continue;
918
+ const [, local = part] = part
919
+ .split(/\s+as\s+/i)
920
+ .map((entry) => entry.trim());
921
+ names.push(local);
922
+ }
923
+ }
924
+ return names.filter((name) => /^[A-Za-z_$][\w$]*$/.test(name));
925
+ };
926
+
927
+ export const findBackendLifecycleReadEndpointFiles = (backendSources) => {
928
+ const globalLifecycleHelperNames = new Set(
929
+ backendSources.flatMap((source) => lifecycleHelperNamesInSource(source)),
930
+ );
931
+ return backendSources
932
+ .filter((source) => {
933
+ const helperNames = [
934
+ ...lifecycleHelperNamesInSource(source),
935
+ ...backendImportedLifecycleAliasNames({ backendSources, source }),
936
+ ...importedLocalNamesInSource(source).filter((name) =>
937
+ globalLifecycleHelperNames.has(name),
938
+ ),
939
+ ];
940
+ return [
941
+ ...pythonGetEndpointBlocks(source.text),
942
+ ...typescriptGetEndpointBlocks(source.text),
943
+ ].some(
944
+ (block) =>
945
+ READ_SIDE_EFFECT_LIFECYCLE_PATTERN.test(block) ||
946
+ blockCallsLifecycleHelper(block, helperNames),
947
+ );
948
+ })
949
+ .map((source) => source.file_path);
950
+ };
951
+
952
+ export const lifecycleConceptPattern = (concepts) =>
953
+ new RegExp(
954
+ concepts
955
+ .flatMap((concept) => [
956
+ concept,
957
+ `${concept}[_-]?id`,
958
+ `${concept}Id`,
959
+ `${concept}ID`,
960
+ ])
961
+ .map((value) => `\\b${value}\\b`)
962
+ .join("|"),
963
+ "i",
964
+ );
965
+
966
+ export const identityBoundaryPattern = lifecycleConceptPattern([
967
+ "login",
968
+ "signin",
969
+ "sign_in",
970
+ "auth",
971
+ "token",
972
+ "session",
973
+ "user",
974
+ ]);
975
+ export const frontendIdentityBoundaryPattern = lifecycleConceptPattern([
976
+ "login",
977
+ "signin",
978
+ "sign_in",
979
+ "auth",
980
+ "session",
981
+ "user",
982
+ ]);
983
+ export const logoutBoundaryPattern = /\b(logout|signout|sign_out)\b/i;
984
+ // Source app vocabulary that may represent the company boundary Cluebase maps to
985
+ // organization. These are not Cluebase account/workspace concepts.
986
+ export const companyBoundaryPattern = lifecycleConceptPattern([
987
+ "account",
988
+ "workspace",
989
+ "company",
990
+ "organization",
991
+ "org",
992
+ "tenant",
993
+ ]);
994
+