@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.
- package/README.md +101 -0
- package/bin/cluebase-cli.mjs +11 -0
- package/package.json +17 -0
- package/src/cli-command.mjs +515 -0
- package/src/cli-invocation.mjs +17 -0
- package/src/code-evidence-analyzer.mjs +2041 -0
- package/src/contracts.mjs +36 -0
- package/src/generated-code-evidence-contract.mjs +22 -0
- package/src/generated-sdk-version-contract.mjs +5 -0
- package/src/generated-source-path-policy.mjs +20 -0
- package/src/lifecycle-guard.mjs +202 -0
- package/src/path-policy.mjs +81 -0
- package/src/setup-ai-contract.mjs +221 -0
- package/src/setup-check-constants.mjs +110 -0
- package/src/setup-check-scan-a.mjs +849 -0
- package/src/setup-check-scan-b.mjs +994 -0
- package/src/setup-check.mjs +575 -0
- package/src/setup-discover-check.mjs +755 -0
- package/src/setup-doctor-deadline.mjs +221 -0
- package/src/setup-doctor-env.mjs +331 -0
- package/src/setup-doctor-file-boundary.mjs +426 -0
- package/src/setup-doctor-probe.mjs +719 -0
- package/src/setup-doctor-quality-checks-a.mjs +593 -0
- package/src/setup-doctor-quality-checks-b.mjs +638 -0
- package/src/setup-doctor-quality-shared.mjs +382 -0
- package/src/setup-doctor-quality.mjs +209 -0
- package/src/setup-doctor-route-scan.mjs +160 -0
- package/src/setup-doctor-sdk-probe.mjs +340 -0
- package/src/setup-doctor.mjs +545 -0
- package/src/setup-documents.mjs +112 -0
- package/src/setup-help.mjs +130 -0
- package/src/setup-prepare.mjs +360 -0
- package/src/setup-repository-discovery.mjs +764 -0
- package/src/setup-step-builders-discover.mjs +701 -0
- package/src/setup-step-builders-events.mjs +229 -0
- package/src/setup-step-builders-implement.mjs +710 -0
- package/src/setup-step-commands.mjs +427 -0
- 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
|
+
};
|