@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,764 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import {
4
+ listAllowedPythonFiles,
5
+ listAllowedSourceFiles,
6
+ } from "./path-policy.mjs";
7
+ import {
8
+ BACKEND_SDK_BY_FRAMEWORK,
9
+ SOURCE_EXTENSIONS,
10
+ } from "./setup-check-constants.mjs";
11
+ import { CODE_EVIDENCE_ROUTE_METHODS } from "./generated-code-evidence-contract.mjs";
12
+ import { stripSourceNoise } from "./lifecycle-guard.mjs";
13
+
14
+ // Cluebase setup discovery detects instrumentable services mechanically.
15
+ //
16
+ // Deterministic scope (CLI-owned): language + framework + service root, decided
17
+ // from dependency manifests and framework import signals. The normalized setup
18
+ // contract below additionally identifies a unique bootstrap and lifecycle
19
+ // boundary when source evidence makes that safe; ambiguous boundaries remain
20
+ // unresolved for the setup caller.
21
+
22
+ const deriveServiceKeyFromPath = (rootPath) => {
23
+ const segment = rootPath
24
+ .split("/")
25
+ .map((part) => part.trim())
26
+ .filter(Boolean)
27
+ .at(-1);
28
+ const normalized = segment
29
+ ?.toLowerCase()
30
+ .replace(/[^a-z0-9_-]+/g, "-")
31
+ .replace(/^-+|-+$/g, "");
32
+ return normalized || "backend";
33
+ };
34
+
35
+ const commonDirectory = (paths) => {
36
+ const directories = paths.map((path) =>
37
+ dirname(path)
38
+ .split(/[\\/]+/)
39
+ .filter(Boolean),
40
+ );
41
+ if (directories.length === 0) return ".";
42
+ const common = [];
43
+ for (let index = 0; index < directories[0].length; index += 1) {
44
+ const part = directories[0][index];
45
+ if (directories.every((directory) => directory[index] === part)) {
46
+ common.push(part);
47
+ continue;
48
+ }
49
+ break;
50
+ }
51
+ return common.length ? common.join("/") : ".";
52
+ };
53
+
54
+ const isPathInsideOrEqual = (childPath, parentPath) =>
55
+ childPath === parentPath ||
56
+ parentPath === "." ||
57
+ childPath.startsWith(`${parentPath}/`);
58
+
59
+ const ignoredScanDirs = new Set([
60
+ ".git",
61
+ ".next",
62
+ ".turbo",
63
+ ".venv",
64
+ "coverage",
65
+ "dist",
66
+ "build",
67
+ "node_modules",
68
+ "__pycache__",
69
+ "vendor",
70
+ ]);
71
+
72
+ const PYTHON_MANIFEST_FILES = new Set([
73
+ "requirements.txt",
74
+ "requirements-dev.txt",
75
+ "pyproject.toml",
76
+ "Pipfile",
77
+ "setup.py",
78
+ "setup.cfg",
79
+ ]);
80
+
81
+ const isTestSourcePath = (file) => {
82
+ const parts = file.split("/").filter(Boolean);
83
+ return (
84
+ parts.some((part) => /^(?:test|tests|__tests__|spec)$/i.test(part)) ||
85
+ /(?:^|\/)(?:test_[^/]+|[^/]+_test\.[^/]+)$/i.test(file)
86
+ );
87
+ };
88
+
89
+ const AUTH_BOUNDARY_FILE_PATTERN =
90
+ /(?:^|\/)(?:auth|authentication|login|signin|sign-in)(?:[._-]|\/|$)/i;
91
+ const AUTH_BOUNDARY_SOURCE_PATTERN =
92
+ /\b(?:OAuth2(?:Password)?(?:Bearer|RequestForm)?|HTTPBearer|create_access_token|authenticate|sign[_-]?in|login[A-Za-z_$]*)\b/;
93
+ const AUTH_BOUNDARY_ROUTE_PATTERN =
94
+ /\.\s*(?:get|post|put|patch|api_route)\s*\(\s*["'`][^"'`]*(?:auth|login|sign[-_ ]?in)[^"'`]*["'`]/i;
95
+
96
+ // Backend framework signals are language/framework labels for documentation
97
+ // selection, matched against declared dependency names or import statements.
98
+ // They are not route parsers. First match wins.
99
+ const PYTHON_FRAMEWORKS = [
100
+ "fastapi",
101
+ "django",
102
+ "flask",
103
+ "starlette",
104
+ "sanic",
105
+ "aiohttp",
106
+ "tornado",
107
+ "quart",
108
+ "falcon",
109
+ ];
110
+ // Framework-agnostic Python server runtimes: presence in a manifest proves a
111
+ // runnable backend even when the web framework is unrecognized.
112
+ const PYTHON_SERVER_RUNTIMES = ["uvicorn", "gunicorn", "hypercorn", "daphne"];
113
+
114
+ const NODE_BACKEND_FRAMEWORK_SIGNALS = [
115
+ ["@nestjs/core", "nestjs"],
116
+ ["express", "express"],
117
+ ["fastify", "fastify"],
118
+ ["koa", "koa"],
119
+ ["@hapi/hapi", "hapi"],
120
+ ["hapi", "hapi"],
121
+ ["restify", "restify"],
122
+ ["@adonisjs/core", "adonisjs"],
123
+ ];
124
+
125
+ const FRONTEND_FRAMEWORK_SIGNALS = [
126
+ ["next", "nextjs"],
127
+ ["@sveltejs/kit", "sveltekit"],
128
+ ["nuxt", "nuxt"],
129
+ ["@angular/core", "angular"],
130
+ ["vite", "vite"],
131
+ ["vue", "vue"],
132
+ ["react", "react"],
133
+ ];
134
+
135
+ const listFilesByName = async ({ repoRoot, names, currentPath = "." }) => {
136
+ const absolutePath = join(repoRoot, currentPath);
137
+ const entries = await readdir(absolutePath, { withFileTypes: true });
138
+ const files = [];
139
+ for (const entry of entries) {
140
+ if (entry.isDirectory()) {
141
+ if (ignoredScanDirs.has(entry.name)) continue;
142
+ files.push(
143
+ ...(await listFilesByName({
144
+ repoRoot,
145
+ names,
146
+ currentPath: join(currentPath, entry.name),
147
+ })),
148
+ );
149
+ continue;
150
+ }
151
+ if (entry.isFile() && names.has(entry.name)) {
152
+ files.push(join(currentPath, entry.name));
153
+ }
154
+ }
155
+ return files;
156
+ };
157
+
158
+ const readJsonFile = async (path) => JSON.parse(await readFile(path, "utf8"));
159
+
160
+ const packageDependencyNames = (packageJson) =>
161
+ new Set([
162
+ ...Object.keys(packageJson.dependencies ?? {}),
163
+ ...Object.keys(packageJson.devDependencies ?? {}),
164
+ ]);
165
+
166
+ const detectFramework = (signals, dependencyNames) => {
167
+ for (const [dependency, framework] of signals) {
168
+ if (dependencyNames.has(dependency)) return framework;
169
+ }
170
+ return null;
171
+ };
172
+
173
+ // Declared package names from a Python dependency manifest. Matches
174
+ // requirements-style pins, Pipfile / pyproject tables, and PEP 621 arrays
175
+ // closely enough to recognize known framework and runtime names.
176
+ const pythonDeclaredPackages = (manifestText) => {
177
+ const names = new Set();
178
+ for (const rawLine of manifestText.split(/\r?\n/)) {
179
+ const line = rawLine.trim();
180
+ if (!line || line.startsWith("#")) continue;
181
+ const match = line.match(/["']?([A-Za-z0-9_.-]+)/);
182
+ if (match) names.add(match[1].toLowerCase());
183
+ }
184
+ return names;
185
+ };
186
+
187
+ const pythonFrameworkFromImports = (source) => {
188
+ for (const framework of PYTHON_FRAMEWORKS) {
189
+ const importPattern = new RegExp(
190
+ String.raw`(^|\n)\s*(from\s+${framework}\b|import\s+${framework}\b)`,
191
+ );
192
+ if (importPattern.test(source)) return framework;
193
+ }
194
+ return null;
195
+ };
196
+
197
+ const normalizeTargetId = ({ name, fallbackPath }) => {
198
+ const source = typeof name === "string" && name ? name : fallbackPath;
199
+ const segment = source.split("/").at(-1) ?? source;
200
+ return segment
201
+ .toLowerCase()
202
+ .replace(/[^a-z0-9_-]+/g, "-")
203
+ .replace(/^-+|-+$/g, "");
204
+ };
205
+
206
+ const portFromScripts = (scripts) => {
207
+ const scriptText = Object.values(scripts ?? {})
208
+ .filter((value) => typeof value === "string")
209
+ .join("\n");
210
+ const portFlag = scriptText.match(/(?:--port|-p)\s+(\d{2,5})/);
211
+ if (portFlag) return portFlag[1];
212
+ const envPort = scriptText.match(/(?:^|\s)PORT=(\d{2,5})(?:\s|$)/);
213
+ return envPort?.[1] ?? null;
214
+ };
215
+
216
+ const backendService = ({ language, framework, rootPath }) => ({
217
+ kind: "backend",
218
+ language,
219
+ framework,
220
+ root_path: rootPath,
221
+ service_key: deriveServiceKeyFromPath(rootPath),
222
+ local_url_candidates: [],
223
+ });
224
+
225
+ const detectServicesFromPackageJson = async ({ repoRoot }) => {
226
+ const packageJsonFiles = await listFilesByName({
227
+ repoRoot,
228
+ names: new Set(["package.json"]),
229
+ });
230
+ const frontend = [];
231
+ const backend = [];
232
+ for (const packageJsonFile of packageJsonFiles) {
233
+ const packageJson = await readJsonFile(join(repoRoot, packageJsonFile));
234
+ const dependencyNames = packageDependencyNames(packageJson);
235
+ const rootPath = dirname(packageJsonFile);
236
+ const frontendFramework = detectFramework(
237
+ FRONTEND_FRAMEWORK_SIGNALS,
238
+ dependencyNames,
239
+ );
240
+ if (frontendFramework) {
241
+ const targetId = normalizeTargetId({
242
+ name: packageJson.name,
243
+ fallbackPath: packageJsonFile,
244
+ });
245
+ if (targetId) {
246
+ const port = portFromScripts(packageJson.scripts);
247
+ frontend.push({
248
+ kind: "frontend",
249
+ language: "node",
250
+ framework: frontendFramework,
251
+ root_path: rootPath,
252
+ target_id: targetId,
253
+ local_url_candidates: port ? [`http://localhost:${port}`] : [],
254
+ });
255
+ }
256
+ }
257
+ const backendFramework = detectFramework(
258
+ NODE_BACKEND_FRAMEWORK_SIGNALS,
259
+ dependencyNames,
260
+ );
261
+ if (backendFramework) {
262
+ backend.push(
263
+ backendService({
264
+ language: "node",
265
+ framework: backendFramework,
266
+ rootPath,
267
+ }),
268
+ );
269
+ }
270
+ }
271
+ return { frontend, backend };
272
+ };
273
+
274
+ // Python backends from framework import signals in source. Root is the common
275
+ // directory of the files importing a given framework, so a nested app package
276
+ // resolves to its own root rather than the repository root.
277
+ const detectPythonBackendsFromSource = async ({ repoRoot }) => {
278
+ const files = await listAllowedPythonFiles({
279
+ repoRoot,
280
+ allowedSourcePaths: ["."],
281
+ excludedSourcePaths: [],
282
+ });
283
+ const relativeFilesByFramework = new Map();
284
+ for (const absolutePath of files) {
285
+ const relativePath = relative(repoRoot, absolutePath).replaceAll("\\", "/");
286
+ if (isTestSourcePath(relativePath)) continue;
287
+ const framework = pythonFrameworkFromImports(
288
+ await readFile(absolutePath, "utf8"),
289
+ );
290
+ if (!framework) continue;
291
+ const collected = relativeFilesByFramework.get(framework) ?? [];
292
+ collected.push(relativePath);
293
+ relativeFilesByFramework.set(framework, collected);
294
+ }
295
+ return [...relativeFilesByFramework.entries()].map(([framework, files]) =>
296
+ backendService({
297
+ language: "python",
298
+ framework,
299
+ rootPath: commonDirectory(files),
300
+ }),
301
+ );
302
+ };
303
+
304
+ // Python backends declared in dependency manifests, used to catch backends
305
+ // whose framework import was not directly visible in scanned source.
306
+ const detectPythonBackendsFromManifests = async ({ repoRoot }) => {
307
+ const manifestFiles = await listFilesByName({
308
+ repoRoot,
309
+ names: PYTHON_MANIFEST_FILES,
310
+ });
311
+ const backendsByRoot = new Map();
312
+ for (const manifestFile of manifestFiles) {
313
+ const declaredPackages = pythonDeclaredPackages(
314
+ await readFile(join(repoRoot, manifestFile), "utf8"),
315
+ );
316
+ const framework = PYTHON_FRAMEWORKS.find((name) =>
317
+ declaredPackages.has(name),
318
+ );
319
+ const hasServerRuntime = PYTHON_SERVER_RUNTIMES.some((runtime) =>
320
+ declaredPackages.has(runtime),
321
+ );
322
+ if (!framework && !hasServerRuntime) continue;
323
+ const rootPath = dirname(manifestFile);
324
+ const existing = backendsByRoot.get(rootPath);
325
+ if (existing && (existing.framework !== "python" || !framework)) continue;
326
+ backendsByRoot.set(
327
+ rootPath,
328
+ backendService({
329
+ language: "python",
330
+ framework: framework ?? "python",
331
+ rootPath,
332
+ }),
333
+ );
334
+ }
335
+ return [...backendsByRoot.values()];
336
+ };
337
+
338
+ // Merge source-derived and manifest-derived Python backends. Source roots are
339
+ // more precise, so a manifest backend is added only when it does not overlap a
340
+ // source-detected root.
341
+ const mergePythonBackends = (sourceBackends, manifestBackends) => {
342
+ const merged = [...sourceBackends];
343
+ for (const manifestBackend of manifestBackends) {
344
+ const overlaps = merged.some(
345
+ (existing) =>
346
+ isPathInsideOrEqual(existing.root_path, manifestBackend.root_path) ||
347
+ isPathInsideOrEqual(manifestBackend.root_path, existing.root_path),
348
+ );
349
+ if (!overlaps) merged.push(manifestBackend);
350
+ }
351
+ return merged;
352
+ };
353
+
354
+ export const discoverSetupRepository = async ({ repoRoot }) => {
355
+ const resolvedRepoRoot = resolve(repoRoot ?? ".");
356
+ const packageServices = await detectServicesFromPackageJson({
357
+ repoRoot: resolvedRepoRoot,
358
+ });
359
+ const pythonBackends = mergePythonBackends(
360
+ await detectPythonBackendsFromSource({ repoRoot: resolvedRepoRoot }),
361
+ await detectPythonBackendsFromManifests({ repoRoot: resolvedRepoRoot }),
362
+ );
363
+
364
+ const frontend = packageServices.frontend.sort((left, right) =>
365
+ left.target_id.localeCompare(right.target_id),
366
+ );
367
+ const backend = [...pythonBackends, ...packageServices.backend].sort(
368
+ (left, right) => left.service_key.localeCompare(right.service_key),
369
+ );
370
+
371
+ const detected = frontend.length > 0 || backend.length > 0;
372
+ const candidates = backend.map((service) => ({
373
+ framework: service.framework,
374
+ language: service.language,
375
+ backend_root_path: service.root_path,
376
+ service_key: service.service_key,
377
+ }));
378
+
379
+ return {
380
+ detected,
381
+ blockers: detected
382
+ ? []
383
+ : [
384
+ {
385
+ code: "NO_INSTRUMENTABLE_SERVICE",
386
+ message:
387
+ "No frontend or backend service was detected. Add a supported frontend or backend service, or use the universal HTTP ingest contract directly.",
388
+ },
389
+ ],
390
+ candidates,
391
+ services: {
392
+ frontend,
393
+ backend,
394
+ },
395
+ repo_root: relative(process.cwd(), resolvedRepoRoot) || ".",
396
+ };
397
+ };
398
+
399
+ const sourceLineFor = (source, index) =>
400
+ source.slice(0, index).split("\n").length;
401
+
402
+ const JS_FUNCTION_BOUNDARY_PATTERN =
403
+ /(?:^|\n)\s*(?:(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^\n)]*\)|[A-Za-z_$][\w$]*)\s*=>)/gm;
404
+ const PYTHON_FUNCTION_BOUNDARY_PATTERN =
405
+ /(?:^|\n)\s*(?:async\s+)?def\s+([A-Za-z_][\w]*)\s*\(/gm;
406
+ const ROUTE_BOUNDARY_PATTERN =
407
+ /(?:^|\n)\s*([A-Za-z_$][\w$]*)\.(get|post|put|patch|delete|options|head|trace|api_route)\s*\(\s*(["'`])([^"'`]+)\3/gim;
408
+ const JS_CLASS_BOUNDARY_PATTERN =
409
+ /(?:^|\n)\s*(?:(?:export\s+)?(?:default\s+)?class)\s+([A-Za-z_$][\w$]*)\b/gm;
410
+ const NEST_ROUTE_METHOD_NAMES = CODE_EVIDENCE_ROUTE_METHODS.map(
411
+ (method) => `${method[0]}${method.slice(1).toLowerCase()}`,
412
+ ).join("|");
413
+ const NEST_METHOD_BOUNDARY_PATTERN = new RegExp(
414
+ String.raw`(?:^|\n)\s*@(${NEST_ROUTE_METHOD_NAMES})\s*(?:\(\s*(?:(['"\`])([^'"\`]*?)\2)?\s*\))?\s*(?:(?:public|private|protected|static|readonly)\s+)*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(`,
415
+ "gm",
416
+ );
417
+
418
+ const boundaryCandidatesFor = (source) => {
419
+ const candidates = [];
420
+ const classes = [];
421
+ JS_CLASS_BOUNDARY_PATTERN.lastIndex = 0;
422
+ for (const match of source.matchAll(JS_CLASS_BOUNDARY_PATTERN)) {
423
+ classes.push({ index: match.index ?? 0, name: match[1] });
424
+ }
425
+ for (const pattern of [
426
+ JS_FUNCTION_BOUNDARY_PATTERN,
427
+ PYTHON_FUNCTION_BOUNDARY_PATTERN,
428
+ ]) {
429
+ pattern.lastIndex = 0;
430
+ for (const match of source.matchAll(pattern)) {
431
+ candidates.push({
432
+ index: match.index ?? 0,
433
+ name: match[1] ?? match[2],
434
+ key: `function:${match[1] ?? match[2]}`,
435
+ });
436
+ }
437
+ }
438
+ ROUTE_BOUNDARY_PATTERN.lastIndex = 0;
439
+ for (const match of source.matchAll(ROUTE_BOUNDARY_PATTERN)) {
440
+ candidates.push({
441
+ index: match.index ?? 0,
442
+ name: `${match[2].toUpperCase()} ${match[4]}`,
443
+ key: `route:${match[2].toUpperCase()}:${match[4]}`,
444
+ });
445
+ }
446
+ NEST_METHOD_BOUNDARY_PATTERN.lastIndex = 0;
447
+ for (const match of source.matchAll(NEST_METHOD_BOUNDARY_PATTERN)) {
448
+ const classBoundary = classes
449
+ .filter((candidate) => candidate.index <= (match.index ?? 0))
450
+ .at(-1);
451
+ if (!classBoundary) continue;
452
+ candidates.push({
453
+ index: match.index ?? 0,
454
+ name: `${match[1].toUpperCase()} ${match[3] || "<controller-root>"} :: ${match[4]}`,
455
+ key: `nest:${classBoundary.name}:${match[4]}`,
456
+ });
457
+ }
458
+ return candidates.sort((left, right) => left.index - right.index);
459
+ };
460
+
461
+ const boundaryAt = (source, index) => {
462
+ const candidates = boundaryCandidatesFor(source).filter(
463
+ (candidate) => candidate.index <= index,
464
+ );
465
+ const candidate = candidates.at(-1);
466
+ return candidate ?? { index: 0, name: null, key: "file" };
467
+ };
468
+
469
+ const sourceFilesForSetupDiscovery = async (repoRoot) => {
470
+ const files = await listAllowedSourceFiles({
471
+ repoRoot,
472
+ allowedSourcePaths: ["."],
473
+ excludedSourcePaths: [],
474
+ extensions: SOURCE_EXTENSIONS,
475
+ });
476
+ return Promise.all(
477
+ files.map(async (absolutePath) => ({
478
+ file: relative(resolve(repoRoot), absolutePath).replaceAll("\\", "/"),
479
+ source: await readFile(absolutePath, "utf8"),
480
+ })),
481
+ );
482
+ };
483
+
484
+ const firstIndex = (source, patterns) => {
485
+ const matches = patterns
486
+ .map((pattern) => {
487
+ const index = source.search(pattern);
488
+ return index < 0 ? null : index;
489
+ })
490
+ .filter((index) => index !== null);
491
+ return matches.length === 0 ? -1 : Math.min(...matches);
492
+ };
493
+
494
+ const siteAt = ({
495
+ file,
496
+ source,
497
+ index,
498
+ availableFields,
499
+ groupOwnerKind,
500
+ boundary,
501
+ }) => ({
502
+ file,
503
+ line: sourceLineFor(source, index),
504
+ rationale:
505
+ "Existing application lifecycle boundary identified by setup discovery.",
506
+ evidenceSnippet: source
507
+ .slice(Math.max(0, index - 80), Math.min(source.length, index + 180))
508
+ .trim(),
509
+ ...(availableFields ? { availableFields } : {}),
510
+ ...(groupOwnerKind ? { groupOwnerKind } : {}),
511
+ ...(boundary?.name ? { symbol: boundary.name } : {}),
512
+ });
513
+
514
+ const uniqueSite = (sites) => (sites.length === 1 ? sites[0] : null);
515
+
516
+ const setupSourceCandidates = (files, detection) => {
517
+ const backendRoots = (detection.services?.backend ?? []).map(
518
+ (service) => service.root_path,
519
+ );
520
+ return files.filter(({ file }) =>
521
+ !isTestSourcePath(file) &&
522
+ backendRoots.some(
523
+ (rootPath) =>
524
+ rootPath === "." ||
525
+ file === rootPath ||
526
+ file.startsWith(`${rootPath}/`),
527
+ ),
528
+ );
529
+ };
530
+
531
+ const lifecycleSitesFor = (files) => {
532
+ const identifyCandidates = [];
533
+ const groupCandidates = [];
534
+ const resetCandidates = [];
535
+ for (const { file, source } of files) {
536
+ const boundaries = boundaryCandidatesFor(source);
537
+ const lifecycleCandidates = (pattern) => {
538
+ pattern.lastIndex = 0;
539
+ const seen = new Set();
540
+ return [...source.matchAll(pattern)].flatMap((match) => {
541
+ const index = match.index ?? 0;
542
+ const boundary = boundaryAt(source, index);
543
+ if (seen.has(boundary.key)) return [];
544
+ seen.add(boundary.key);
545
+ return [{ index, boundary }];
546
+ });
547
+ };
548
+ const executableSource = stripSourceNoise(source, { stripStrings: true });
549
+ const isAuthBoundary =
550
+ AUTH_BOUNDARY_FILE_PATTERN.test(file) ||
551
+ AUTH_BOUNDARY_SOURCE_PATTERN.test(executableSource) ||
552
+ AUTH_BOUNDARY_ROUTE_PATTERN.test(source);
553
+ const userReferencePattern =
554
+ /\b(?:user|account|currentUser|session\??\.user)\s*(?:\.\s*id|\[\s*["']id["']\s*\])/gi;
555
+ const userCandidates = lifecycleCandidates(userReferencePattern);
556
+ if (isAuthBoundary && userCandidates.length > 0) {
557
+ for (const { index: userIndex, boundary } of userCandidates) {
558
+ const boundaryEnd = boundaries.find(
559
+ (candidate) => candidate.index > boundary.index,
560
+ )?.index ?? source.length;
561
+ const idMatch = source
562
+ .slice(userIndex, boundaryEnd)
563
+ .match(userReferencePattern);
564
+ const nameMatch = source
565
+ .slice(userIndex, boundaryEnd)
566
+ .match(
567
+ /\b(?:user|account|currentUser|session\??\.user)\s*(?:\.\s*(?:name|displayName)|\[\s*["'](?:name|displayName)["']\s*\])/i,
568
+ );
569
+ if (idMatch) {
570
+ identifyCandidates.push(
571
+ siteAt({
572
+ file,
573
+ source,
574
+ index: userIndex,
575
+ boundary,
576
+ availableFields: {
577
+ id: idMatch[0],
578
+ ...(nameMatch ? { name: nameMatch[0] } : {}),
579
+ },
580
+ }),
581
+ );
582
+ }
583
+ }
584
+ }
585
+
586
+ const organizationReferencePattern =
587
+ /\b(?:organization|org)\s*(?:\.\s*id|\[\s*["']id["']\s*\])|\b(?:organizationId|organization_id|orgId|org_id)\b/gi;
588
+ const organizationCandidates = lifecycleCandidates(
589
+ organizationReferencePattern,
590
+ );
591
+ if (
592
+ organizationCandidates.length > 0 &&
593
+ (isAuthBoundary || /group|organization|workspace|tenant/i.test(source))
594
+ ) {
595
+ for (const { index: organizationIndex, boundary } of organizationCandidates) {
596
+ const boundaryEnd = boundaries.find(
597
+ (candidate) => candidate.index > boundary.index,
598
+ )?.index ?? source.length;
599
+ const idMatch = source
600
+ .slice(organizationIndex, boundaryEnd)
601
+ .match(organizationReferencePattern);
602
+ const nameMatch = source
603
+ .slice(organizationIndex, boundaryEnd)
604
+ .match(
605
+ /\b(?:organization|org)\s*(?:\.\s*(?:name|displayName)|\[\s*["'](?:name|displayName)["']\s*\])|\b(?:organizationName|organization_name|orgName|org_name)\b/i,
606
+ );
607
+ if (idMatch) {
608
+ groupCandidates.push(
609
+ siteAt({
610
+ file,
611
+ source,
612
+ index: organizationIndex,
613
+ boundary,
614
+ availableFields: {
615
+ groupKey: idMatch[0],
616
+ ...(nameMatch ? { name: nameMatch[0] } : {}),
617
+ },
618
+ groupOwnerKind: "organization",
619
+ }),
620
+ );
621
+ }
622
+ }
623
+ }
624
+
625
+ const resetReferencePattern =
626
+ /\b(?:logout|logOut|signOut|sign[-_ ]?out|endSession)\s*(?=\()/gi;
627
+ for (const { index: resetIndex, boundary } of lifecycleCandidates(
628
+ resetReferencePattern,
629
+ )) {
630
+ const returnIndex = source.indexOf("return", resetIndex);
631
+ const returnLineStart =
632
+ returnIndex > resetIndex
633
+ ? source.lastIndexOf("\n", returnIndex - 1) + 1
634
+ : -1;
635
+ const previousLineStart =
636
+ returnLineStart > 0
637
+ ? source.lastIndexOf("\n", returnLineStart - 2) + 1
638
+ : resetIndex;
639
+ resetCandidates.push(
640
+ siteAt({
641
+ file,
642
+ source,
643
+ index: previousLineStart,
644
+ boundary,
645
+ }),
646
+ );
647
+ }
648
+ }
649
+ return {
650
+ identifySites: identifyCandidates,
651
+ groupSites: groupCandidates,
652
+ resetSites: resetCandidates,
653
+ identifyCandidateCount: identifyCandidates.length,
654
+ groupCandidateCount: groupCandidates.length,
655
+ resetCandidateCount: resetCandidates.length,
656
+ };
657
+ };
658
+
659
+ const bootstrapSitesFor = (files, detection) => {
660
+ const candidates = files.filter(({ source }) =>
661
+ /\b(?:express\s*\(|NestFactory\.create\s*\(|app\.listen\s*\(|FastAPI\s*\()/.test(
662
+ source,
663
+ ),
664
+ );
665
+ const backend = detection.services?.backend ?? [];
666
+ const backendService = backend.length === 1 ? backend[0] : null;
667
+ const candidate = uniqueSite(
668
+ candidates.map(({ file, source }) => {
669
+ const constructionIndex = firstIndex(source, [
670
+ /\b(?:express\s*\(|NestFactory\.create\s*\(|app\.listen\s*\(|FastAPI\s*\()/,
671
+ ]);
672
+ const index =
673
+ constructionIndex < 0
674
+ ? 0
675
+ : source.lastIndexOf("\n", constructionIndex) + 1;
676
+ return siteAt({ file, source, index });
677
+ }),
678
+ );
679
+ return {
680
+ site: candidate
681
+ ? {
682
+ ...candidate,
683
+ ...(backendService?.service_key
684
+ ? { serviceKey: backendService.service_key }
685
+ : {}),
686
+ ...(backendService?.framework
687
+ ? { framework: backendService.framework }
688
+ : {}),
689
+ }
690
+ : null,
691
+ ambiguous: candidates.length > 1,
692
+ };
693
+ };
694
+
695
+ export const discoverSetupContract = async ({ repoRoot, detection }) => {
696
+ const resolvedRepoRoot = resolve(repoRoot ?? ".");
697
+ const repositoryDetection =
698
+ detection ??
699
+ (await discoverSetupRepository({ repoRoot: resolvedRepoRoot }));
700
+ const files = await sourceFilesForSetupDiscovery(resolvedRepoRoot);
701
+ const backendFiles = setupSourceCandidates(files, repositoryDetection);
702
+ const lifecycle = lifecycleSitesFor(backendFiles);
703
+ const bootstrap = bootstrapSitesFor(backendFiles, repositoryDetection);
704
+ const sdkContract =
705
+ repositoryDetection.services?.backend?.length === 1
706
+ ? BACKEND_SDK_BY_FRAMEWORK[
707
+ repositoryDetection.services.backend[0].framework
708
+ ]
709
+ : null;
710
+ const ambiguous =
711
+ bootstrap.ambiguous ||
712
+ lifecycle.identifyCandidateCount > 1 ||
713
+ lifecycle.groupCandidateCount > 1 ||
714
+ lifecycle.resetCandidateCount > 1;
715
+ const applicableLifecycleApis = [
716
+ ...(bootstrap.site ? ["cluebase.init"] : []),
717
+ ...(lifecycle.identifyCandidateCount > 0 ? ["cluebase.identify"] : []),
718
+ ...(lifecycle.groupCandidateCount > 0 ? ["cluebase.group"] : []),
719
+ ...(lifecycle.resetCandidateCount > 0 ? ["cluebase.reset"] : []),
720
+ ];
721
+ const ambiguousLifecycleApis = [
722
+ ...(bootstrap.ambiguous ? ["cluebase.init"] : []),
723
+ ...(lifecycle.identifyCandidateCount > 1 ? ["cluebase.identify"] : []),
724
+ ...(lifecycle.groupCandidateCount > 1 ? ["cluebase.group"] : []),
725
+ ...(lifecycle.resetCandidateCount > 1 ? ["cluebase.reset"] : []),
726
+ ];
727
+ const backendService = repositoryDetection.services?.backend;
728
+ const frontendServices = repositoryDetection.services?.frontend ?? [];
729
+ const primaryService =
730
+ backendService?.length === 1
731
+ ? backendService[0]
732
+ : frontendServices.length === 1
733
+ ? frontendServices[0]
734
+ : null;
735
+ return {
736
+ cluebaseInitBackend: bootstrap.site,
737
+ cluebaseInitFrontend: null,
738
+ identifySites:
739
+ lifecycle.identifySites.length === 1 ? lifecycle.identifySites : [],
740
+ groupSites: lifecycle.groupSites.length === 1 ? lifecycle.groupSites : [],
741
+ resetSites: lifecycle.resetSites.length === 1 ? lifecycle.resetSites : [],
742
+ applicableLifecycleApis,
743
+ ambiguousLifecycleApis,
744
+ hasUnclearPoints:
745
+ ambiguous ||
746
+ !repositoryDetection.detected ||
747
+ Boolean(sdkContract?.blocker),
748
+ ...(sdkContract?.packages?.[0]
749
+ ? { sdkPackageName: sdkContract.packages[0] }
750
+ : {}),
751
+ ...(repositoryDetection.services?.backend?.length === 1
752
+ ? { serviceKey: repositoryDetection.services.backend[0].service_key }
753
+ : {}),
754
+ ...(backendService?.length === 1
755
+ ? { backendRootPath: backendService[0].root_path }
756
+ : {}),
757
+ ...(primaryService
758
+ ? {
759
+ language: primaryService.language,
760
+ framework: primaryService.framework,
761
+ }
762
+ : {}),
763
+ };
764
+ };