@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,2041 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CODE_EVIDENCE_ANALYZER_VERSION,
|
|
7
|
+
CODE_EVIDENCE_DATA_OPERATIONS,
|
|
8
|
+
CODE_EVIDENCE_OPERATION_DISCRIMINATOR_FIELD,
|
|
9
|
+
CODE_EVIDENCE_ROUTE_METHODS,
|
|
10
|
+
} from "./generated-code-evidence-contract.mjs";
|
|
11
|
+
import { stripSourceNoise } from "./lifecycle-guard.mjs";
|
|
12
|
+
import { listAllowedSourceFiles } from "./path-policy.mjs";
|
|
13
|
+
import {
|
|
14
|
+
DEPENDENCY_FILE_CANDIDATES,
|
|
15
|
+
REQUIRED_LIFECYCLE_APIS,
|
|
16
|
+
SOURCE_EXTENSIONS,
|
|
17
|
+
sourceMatchesBackendInit,
|
|
18
|
+
} from "./setup-check-constants.mjs";
|
|
19
|
+
|
|
20
|
+
const HTTP_METHODS = new Set(CODE_EVIDENCE_ROUTE_METHODS);
|
|
21
|
+
const [DATA_READ, DATA_CREATE, DATA_UPDATE, DATA_DELETE, DATA_UNKNOWN] =
|
|
22
|
+
CODE_EVIDENCE_DATA_OPERATIONS;
|
|
23
|
+
const ROUTE_METHOD_NAMES = [...HTTP_METHODS]
|
|
24
|
+
.map((method) => method.toLowerCase())
|
|
25
|
+
.join("|");
|
|
26
|
+
|
|
27
|
+
const ROUTE_METHOD_PATTERN = new RegExp(
|
|
28
|
+
`\\b[A-Za-z_$][\\w$]*\\.(${ROUTE_METHOD_NAMES})\\s*\\(`,
|
|
29
|
+
"gi",
|
|
30
|
+
);
|
|
31
|
+
const SERVER_ROUTE_DECLARATION_PATTERN = new RegExp(
|
|
32
|
+
`\\b[A-Za-z_$][\\w$]*\\.(${ROUTE_METHOD_NAMES})\\s*\\(\\s*["']\\/[^"']*["']`,
|
|
33
|
+
"i",
|
|
34
|
+
);
|
|
35
|
+
const NEST_DECORATOR_PATTERN = new RegExp(
|
|
36
|
+
`@(${ROUTE_METHOD_NAMES.split("|")
|
|
37
|
+
.map((method) => method[0].toUpperCase() + method.slice(1))
|
|
38
|
+
.join("|")})\\s*(?:\\(\\s*(?:(['"\`])([^'"\`]*?)\\2)?\\s*\\))?`,
|
|
39
|
+
"g",
|
|
40
|
+
);
|
|
41
|
+
const CONTROLLER_DECORATOR_PATTERN =
|
|
42
|
+
/@Controller\s*\(\s*(?:(['"`])([^'"`]*)\1)?\s*\)/g;
|
|
43
|
+
|
|
44
|
+
const PYTHON_ROUTE_PATTERN =
|
|
45
|
+
/\b(?:[A-Za-z_]\w*)\.(get|post|put|patch|delete|options|head|trace|api_route)\s*\(\s*(['"])([^'"\n]+)\2([^\n]*)\)/gi;
|
|
46
|
+
|
|
47
|
+
const EXPRESS_FRAMEWORK_PATTERN =
|
|
48
|
+
/\bfrom\s+["']express["']|\brequire\(\s*["']express["']\s*\)|\bexpress\s*\(/;
|
|
49
|
+
const NEST_FRAMEWORK_PATTERN = /@Controller\s*\(/;
|
|
50
|
+
const FASTAPI_FRAMEWORK_PATTERN =
|
|
51
|
+
/\bfrom\s+fastapi\b|\bimport\s+fastapi\b|\b(?:FastAPI|APIRouter)\s*\(/;
|
|
52
|
+
|
|
53
|
+
const JS_DISCRIMINATOR_PATTERNS = [
|
|
54
|
+
/\b(?:req|request|ctx)\.(?:body|query|params)\.([A-Za-z_$][\w$]*)\s*={2,3}\s*(["'])([^"']+)\2/g,
|
|
55
|
+
/\b(?:req|request|ctx)\.(?:body|query|params)\[\s*(["'])([^"']+)\1\s*\]\s*={2,3}\s*(["'])([^"']+)\3/g,
|
|
56
|
+
/\b(?:body|payload|input)\[\s*(["'])([^"']+)\1\s*\]\s*={2,3}\s*(["'])([^"']+)\3/g,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const PYTHON_DISCRIMINATOR_PATTERNS = [
|
|
60
|
+
/\b(?:request|payload|body|data|input)\s*\.\s*([A-Za-z_]\w*)\s*={2,3}\s*(["'])([^"']+)\2/g,
|
|
61
|
+
/\b(?:request|payload|body|data|input)\s*\[\s*(["'])([^"']+)\1\s*\]\s*={2,3}\s*(["'])([^"']+)\3/g,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const LIFECYCLE_PATTERN = new RegExp(
|
|
65
|
+
`\\b(${REQUIRED_LIFECYCLE_APIS.map((api) => api.replace(".", "\\.")).join(
|
|
66
|
+
"|",
|
|
67
|
+
)})\\s*\\(`,
|
|
68
|
+
"g",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const EXISTING_EXTERNAL_LINK_PATTERN = /\bcluebase\.link\s*\(/;
|
|
72
|
+
const EXTERNAL_METADATA_ORGANIZATION_PATTERN =
|
|
73
|
+
/\bmetadata\s*(?:\.\s*organization_id|\[\s*["']organization_id["']\s*\])/i;
|
|
74
|
+
const ORGANIZATION_ID_PATTERN =
|
|
75
|
+
/\b(?:organization|org)\s*(?:\.\s*id|\[\s*["']id["']\s*\])/i;
|
|
76
|
+
const EXTERNAL_ID_PATTERN = /\b([A-Za-z_$][\w$]*(?:Id|_id))\b/g;
|
|
77
|
+
const NON_EXTERNAL_ID_NAMES = new Set([
|
|
78
|
+
"id",
|
|
79
|
+
"organization_id",
|
|
80
|
+
"organizationId",
|
|
81
|
+
"user_id",
|
|
82
|
+
"userId",
|
|
83
|
+
"anonymous_id",
|
|
84
|
+
"anonymousId",
|
|
85
|
+
"session_id",
|
|
86
|
+
"sessionId",
|
|
87
|
+
"request_id",
|
|
88
|
+
"requestId",
|
|
89
|
+
"trace_id",
|
|
90
|
+
"traceId",
|
|
91
|
+
]);
|
|
92
|
+
const NON_EXTERNAL_ID_PATTERN = /(?:By|From|With|For)Id$/;
|
|
93
|
+
const RELATIVE_IMPORT_PATTERN =
|
|
94
|
+
/(?:from\s+|import\s*\(\s*|require\s*\(\s*)(['"])(\.[^'"\n]+)\1/g;
|
|
95
|
+
const CALL_PATTERN = /\b([A-Za-z_$][\w$]*)\s*\(/g;
|
|
96
|
+
const NON_CALLABLE_NAMES = new Set([
|
|
97
|
+
"if",
|
|
98
|
+
"for",
|
|
99
|
+
"while",
|
|
100
|
+
"switch",
|
|
101
|
+
"catch",
|
|
102
|
+
"function",
|
|
103
|
+
"return",
|
|
104
|
+
"typeof",
|
|
105
|
+
"await",
|
|
106
|
+
"async",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const normalizePath = (value) => value.replaceAll("\\", "/");
|
|
110
|
+
|
|
111
|
+
const matchesPattern = (source, pattern) => {
|
|
112
|
+
pattern.lastIndex = 0;
|
|
113
|
+
const matched = pattern.test(source);
|
|
114
|
+
pattern.lastIndex = 0;
|
|
115
|
+
return matched;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const lineNumberAt = (source, index) =>
|
|
119
|
+
source.slice(0, index).split("\n").length;
|
|
120
|
+
|
|
121
|
+
const lineEndAt = (source, index) => {
|
|
122
|
+
const newline = source.indexOf("\n", index);
|
|
123
|
+
return newline === -1 ? source.length : newline;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const isQuote = (value) => value === "'" || value === '"' || value === "`";
|
|
127
|
+
|
|
128
|
+
const findMatchingDelimiter = (source, start, opening = "(", closing = ")") => {
|
|
129
|
+
let depth = 0;
|
|
130
|
+
let quote = null;
|
|
131
|
+
for (let index = start; index < source.length; index += 1) {
|
|
132
|
+
const current = source[index];
|
|
133
|
+
const next = source[index + 1];
|
|
134
|
+
if (quote) {
|
|
135
|
+
if (current === "\\" && quote !== "`" && index + 1 < source.length) {
|
|
136
|
+
index += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (current === quote) quote = null;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (current === "/" && next === "/") {
|
|
143
|
+
const newline = source.indexOf("\n", index + 2);
|
|
144
|
+
index = newline === -1 ? source.length : newline;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (current === "/" && next === "*") {
|
|
148
|
+
const end = source.indexOf("*/", index + 2);
|
|
149
|
+
index = end === -1 ? source.length : end + 1;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (isQuote(current)) {
|
|
153
|
+
quote = current;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (current === opening) depth += 1;
|
|
157
|
+
if (current === closing) {
|
|
158
|
+
depth -= 1;
|
|
159
|
+
if (depth === 0) return index;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return -1;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const splitTopLevel = (source) => {
|
|
166
|
+
const values = [];
|
|
167
|
+
let start = 0;
|
|
168
|
+
let parenDepth = 0;
|
|
169
|
+
let braceDepth = 0;
|
|
170
|
+
let bracketDepth = 0;
|
|
171
|
+
let quote = null;
|
|
172
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
173
|
+
const current = source[index];
|
|
174
|
+
const next = source[index + 1];
|
|
175
|
+
if (quote) {
|
|
176
|
+
if (current === "\\" && quote !== "`" && index + 1 < source.length) {
|
|
177
|
+
index += 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (current === quote) quote = null;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (current === "/" && next === "/") {
|
|
184
|
+
const newline = source.indexOf("\n", index + 2);
|
|
185
|
+
index = newline === -1 ? source.length : newline;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (current === "/" && next === "*") {
|
|
189
|
+
const end = source.indexOf("*/", index + 2);
|
|
190
|
+
index = end === -1 ? source.length : end + 1;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (isQuote(current)) {
|
|
194
|
+
quote = current;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (current === "(") parenDepth += 1;
|
|
198
|
+
if (current === ")") parenDepth -= 1;
|
|
199
|
+
if (current === "{") braceDepth += 1;
|
|
200
|
+
if (current === "}") braceDepth -= 1;
|
|
201
|
+
if (current === "[") bracketDepth += 1;
|
|
202
|
+
if (current === "]") bracketDepth -= 1;
|
|
203
|
+
if (
|
|
204
|
+
current === "," &&
|
|
205
|
+
parenDepth === 0 &&
|
|
206
|
+
braceDepth === 0 &&
|
|
207
|
+
bracketDepth === 0
|
|
208
|
+
) {
|
|
209
|
+
values.push(source.slice(start, index).trim());
|
|
210
|
+
start = index + 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
values.push(source.slice(start).trim());
|
|
214
|
+
return values;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const stringLiteralValue = (value) => {
|
|
218
|
+
const match = value.match(/^(['"`])([^'"`]*)\1$/s);
|
|
219
|
+
if (!match || (match[1] === "`" && value.includes("${"))) return null;
|
|
220
|
+
return match[2];
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const joinRoutePath = (...parts) => {
|
|
224
|
+
const path = parts
|
|
225
|
+
.map((part) => String(part ?? "").trim())
|
|
226
|
+
.filter(Boolean)
|
|
227
|
+
.join("/")
|
|
228
|
+
.replace(/\/+/g, "/");
|
|
229
|
+
return `/${path}`.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const resourceFromPath = (path) => {
|
|
233
|
+
const segment = path
|
|
234
|
+
.split("/")
|
|
235
|
+
.map((value) => value.trim())
|
|
236
|
+
.find((value) => value && !value.startsWith(":") && !value.startsWith("{"));
|
|
237
|
+
return segment ? segment.replace(/[^A-Za-z0-9_-]/g, "") || null : null;
|
|
238
|
+
};
|
|
239
|
+
const dataOperationForSource = (analysisSource) => {
|
|
240
|
+
const directCallSuffix = "(?:[A-Z][A-Za-z0-9]*|_[A-Za-z0-9]+)?";
|
|
241
|
+
if (
|
|
242
|
+
new RegExp(
|
|
243
|
+
`\\b(?:delete|destroy|remove|drop|truncate|purge)${directCallSuffix}\\s*\\(`,
|
|
244
|
+
"i",
|
|
245
|
+
).test(analysisSource)
|
|
246
|
+
) {
|
|
247
|
+
return DATA_DELETE;
|
|
248
|
+
}
|
|
249
|
+
if (
|
|
250
|
+
new RegExp(
|
|
251
|
+
`\\b(?:update|upsert|patch)${directCallSuffix}\\s*\\(`,
|
|
252
|
+
"i",
|
|
253
|
+
).test(analysisSource)
|
|
254
|
+
) {
|
|
255
|
+
return DATA_UPDATE;
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
new RegExp(
|
|
259
|
+
`\\b(?:create|insert|add|save)${directCallSuffix}\\s*\\(`,
|
|
260
|
+
"i",
|
|
261
|
+
).test(analysisSource)
|
|
262
|
+
) {
|
|
263
|
+
return DATA_CREATE;
|
|
264
|
+
}
|
|
265
|
+
if (
|
|
266
|
+
new RegExp(
|
|
267
|
+
`\\b(?:select|find|get|read|list|search)${directCallSuffix}\\s*\\(`,
|
|
268
|
+
"i",
|
|
269
|
+
).test(analysisSource)
|
|
270
|
+
) {
|
|
271
|
+
return DATA_READ;
|
|
272
|
+
}
|
|
273
|
+
return DATA_UNKNOWN;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const withoutCommentsAndStringLiterals = (source) => {
|
|
277
|
+
let output = "";
|
|
278
|
+
let quote = null;
|
|
279
|
+
let comment = null;
|
|
280
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
281
|
+
const current = source[index];
|
|
282
|
+
const next = source[index + 1];
|
|
283
|
+
if (comment === "line") {
|
|
284
|
+
output += current === "\n" ? "\n" : " ";
|
|
285
|
+
if (current === "\n") comment = null;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (comment === "block") {
|
|
289
|
+
output += current === "\n" ? "\n" : " ";
|
|
290
|
+
if (current === "*" && next === "/") {
|
|
291
|
+
output += " ";
|
|
292
|
+
index += 1;
|
|
293
|
+
comment = null;
|
|
294
|
+
}
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (quote) {
|
|
298
|
+
output += current === "\n" ? "\n" : " ";
|
|
299
|
+
if (current === "\\" && index + 1 < source.length) {
|
|
300
|
+
output += " ";
|
|
301
|
+
index += 1;
|
|
302
|
+
} else if (current === quote) {
|
|
303
|
+
quote = null;
|
|
304
|
+
}
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if ((current === "/" && next === "/") || current === "#") {
|
|
308
|
+
output += " ";
|
|
309
|
+
index += 1;
|
|
310
|
+
comment = "line";
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (current === "/" && next === "*") {
|
|
314
|
+
output += " ";
|
|
315
|
+
index += 1;
|
|
316
|
+
comment = "block";
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (isQuote(current)) {
|
|
320
|
+
output += " ";
|
|
321
|
+
quote = current;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
output += current;
|
|
325
|
+
}
|
|
326
|
+
return output;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const functionNameNear = (source, start, end) => {
|
|
330
|
+
const declaration = source
|
|
331
|
+
.slice(start, Math.min(source.length, end + 240))
|
|
332
|
+
.match(
|
|
333
|
+
/(?:async\s+)?(?:function\s+([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>|([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{)/,
|
|
334
|
+
);
|
|
335
|
+
return (
|
|
336
|
+
declaration?.[1] ??
|
|
337
|
+
declaration?.[2] ??
|
|
338
|
+
declaration?.[3] ??
|
|
339
|
+
"anonymous_route"
|
|
340
|
+
);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const findHandlerEnd = (source, start, fallbackEnd) => {
|
|
344
|
+
const opening = source.indexOf("{", start);
|
|
345
|
+
if (opening === -1 || opening > fallbackEnd + 240) return fallbackEnd;
|
|
346
|
+
const closing = findMatchingDelimiter(source, opening, "{", "}");
|
|
347
|
+
return closing === -1 ? fallbackEnd : closing;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const escapedRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
351
|
+
|
|
352
|
+
const relativeDependencyPath = (filePath, specifier, sourceByFile) => {
|
|
353
|
+
const directory = filePath.includes("/")
|
|
354
|
+
? filePath.slice(0, filePath.lastIndexOf("/"))
|
|
355
|
+
: "";
|
|
356
|
+
const base = normalizePath(`${directory}/${specifier}`)
|
|
357
|
+
.replace(/\/\.\//g, "/")
|
|
358
|
+
.replace(/^\.\//, "")
|
|
359
|
+
.replace(/\/+/g, "/");
|
|
360
|
+
const extension = base.match(/\.[A-Za-z0-9]+$/) ? "" : null;
|
|
361
|
+
const candidates = extension
|
|
362
|
+
? [base]
|
|
363
|
+
: [
|
|
364
|
+
base,
|
|
365
|
+
...SOURCE_EXTENSIONS.map((item) => `${base}${item}`),
|
|
366
|
+
...SOURCE_EXTENSIONS.map((item) => `${base}/index${item}`),
|
|
367
|
+
];
|
|
368
|
+
return candidates.find((candidate) => sourceByFile.has(candidate)) ?? null;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const namedFunctionSource = (source, name) => {
|
|
372
|
+
const escapedName = escapedRegExp(name);
|
|
373
|
+
const patterns = [
|
|
374
|
+
new RegExp(
|
|
375
|
+
`(?:async\\s+)?function\\s+${escapedName}\\s*\\([^)]*\\)\\s*\\{`,
|
|
376
|
+
),
|
|
377
|
+
new RegExp(
|
|
378
|
+
`(?:const|let|var)\\s+${escapedName}\\s*=\\s*(?:async\\s*)?\\([^)]*\\)\\s*=>\\s*\\{`,
|
|
379
|
+
),
|
|
380
|
+
new RegExp(
|
|
381
|
+
`\\b${escapedName}\\s*:\\s*(?:async\\s*)?\\([^)]*\\)\\s*=>\\s*\\{`,
|
|
382
|
+
),
|
|
383
|
+
new RegExp(`(?:async\\s+)?${escapedName}\\s*\\([^)]*\\)\\s*\\{`),
|
|
384
|
+
];
|
|
385
|
+
for (const pattern of patterns) {
|
|
386
|
+
const match = pattern.exec(source);
|
|
387
|
+
if (!match) continue;
|
|
388
|
+
const opening = source.indexOf("{", match.index);
|
|
389
|
+
const closing = findMatchingDelimiter(source, opening, "{", "}");
|
|
390
|
+
if (closing !== -1) return source.slice(match.index, closing + 1);
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const directDependencySourceFor = ({
|
|
396
|
+
filePath,
|
|
397
|
+
fileSource,
|
|
398
|
+
handlerSource,
|
|
399
|
+
sourceByFile,
|
|
400
|
+
}) => {
|
|
401
|
+
const dependencyPaths = [];
|
|
402
|
+
RELATIVE_IMPORT_PATTERN.lastIndex = 0;
|
|
403
|
+
let importMatch;
|
|
404
|
+
while ((importMatch = RELATIVE_IMPORT_PATTERN.exec(fileSource))) {
|
|
405
|
+
const dependencyPath = relativeDependencyPath(
|
|
406
|
+
filePath,
|
|
407
|
+
importMatch[2],
|
|
408
|
+
sourceByFile,
|
|
409
|
+
);
|
|
410
|
+
if (dependencyPath) dependencyPaths.push(dependencyPath);
|
|
411
|
+
}
|
|
412
|
+
if (dependencyPaths.length === 0) return "";
|
|
413
|
+
|
|
414
|
+
const callableNames = new Set();
|
|
415
|
+
CALL_PATTERN.lastIndex = 0;
|
|
416
|
+
let callMatch;
|
|
417
|
+
while ((callMatch = CALL_PATTERN.exec(handlerSource))) {
|
|
418
|
+
if (!NON_CALLABLE_NAMES.has(callMatch[1])) callableNames.add(callMatch[1]);
|
|
419
|
+
}
|
|
420
|
+
return [...new Set(dependencyPaths)]
|
|
421
|
+
.flatMap((dependencyPath) => {
|
|
422
|
+
const dependencySource = sourceByFile.get(dependencyPath);
|
|
423
|
+
if (!dependencySource) return [];
|
|
424
|
+
const functions = [...callableNames]
|
|
425
|
+
.map((name) => namedFunctionSource(dependencySource, name))
|
|
426
|
+
.filter((value) => value !== null);
|
|
427
|
+
return functions.length > 0 ? functions : [];
|
|
428
|
+
})
|
|
429
|
+
.join("\n");
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const directDependencyAnalysisFor = ({
|
|
433
|
+
filePath,
|
|
434
|
+
fileSource,
|
|
435
|
+
handlerSource,
|
|
436
|
+
sourceByFile,
|
|
437
|
+
}) => {
|
|
438
|
+
const dependencySpecifiers = [];
|
|
439
|
+
RELATIVE_IMPORT_PATTERN.lastIndex = 0;
|
|
440
|
+
let importMatch;
|
|
441
|
+
while ((importMatch = RELATIVE_IMPORT_PATTERN.exec(fileSource))) {
|
|
442
|
+
dependencySpecifiers.push({
|
|
443
|
+
specifier: importMatch[2],
|
|
444
|
+
index: importMatch.index,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
const dependencyPaths = dependencySpecifiers.map(({ specifier }) =>
|
|
448
|
+
relativeDependencyPath(filePath, specifier, sourceByFile),
|
|
449
|
+
);
|
|
450
|
+
const unresolvedImport = dependencyPaths.some((path) => path === null);
|
|
451
|
+
const importedNames = new Set();
|
|
452
|
+
const namedImportsPattern = /import\s*\{([^}]+)\}\s*from\s*["']\.[^"']+["']/g;
|
|
453
|
+
let namedImport;
|
|
454
|
+
while ((namedImport = namedImportsPattern.exec(fileSource))) {
|
|
455
|
+
for (const item of namedImport[1].split(",")) {
|
|
456
|
+
const name = item
|
|
457
|
+
.trim()
|
|
458
|
+
.split(/\s+as\s+/i)
|
|
459
|
+
.at(-1);
|
|
460
|
+
if (name && /^[A-Za-z_$][\w$]*$/.test(name)) importedNames.add(name);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const dependencySources = dependencyPaths.flatMap((path) =>
|
|
464
|
+
path ? [sourceByFile.get(path) ?? ""] : [],
|
|
465
|
+
);
|
|
466
|
+
const unresolvedNamedImport = [...importedNames].some((name) => {
|
|
467
|
+
const matches = dependencySources.filter((source) =>
|
|
468
|
+
namedFunctionSource(source, name),
|
|
469
|
+
);
|
|
470
|
+
return matches.length !== 1;
|
|
471
|
+
});
|
|
472
|
+
return {
|
|
473
|
+
source: directDependencySourceFor({
|
|
474
|
+
filePath,
|
|
475
|
+
fileSource,
|
|
476
|
+
handlerSource,
|
|
477
|
+
sourceByFile,
|
|
478
|
+
}),
|
|
479
|
+
resolved: !unresolvedImport && !unresolvedNamedImport,
|
|
480
|
+
};
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const safeDiscriminator = (_field, value) =>
|
|
484
|
+
`${CODE_EVIDENCE_OPERATION_DISCRIMINATOR_FIELD}=sha256:${createHash("sha256")
|
|
485
|
+
.update(`${CODE_EVIDENCE_OPERATION_DISCRIMINATOR_FIELD}=${value}`)
|
|
486
|
+
.digest("hex")}`;
|
|
487
|
+
|
|
488
|
+
const branchDiscriminators = (source, language) => {
|
|
489
|
+
const patterns =
|
|
490
|
+
language === "python"
|
|
491
|
+
? PYTHON_DISCRIMINATOR_PATTERNS
|
|
492
|
+
: JS_DISCRIMINATOR_PATTERNS;
|
|
493
|
+
const discriminators = [];
|
|
494
|
+
for (const pattern of patterns) {
|
|
495
|
+
pattern.lastIndex = 0;
|
|
496
|
+
let match;
|
|
497
|
+
while ((match = pattern.exec(source))) {
|
|
498
|
+
const field = match[4] ? match[2] : match[1];
|
|
499
|
+
const value = match[4] ? match[4] : match[3];
|
|
500
|
+
if (!field || !value) continue;
|
|
501
|
+
discriminators.push({
|
|
502
|
+
value: safeDiscriminator(field, value),
|
|
503
|
+
literal: value,
|
|
504
|
+
index: match.index,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return [
|
|
509
|
+
...new Map(discriminators.map((item) => [item.value, item])).values(),
|
|
510
|
+
].sort((left, right) => left.index - right.index);
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const branchTextFor = (source, discriminator, language) => {
|
|
514
|
+
if (language === "python") {
|
|
515
|
+
const lineStart = source.lastIndexOf("\n", discriminator.index) + 1;
|
|
516
|
+
const lines = source.slice(lineStart).split("\n");
|
|
517
|
+
const baseIndent = lines[0].match(/^\s*/)?.[0].length ?? 0;
|
|
518
|
+
const selected = [lines[0]];
|
|
519
|
+
for (const line of lines.slice(1)) {
|
|
520
|
+
if (line.trim() && (line.match(/^\s*/)?.[0].length ?? 0) <= baseIndent)
|
|
521
|
+
break;
|
|
522
|
+
selected.push(line);
|
|
523
|
+
}
|
|
524
|
+
return selected.join("\n");
|
|
525
|
+
}
|
|
526
|
+
const branchStart = source.lastIndexOf("if", discriminator.index);
|
|
527
|
+
const opening = source.indexOf("{", discriminator.index);
|
|
528
|
+
if (opening === -1) return source;
|
|
529
|
+
const closing = findMatchingDelimiter(source, opening, "{", "}");
|
|
530
|
+
return source.slice(
|
|
531
|
+
branchStart === -1 ? opening : branchStart,
|
|
532
|
+
closing === -1 ? source.length : closing + 1,
|
|
533
|
+
);
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const hasRuntimeDiscriminatorCall = (source, language) =>
|
|
537
|
+
(language === "python"
|
|
538
|
+
? /set_operation_discriminator\s*\(/u
|
|
539
|
+
: /setOperationDiscriminator\s*\(/u
|
|
540
|
+
).test(source);
|
|
541
|
+
|
|
542
|
+
const effectsFor = ({ source, path }) => {
|
|
543
|
+
const analysisSource = withoutCommentsAndStringLiterals(source);
|
|
544
|
+
const persistenceWrite =
|
|
545
|
+
/\b(?:save|create|update|upsert|insert|delete|destroy|remove|clear)\s*\(/i.test(
|
|
546
|
+
analysisSource,
|
|
547
|
+
) ||
|
|
548
|
+
/\b(?:INSERT|UPDATE|DELETE)\s+INTO?\b/i.test(analysisSource) ||
|
|
549
|
+
/\b(?:prisma|repository|repo|model)\.[A-Za-z_$][\w$]*\.(?:create|update|upsert|delete|deleteMany|updateMany)\s*\(/i.test(
|
|
550
|
+
analysisSource,
|
|
551
|
+
);
|
|
552
|
+
const externalSideEffect =
|
|
553
|
+
/\b(?:fetch|axios|httpx|requests|urllib|sendMail|send_mail|notify|publish|stripe)\b/i.test(
|
|
554
|
+
analysisSource,
|
|
555
|
+
);
|
|
556
|
+
const notificationSideEffect =
|
|
557
|
+
/\b(?:sendMail|send_mail|notify|notification|publish)\b/i.test(
|
|
558
|
+
analysisSource,
|
|
559
|
+
);
|
|
560
|
+
const downstreamDependency =
|
|
561
|
+
/\b(?:service|repository|repo|client|gateway|useCase|use_case)\.[A-Za-z_$][\w$]*\s*\(/i.test(
|
|
562
|
+
analysisSource,
|
|
563
|
+
) ||
|
|
564
|
+
/\b[A-Za-z_$][\w$]*(?:Service|Repository|Client|Gateway)\.[A-Za-z_$][\w$]*\s*\(/.test(
|
|
565
|
+
analysisSource,
|
|
566
|
+
) ||
|
|
567
|
+
/\b[A-Za-z_$][\w$]*(?:_service|_repository|_client|_gateway)\.[A-Za-z_$][\w$]*\s*\(/i.test(
|
|
568
|
+
analysisSource,
|
|
569
|
+
) ||
|
|
570
|
+
/\b(?:await\s+)?[A-Za-z_$][\w$]*(?:Service|Repository|Client|Gateway)\s*\(/.test(
|
|
571
|
+
analysisSource,
|
|
572
|
+
);
|
|
573
|
+
const destructiveReference =
|
|
574
|
+
/\b(?:delete|destroy|remove|drop|truncate|purge)(?:_[A-Za-z0-9]+)?\s*\(/i.test(
|
|
575
|
+
analysisSource,
|
|
576
|
+
) || /\b(?:DELETE|DROP|TRUNCATE)\b/i.test(analysisSource);
|
|
577
|
+
const reversibleReference =
|
|
578
|
+
/\b(?:restore|undo|rollback|revert|unarchive|softDelete|soft_delete)(?:_[A-Za-z0-9]+)?\s*\(/i.test(
|
|
579
|
+
analysisSource,
|
|
580
|
+
);
|
|
581
|
+
const successPath =
|
|
582
|
+
/\breturn\b/.test(analysisSource) ||
|
|
583
|
+
/\bres\.(?:json|send|end|status)\s*\(/.test(analysisSource) ||
|
|
584
|
+
/\b(?:JSONResponse|Response|HTTPException)\s*\(/.test(analysisSource);
|
|
585
|
+
const dataOperation = dataOperationForSource(analysisSource);
|
|
586
|
+
return {
|
|
587
|
+
resource: resourceFromPath(path),
|
|
588
|
+
data_operation: dataOperation,
|
|
589
|
+
persistence_write: persistenceWrite,
|
|
590
|
+
external_side_effect: externalSideEffect,
|
|
591
|
+
notification_side_effect: notificationSideEffect,
|
|
592
|
+
downstream_dependency: downstreamDependency,
|
|
593
|
+
success_path: successPath,
|
|
594
|
+
destructive_reference: destructiveReference,
|
|
595
|
+
reversible_reference: reversibleReference,
|
|
596
|
+
};
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const confidenceFor = ({
|
|
600
|
+
deterministicDiscriminator,
|
|
601
|
+
dataOperation,
|
|
602
|
+
handlerResolved = true,
|
|
603
|
+
dependencyResolved = true,
|
|
604
|
+
operationBoundaryResolved = true,
|
|
605
|
+
}) => {
|
|
606
|
+
if (!handlerResolved || !dependencyResolved || !operationBoundaryResolved) {
|
|
607
|
+
return deterministicDiscriminator ? 0.4 : 0.3;
|
|
608
|
+
}
|
|
609
|
+
if (dataOperation === DATA_UNKNOWN)
|
|
610
|
+
return deterministicDiscriminator ? 0.4 : 0.3;
|
|
611
|
+
return deterministicDiscriminator ? 0.95 : 0.85;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const insertionsByFile = () => new Map();
|
|
615
|
+
|
|
616
|
+
const addInsertion = (insertions, filePath, index, text) => {
|
|
617
|
+
const fileInsertions = insertions.get(filePath) ?? [];
|
|
618
|
+
fileInsertions.push({ index, text });
|
|
619
|
+
insertions.set(filePath, fileInsertions);
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const applyInsertions = (source, insertions) =>
|
|
623
|
+
[...insertions]
|
|
624
|
+
.sort((left, right) => right.index - left.index)
|
|
625
|
+
.reduce(
|
|
626
|
+
(result, insertion) =>
|
|
627
|
+
`${result.slice(0, insertion.index)}${insertion.text}${result.slice(insertion.index)}`,
|
|
628
|
+
source,
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
const lifecycleRepairText = (api, anchor) => {
|
|
632
|
+
const python = anchor.filePath?.endsWith(".py");
|
|
633
|
+
const typescript = /\.tsx?$/.test(anchor.filePath ?? "");
|
|
634
|
+
if (api === "cluebase.init") {
|
|
635
|
+
const serviceKey = JSON.stringify(anchor.serviceKey ?? "backend");
|
|
636
|
+
const endpoint = `process.env.CLUEBASE_INGEST_ENDPOINT${typescript ? "!" : ""}`;
|
|
637
|
+
const projectKey = `process.env.CLUEBASE_PROJECT_KEY${typescript ? "!" : ""}`;
|
|
638
|
+
const apiKey = `process.env.CLUEBASE_API_KEY${typescript ? "!" : ""}`;
|
|
639
|
+
if (python && anchor.framework === "fastapi") {
|
|
640
|
+
return `_cluebase_endpoint = os.getenv("CLUEBASE_INGEST_ENDPOINT")\n_cluebase_project_key = os.getenv("CLUEBASE_PROJECT_KEY")\n_cluebase_api_key = os.getenv("CLUEBASE_API_KEY")\nif _cluebase_endpoint and _cluebase_project_key and _cluebase_api_key:\n cluebase_init_fastapi(app, project_key=_cluebase_project_key, api_key=_cluebase_api_key, service_key=${serviceKey})`;
|
|
641
|
+
}
|
|
642
|
+
return python
|
|
643
|
+
? `if os.getenv("CLUEBASE_INGEST_ENDPOINT") and os.getenv("CLUEBASE_PROJECT_KEY") and os.getenv("CLUEBASE_API_KEY"):\n cluebase.init({"endpoint": os.getenv("CLUEBASE_INGEST_ENDPOINT"), "project_key": os.getenv("CLUEBASE_PROJECT_KEY"), "api_key": os.getenv("CLUEBASE_API_KEY"), "service_key": ${serviceKey}})`
|
|
644
|
+
: `cluebase.init({ endpoint: ${endpoint}, projectKey: ${projectKey}, apiKey: ${apiKey}, serviceKey: ${serviceKey} });`;
|
|
645
|
+
}
|
|
646
|
+
if (api === "cluebase.group") {
|
|
647
|
+
return python
|
|
648
|
+
? `cluebase.group("organization", ${anchor.idExpression}${anchor.nameExpression ? `, {"name": ${anchor.nameExpression}}` : ""})`
|
|
649
|
+
: `cluebase.group("organization", ${anchor.idExpression}${anchor.nameExpression ? `, { name: ${anchor.nameExpression} }` : ""});`;
|
|
650
|
+
}
|
|
651
|
+
if (api === "cluebase.identify") {
|
|
652
|
+
return python
|
|
653
|
+
? `cluebase.identify(${anchor.idExpression}${anchor.nameExpression ? `, {"name": ${anchor.nameExpression}}` : ""})`
|
|
654
|
+
: `cluebase.identify(${anchor.idExpression}${anchor.nameExpression ? `, { name: ${anchor.nameExpression} }` : ""});`;
|
|
655
|
+
}
|
|
656
|
+
if (api === "cluebase.reset")
|
|
657
|
+
return python ? "cluebase.reset()" : "cluebase.reset();";
|
|
658
|
+
return null;
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const DISCOVERY_SITE_KEYS = {
|
|
662
|
+
"cluebase.identify": "identifySites",
|
|
663
|
+
"cluebase.group": "groupSites",
|
|
664
|
+
"cluebase.reset": "resetSites",
|
|
665
|
+
};
|
|
666
|
+
const SAFE_EXPRESSION_PATTERN =
|
|
667
|
+
/^[A-Za-z_$][\w$]*(?:(?:\.[A-Za-z_$][\w$]*)|(?:\[\s*["'][^"']+["']\s*\])|(?:\[\s*\d+\s*\]))*$/;
|
|
668
|
+
|
|
669
|
+
const discoveryAnchorsFor = ({ api, setupDiscovery, sourceByFile }) => {
|
|
670
|
+
if (!setupDiscovery) return null;
|
|
671
|
+
const ambiguousApis = setupDiscovery.ambiguousLifecycleApis;
|
|
672
|
+
if (
|
|
673
|
+
Array.isArray(ambiguousApis) &&
|
|
674
|
+
ambiguousApis.includes(api)
|
|
675
|
+
)
|
|
676
|
+
return null;
|
|
677
|
+
if (setupDiscovery.hasUnclearPoints && !Array.isArray(ambiguousApis))
|
|
678
|
+
return null;
|
|
679
|
+
const sites =
|
|
680
|
+
api === "cluebase.init"
|
|
681
|
+
? [setupDiscovery.cluebaseInitBackend].filter(Boolean)
|
|
682
|
+
: (setupDiscovery[DISCOVERY_SITE_KEYS[api]] ?? []);
|
|
683
|
+
if (!Array.isArray(sites) || sites.length === 0) return null;
|
|
684
|
+
const anchors = [];
|
|
685
|
+
for (const site of sites) {
|
|
686
|
+
if (!site || site.createsNewFile === true) return null;
|
|
687
|
+
const source = sourceByFile.get(site.file);
|
|
688
|
+
if (source === undefined || !Number.isInteger(site.line) || site.line < 1)
|
|
689
|
+
return null;
|
|
690
|
+
const lines = source.split("\n");
|
|
691
|
+
if (site.line > lines.length) return null;
|
|
692
|
+
const index = lines
|
|
693
|
+
.slice(0, site.line - 1)
|
|
694
|
+
.reduce((sum, line) => sum + line.length + 1, 0);
|
|
695
|
+
const lineEnd = index + lines[site.line - 1].length;
|
|
696
|
+
let insertionIndex = index;
|
|
697
|
+
let insertionIndent =
|
|
698
|
+
source.slice(index, lineEnd).match(/^\s*/)?.[0] ?? "";
|
|
699
|
+
let insertBefore = false;
|
|
700
|
+
if (api === "cluebase.init" || api === "cluebase.reset") {
|
|
701
|
+
if (api === "cluebase.init" && setupDiscovery.framework === "fastapi") {
|
|
702
|
+
const constructorStart = source.lastIndexOf("FastAPI", lineEnd);
|
|
703
|
+
const opening =
|
|
704
|
+
constructorStart === -1
|
|
705
|
+
? -1
|
|
706
|
+
: source.indexOf("(", constructorStart);
|
|
707
|
+
const closing =
|
|
708
|
+
opening === -1
|
|
709
|
+
? -1
|
|
710
|
+
: findMatchingDelimiter(source, opening);
|
|
711
|
+
if (closing !== -1) insertionIndex = lineEndAt(source, closing) + 1;
|
|
712
|
+
}
|
|
713
|
+
anchors.push({
|
|
714
|
+
filePath: site.file,
|
|
715
|
+
source,
|
|
716
|
+
index: insertionIndex,
|
|
717
|
+
indent: insertionIndent,
|
|
718
|
+
lineEnd,
|
|
719
|
+
idExpression: null,
|
|
720
|
+
nameExpression: null,
|
|
721
|
+
serviceKey: site.serviceKey,
|
|
722
|
+
framework: setupDiscovery.framework,
|
|
723
|
+
});
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (site.file.endsWith(".py")) {
|
|
727
|
+
const functionStart = source.lastIndexOf("def ", index);
|
|
728
|
+
if (functionStart !== -1) {
|
|
729
|
+
const beforeAnchor = source.slice(functionStart, index);
|
|
730
|
+
const returnMatch = [
|
|
731
|
+
...beforeAnchor.matchAll(/(?:^|\n)([ \t]*)return\b/g),
|
|
732
|
+
].at(-1);
|
|
733
|
+
if (returnMatch?.index !== undefined) {
|
|
734
|
+
insertionIndex =
|
|
735
|
+
functionStart +
|
|
736
|
+
returnMatch.index +
|
|
737
|
+
(returnMatch[0].startsWith("\n") ? 1 : 0);
|
|
738
|
+
insertionIndent = returnMatch[1] ?? insertionIndent;
|
|
739
|
+
insertBefore = true;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const fields = site.availableFields ?? {};
|
|
744
|
+
const idExpression = fields.id ?? fields.groupKey;
|
|
745
|
+
const nameExpression = fields.name ?? null;
|
|
746
|
+
if (
|
|
747
|
+
typeof idExpression !== "string" ||
|
|
748
|
+
!SAFE_EXPRESSION_PATTERN.test(idExpression) ||
|
|
749
|
+
!source.includes(idExpression) ||
|
|
750
|
+
(nameExpression !== null &&
|
|
751
|
+
(typeof nameExpression !== "string" ||
|
|
752
|
+
!SAFE_EXPRESSION_PATTERN.test(nameExpression) ||
|
|
753
|
+
!source.includes(nameExpression)))
|
|
754
|
+
)
|
|
755
|
+
return null;
|
|
756
|
+
anchors.push({
|
|
757
|
+
filePath: site.file,
|
|
758
|
+
source,
|
|
759
|
+
index: insertionIndex,
|
|
760
|
+
indent: insertionIndent,
|
|
761
|
+
insertBefore,
|
|
762
|
+
lineEnd,
|
|
763
|
+
idExpression,
|
|
764
|
+
nameExpression,
|
|
765
|
+
serviceKey: site.serviceKey,
|
|
766
|
+
framework: setupDiscovery.framework,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
return anchors;
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
const operationRepairInsertions = ({ filePath, source, rows, language }) => {
|
|
773
|
+
if (!/\bcluebase\s*\./.test(source)) return null;
|
|
774
|
+
const branches = branchDiscriminators(source, language);
|
|
775
|
+
const insertions = [];
|
|
776
|
+
for (const row of rows) {
|
|
777
|
+
if (!row.operation_discriminator) return null;
|
|
778
|
+
const branch = branches.find(
|
|
779
|
+
(candidate) =>
|
|
780
|
+
candidate.value === row.operation_discriminator &&
|
|
781
|
+
lineNumberAt(source, candidate.index) ===
|
|
782
|
+
row.source_location.line_start,
|
|
783
|
+
);
|
|
784
|
+
if (!branch) return null;
|
|
785
|
+
if (
|
|
786
|
+
hasRuntimeDiscriminatorCall(
|
|
787
|
+
branchTextFor(source, branch, language),
|
|
788
|
+
language,
|
|
789
|
+
)
|
|
790
|
+
)
|
|
791
|
+
continue;
|
|
792
|
+
if (language === "python") {
|
|
793
|
+
const lineStart = source.lastIndexOf("\n", branch.index) + 1;
|
|
794
|
+
const indent =
|
|
795
|
+
source.slice(lineStart, branch.index).match(/^\s*/)?.[0] ?? "";
|
|
796
|
+
insertions.push({
|
|
797
|
+
index: lineEndAt(source, branch.index) + 1,
|
|
798
|
+
text: `\n${indent} cluebase.set_operation_discriminator(${JSON.stringify(branch.literal)})`,
|
|
799
|
+
});
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
const opening = source.indexOf("{", branch.index);
|
|
803
|
+
if (opening === -1) return null;
|
|
804
|
+
const insertionLineEnd = source.indexOf("\n", opening);
|
|
805
|
+
if (insertionLineEnd === -1) return null;
|
|
806
|
+
const lineStart = source.lastIndexOf("\n", branch.index) + 1;
|
|
807
|
+
const indent =
|
|
808
|
+
source.slice(lineStart, branch.index).match(/^\s*/)?.[0] ?? "";
|
|
809
|
+
insertions.push({
|
|
810
|
+
index: insertionLineEnd + 1,
|
|
811
|
+
text: `${indent} cluebase.setOperationDiscriminator(${JSON.stringify(branch.literal)});\n`,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
return insertions;
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
const sdkImportTextFor = (filePath, source) => {
|
|
818
|
+
const python = filePath.endsWith(".py");
|
|
819
|
+
const alreadyImported = python
|
|
820
|
+
? /\bfrom\s+cluebase_backend_sdk\s+import\s+cluebase\b/.test(source)
|
|
821
|
+
: /\bfrom\s+["']@genn-inc\/cluebase-backend-sdk["']|\bimport\s+cluebase\s+from\s+["']@genn-inc\/cluebase-backend-sdk["']/.test(
|
|
822
|
+
source,
|
|
823
|
+
);
|
|
824
|
+
if (alreadyImported) return null;
|
|
825
|
+
return python
|
|
826
|
+
? `${/\bimport\s+os\b/.test(source) ? "" : "import os\n"}from cluebase_backend_sdk import cluebase\n`
|
|
827
|
+
: 'import cluebase from "@genn-inc/cluebase-backend-sdk";\n';
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
const addSdkImport = ({ insertions, sourceByFile, filePath }) => {
|
|
831
|
+
const source = sourceByFile.get(filePath);
|
|
832
|
+
if (source === undefined) return false;
|
|
833
|
+
const pendingInsertions = insertions.get(filePath) ?? [];
|
|
834
|
+
if (
|
|
835
|
+
pendingInsertions.some(
|
|
836
|
+
(item) =>
|
|
837
|
+
item.index === 0 &&
|
|
838
|
+
/\bfrom\s+cluebase_backend_sdk\s+import\s+cluebase\b|\bfrom\s+["']@genn-inc\/cluebase-backend-sdk["']|\bimport\s+cluebase\s+from\s+["']@genn-inc\/cluebase-backend-sdk["']/.test(
|
|
839
|
+
item.text,
|
|
840
|
+
),
|
|
841
|
+
)
|
|
842
|
+
)
|
|
843
|
+
return true;
|
|
844
|
+
let importText = sdkImportTextFor(filePath, source);
|
|
845
|
+
if (
|
|
846
|
+
filePath.endsWith(".py") &&
|
|
847
|
+
pendingInsertions.some(
|
|
848
|
+
(item) => item.index === 0 && /\bimport\s+os\b/.test(item.text),
|
|
849
|
+
)
|
|
850
|
+
) {
|
|
851
|
+
importText = importText?.replace(/^import os\n/u, "") ?? null;
|
|
852
|
+
}
|
|
853
|
+
if (importText === null) return true;
|
|
854
|
+
addInsertion(insertions, filePath, 0, importText);
|
|
855
|
+
return true;
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const sdkInitImportTextFor = ({ filePath, source, framework }) => {
|
|
859
|
+
if (!filePath.endsWith(".py") || framework !== "fastapi") {
|
|
860
|
+
return sdkImportTextFor(filePath, source);
|
|
861
|
+
}
|
|
862
|
+
const imports = [];
|
|
863
|
+
if (!/\bimport\s+os\b/.test(source)) imports.push("import os");
|
|
864
|
+
if (
|
|
865
|
+
!/\bfrom\s+cluebase_backend_sdk\._integrations\.fastapi\s+import\s+cluebase_init_fastapi\b/.test(
|
|
866
|
+
source,
|
|
867
|
+
)
|
|
868
|
+
) {
|
|
869
|
+
imports.push(
|
|
870
|
+
"from cluebase_backend_sdk._integrations.fastapi import cluebase_init_fastapi",
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
return imports.length > 0 ? `${imports.join("\n")}\n` : null;
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const packageDependencyFields = [
|
|
877
|
+
"dependencies",
|
|
878
|
+
"devDependencies",
|
|
879
|
+
"optionalDependencies",
|
|
880
|
+
"peerDependencies",
|
|
881
|
+
];
|
|
882
|
+
|
|
883
|
+
const hasSdkDependency = ({
|
|
884
|
+
sourceByFile,
|
|
885
|
+
packageManifest,
|
|
886
|
+
sdkPackageName,
|
|
887
|
+
}) => {
|
|
888
|
+
if (
|
|
889
|
+
packageManifest &&
|
|
890
|
+
typeof packageManifest === "object" &&
|
|
891
|
+
packageDependencyFields.some(
|
|
892
|
+
(field) =>
|
|
893
|
+
packageManifest[field] &&
|
|
894
|
+
typeof packageManifest[field] === "object" &&
|
|
895
|
+
typeof packageManifest[field][sdkPackageName] === "string",
|
|
896
|
+
)
|
|
897
|
+
)
|
|
898
|
+
return true;
|
|
899
|
+
for (const [filePath, source] of sourceByFile) {
|
|
900
|
+
if (basename(filePath) === "package.json") {
|
|
901
|
+
try {
|
|
902
|
+
const manifest = JSON.parse(source);
|
|
903
|
+
if (
|
|
904
|
+
packageDependencyFields.some(
|
|
905
|
+
(field) =>
|
|
906
|
+
manifest?.[field] &&
|
|
907
|
+
typeof manifest[field] === "object" &&
|
|
908
|
+
typeof manifest[field][sdkPackageName] === "string",
|
|
909
|
+
)
|
|
910
|
+
)
|
|
911
|
+
return true;
|
|
912
|
+
} catch {
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (
|
|
917
|
+
(filePath.endsWith(".txt") || basename(filePath) === "pyproject.toml") &&
|
|
918
|
+
new RegExp(
|
|
919
|
+
`(?:^|[\\s"'])${sdkPackageName.replace(/[.*+?^${}()|[\\]\\]/g, "\\\\$&")}(?:$|[\\s"'<>=])`,
|
|
920
|
+
"m",
|
|
921
|
+
).test(source)
|
|
922
|
+
)
|
|
923
|
+
return true;
|
|
924
|
+
}
|
|
925
|
+
return false;
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const packageRepairFile = ({
|
|
929
|
+
packageManifest,
|
|
930
|
+
sdkPackageName,
|
|
931
|
+
packageManifestPath = "package.json",
|
|
932
|
+
}) => {
|
|
933
|
+
if (!packageManifest || typeof packageManifest !== "object") return null;
|
|
934
|
+
const dependencyField = packageDependencyFields.find(
|
|
935
|
+
(field) =>
|
|
936
|
+
packageManifest[field] &&
|
|
937
|
+
typeof packageManifest[field] === "object" &&
|
|
938
|
+
typeof packageManifest[field][sdkPackageName] === "string",
|
|
939
|
+
);
|
|
940
|
+
if (dependencyField)
|
|
941
|
+
return null;
|
|
942
|
+
const targetField = dependencyField ?? "dependencies";
|
|
943
|
+
const dependencies =
|
|
944
|
+
packageManifest[targetField] &&
|
|
945
|
+
typeof packageManifest[targetField] === "object"
|
|
946
|
+
? { ...packageManifest[targetField] }
|
|
947
|
+
: {};
|
|
948
|
+
dependencies[sdkPackageName] = sdkPackageName.startsWith("@")
|
|
949
|
+
? "latest"
|
|
950
|
+
: sdkPackageName;
|
|
951
|
+
return {
|
|
952
|
+
path: packageManifestPath,
|
|
953
|
+
content: `${JSON.stringify({ ...packageManifest, [targetField]: dependencies }, null, 2)}\n`,
|
|
954
|
+
operation: "update",
|
|
955
|
+
};
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
const pythonDependencyRepairFile = ({
|
|
960
|
+
sourceByFile,
|
|
961
|
+
sdkPackageName,
|
|
962
|
+
backendRootPath,
|
|
963
|
+
}) => {
|
|
964
|
+
const allCandidates = [...sourceByFile.entries()].filter(([filePath]) =>
|
|
965
|
+
["requirements.txt", "requirements-dev.txt", "pyproject.toml"].includes(
|
|
966
|
+
basename(filePath),
|
|
967
|
+
),
|
|
968
|
+
);
|
|
969
|
+
const candidates = preferredDependencyCandidates(
|
|
970
|
+
allCandidates,
|
|
971
|
+
backendRootPath,
|
|
972
|
+
);
|
|
973
|
+
if (candidates.some(([, source]) => source.includes(sdkPackageName)))
|
|
974
|
+
return null;
|
|
975
|
+
if (candidates.length !== 1) return undefined;
|
|
976
|
+
const [filePath, source] = candidates[0];
|
|
977
|
+
if (filePath.endsWith(".txt")) {
|
|
978
|
+
return {
|
|
979
|
+
path: filePath,
|
|
980
|
+
content: `${source.trimEnd()}\n${sdkPackageName}\n`,
|
|
981
|
+
operation: "update",
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
const dependencyArrays = [
|
|
985
|
+
...source.matchAll(/(^\s*dependencies\s*=\s*\[)([\s\S]*?)(^\s*\])/gm),
|
|
986
|
+
];
|
|
987
|
+
if (dependencyArrays.length !== 1) return undefined;
|
|
988
|
+
const match = dependencyArrays[0];
|
|
989
|
+
const closingIndex = (match.index ?? 0) + match[0].lastIndexOf("]");
|
|
990
|
+
return {
|
|
991
|
+
path: filePath,
|
|
992
|
+
content: `${source.slice(0, closingIndex)} "${sdkPackageName}",\n${source.slice(closingIndex)}`,
|
|
993
|
+
operation: "update",
|
|
994
|
+
};
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
const preferredDependencyCandidates = (candidates, backendRootPath) => {
|
|
998
|
+
if (typeof backendRootPath !== "string" || !backendRootPath.trim())
|
|
999
|
+
return candidates;
|
|
1000
|
+
const backendParts = backendRootPath.split("/").filter(Boolean);
|
|
1001
|
+
const scoped = candidates
|
|
1002
|
+
.map(([filePath, source]) => {
|
|
1003
|
+
const directory = filePath.includes("/")
|
|
1004
|
+
? filePath.slice(0, filePath.lastIndexOf("/"))
|
|
1005
|
+
: ".";
|
|
1006
|
+
const directoryParts = directory === "." ? [] : directory.split("/");
|
|
1007
|
+
const isAncestor = directoryParts.every(
|
|
1008
|
+
(part, index) => backendParts[index] === part,
|
|
1009
|
+
);
|
|
1010
|
+
return isAncestor
|
|
1011
|
+
? { filePath, source, depth: directoryParts.length }
|
|
1012
|
+
: null;
|
|
1013
|
+
})
|
|
1014
|
+
.filter(Boolean);
|
|
1015
|
+
if (scoped.length === 0) return candidates;
|
|
1016
|
+
const deepest = Math.max(...scoped.map((candidate) => candidate.depth));
|
|
1017
|
+
return scoped
|
|
1018
|
+
.filter((candidate) => candidate.depth === deepest)
|
|
1019
|
+
.map((candidate) => [candidate.filePath, candidate.source]);
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
const sdkDependencyRepairFile = ({
|
|
1023
|
+
sourceByFile,
|
|
1024
|
+
packageManifest,
|
|
1025
|
+
sdkPackageName,
|
|
1026
|
+
packageManifestPath,
|
|
1027
|
+
backendRootPath,
|
|
1028
|
+
}) => {
|
|
1029
|
+
if (packageManifest && typeof packageManifest === "object") {
|
|
1030
|
+
return packageRepairFile({
|
|
1031
|
+
packageManifest,
|
|
1032
|
+
sdkPackageName,
|
|
1033
|
+
packageManifestPath,
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
return pythonDependencyRepairFile({
|
|
1037
|
+
sourceByFile,
|
|
1038
|
+
sdkPackageName,
|
|
1039
|
+
backendRootPath,
|
|
1040
|
+
});
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
const repairProposalFor = ({
|
|
1044
|
+
sourceByFile,
|
|
1045
|
+
evidence,
|
|
1046
|
+
missingLifecycleApis,
|
|
1047
|
+
operationDiscriminatorRequired,
|
|
1048
|
+
identityIntegration,
|
|
1049
|
+
setupDiscovery,
|
|
1050
|
+
packageManifest,
|
|
1051
|
+
packageManifestPath,
|
|
1052
|
+
backendRootPath,
|
|
1053
|
+
}) => {
|
|
1054
|
+
const insertions = insertionsByFile();
|
|
1055
|
+
const replacementFiles = new Map();
|
|
1056
|
+
for (const api of missingLifecycleApis) {
|
|
1057
|
+
const anchors = discoveryAnchorsFor({ api, setupDiscovery, sourceByFile });
|
|
1058
|
+
if (!anchors)
|
|
1059
|
+
return {
|
|
1060
|
+
proposal: null,
|
|
1061
|
+
reason: `ambiguous_${api.replace("cluebase.", "")}_boundary`,
|
|
1062
|
+
};
|
|
1063
|
+
for (const anchor of anchors) {
|
|
1064
|
+
const lineStart = anchor.source.lastIndexOf("\n", anchor.index) + 1;
|
|
1065
|
+
const indent =
|
|
1066
|
+
anchor.indent ??
|
|
1067
|
+
anchor.source.slice(lineStart, anchor.index).match(/^\s*/)?.[0] ??
|
|
1068
|
+
"";
|
|
1069
|
+
const text = lifecycleRepairText(api, anchor);
|
|
1070
|
+
if (!text) return { proposal: null, reason: `unsupported_${api}` };
|
|
1071
|
+
if (api === "cluebase.init") {
|
|
1072
|
+
const importText = sdkInitImportTextFor({
|
|
1073
|
+
filePath: anchor.filePath,
|
|
1074
|
+
source: anchor.source,
|
|
1075
|
+
framework: anchor.framework,
|
|
1076
|
+
});
|
|
1077
|
+
if (importText) {
|
|
1078
|
+
addInsertion(
|
|
1079
|
+
insertions,
|
|
1080
|
+
anchor.filePath,
|
|
1081
|
+
0,
|
|
1082
|
+
anchor.index === 0
|
|
1083
|
+
? `${importText}${indent}${text}\n`
|
|
1084
|
+
: importText,
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
if (!importText || anchor.index !== 0) {
|
|
1088
|
+
addInsertion(
|
|
1089
|
+
insertions,
|
|
1090
|
+
anchor.filePath,
|
|
1091
|
+
anchor.index,
|
|
1092
|
+
`${indent}${text}\n`,
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
if (
|
|
1098
|
+
!addSdkImport({ insertions, sourceByFile, filePath: anchor.filePath })
|
|
1099
|
+
) {
|
|
1100
|
+
return { proposal: null, reason: "missing_sdk_import_boundary" };
|
|
1101
|
+
}
|
|
1102
|
+
addInsertion(
|
|
1103
|
+
insertions,
|
|
1104
|
+
anchor.filePath,
|
|
1105
|
+
anchor.insertBefore ? anchor.index : anchor.lineEnd + 1,
|
|
1106
|
+
anchor.insertBefore ? `${indent}${text}\n` : `\n${indent}${text}\n`,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (identityIntegration.status === "instrumentation_required") {
|
|
1111
|
+
const candidate = identityIntegration.candidates[0];
|
|
1112
|
+
if (
|
|
1113
|
+
!candidate?.external_namespace ||
|
|
1114
|
+
!candidate.subject_id_expression ||
|
|
1115
|
+
!candidate.external_id_expression
|
|
1116
|
+
) {
|
|
1117
|
+
return {
|
|
1118
|
+
proposal: null,
|
|
1119
|
+
reason: "external_identity_mapping_requires_external_context",
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
const source = sourceByFile.get(candidate.source_location.file_path);
|
|
1123
|
+
if (source === undefined) {
|
|
1124
|
+
return { proposal: null, reason: "identity_mapping_source_missing" };
|
|
1125
|
+
}
|
|
1126
|
+
if (
|
|
1127
|
+
!addSdkImport({
|
|
1128
|
+
insertions,
|
|
1129
|
+
sourceByFile,
|
|
1130
|
+
filePath: candidate.source_location.file_path,
|
|
1131
|
+
})
|
|
1132
|
+
) {
|
|
1133
|
+
return { proposal: null, reason: "missing_sdk_import_boundary" };
|
|
1134
|
+
}
|
|
1135
|
+
const lineStart = source
|
|
1136
|
+
.split("\n")
|
|
1137
|
+
.slice(0, candidate.source_location.line_start - 1)
|
|
1138
|
+
.reduce((sum, line) => sum + line.length + 1, 0);
|
|
1139
|
+
const lineEnd = lineEndAt(source, lineStart);
|
|
1140
|
+
const indent = source.slice(lineStart, lineEnd).match(/^\s*/)?.[0] ?? "";
|
|
1141
|
+
const python = candidate.source_location.file_path.endsWith(".py");
|
|
1142
|
+
const linkText = python
|
|
1143
|
+
? `cluebase.link({"subject": {"type": "organization", "id": ${candidate.subject_id_expression}}, "external": {"namespace": ${JSON.stringify(candidate.external_namespace)}, "id": ${candidate.external_id_expression}}})`
|
|
1144
|
+
: `cluebase.link({ subject: { type: "organization", id: ${candidate.subject_id_expression} }, external: { namespace: ${JSON.stringify(candidate.external_namespace)}, id: ${candidate.external_id_expression} } });`;
|
|
1145
|
+
addInsertion(
|
|
1146
|
+
insertions,
|
|
1147
|
+
candidate.source_location.file_path,
|
|
1148
|
+
lineEnd + 1,
|
|
1149
|
+
`\n${indent}${linkText}\n`,
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
const requiredRoutes = new Set(operationDiscriminatorRequired);
|
|
1153
|
+
for (const [filePath, source] of sourceByFile) {
|
|
1154
|
+
const rows = evidence.filter(
|
|
1155
|
+
(row) =>
|
|
1156
|
+
requiredRoutes.has(`${row.route.method}:${row.route.path}`) &&
|
|
1157
|
+
row.source_location.file_path === filePath,
|
|
1158
|
+
);
|
|
1159
|
+
if (rows.length === 0) continue;
|
|
1160
|
+
const branchInsertions = operationRepairInsertions({
|
|
1161
|
+
filePath,
|
|
1162
|
+
source,
|
|
1163
|
+
rows,
|
|
1164
|
+
language: filePath.endsWith(".py") ? "python" : "javascript",
|
|
1165
|
+
});
|
|
1166
|
+
if (!branchInsertions)
|
|
1167
|
+
return { proposal: null, reason: "ambiguous_operation_boundary" };
|
|
1168
|
+
for (const insertion of branchInsertions)
|
|
1169
|
+
addInsertion(insertions, filePath, insertion.index, insertion.text);
|
|
1170
|
+
}
|
|
1171
|
+
const requiresSdkDependency =
|
|
1172
|
+
missingLifecycleApis.length > 0 ||
|
|
1173
|
+
identityIntegration.status === "instrumentation_required";
|
|
1174
|
+
if (requiresSdkDependency) {
|
|
1175
|
+
const sdkPackageName =
|
|
1176
|
+
setupDiscovery?.sdkPackageName ?? "@genn-inc/cluebase-backend-sdk";
|
|
1177
|
+
const dependency = sdkDependencyRepairFile({
|
|
1178
|
+
sourceByFile,
|
|
1179
|
+
packageManifest,
|
|
1180
|
+
sdkPackageName,
|
|
1181
|
+
packageManifestPath,
|
|
1182
|
+
backendRootPath,
|
|
1183
|
+
});
|
|
1184
|
+
if (dependency) replacementFiles.set(dependency.path, dependency.content);
|
|
1185
|
+
if (
|
|
1186
|
+
!dependency &&
|
|
1187
|
+
!hasSdkDependency({ sourceByFile, packageManifest, sdkPackageName })
|
|
1188
|
+
) {
|
|
1189
|
+
return { proposal: null, reason: "missing_sdk_dependency_boundary" };
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
if (insertions.size === 0)
|
|
1193
|
+
return { proposal: null, reason: "no_deterministic_repair" };
|
|
1194
|
+
const files = [...insertions.entries()].map(([path, fileInsertions]) => ({
|
|
1195
|
+
path: normalizePath(path),
|
|
1196
|
+
content: replacementFiles.has(path)
|
|
1197
|
+
? replacementFiles.get(path)
|
|
1198
|
+
: applyInsertions(sourceByFile.get(path), fileInsertions),
|
|
1199
|
+
operation: "update",
|
|
1200
|
+
}));
|
|
1201
|
+
for (const [path, content] of replacementFiles) {
|
|
1202
|
+
if (insertions.has(path)) continue;
|
|
1203
|
+
files.push({
|
|
1204
|
+
path: normalizePath(path),
|
|
1205
|
+
content,
|
|
1206
|
+
operation: "update",
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
return {
|
|
1210
|
+
proposal: {
|
|
1211
|
+
allowedPaths: files.map((file) => file.path),
|
|
1212
|
+
files,
|
|
1213
|
+
title: "Repair Cluebase SDK operation instrumentation",
|
|
1214
|
+
body: "Add only deterministic Cluebase lifecycle and operation discriminator observations at existing setup boundaries.",
|
|
1215
|
+
},
|
|
1216
|
+
reason: null,
|
|
1217
|
+
};
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
const operationKeyFor = ({ method, path }) => `route.${method}.${path}`;
|
|
1221
|
+
|
|
1222
|
+
const identityMappingLocation = ({ filePath, source, index, symbol }) => ({
|
|
1223
|
+
file_path: normalizePath(filePath),
|
|
1224
|
+
line_start: lineNumberAt(source, index),
|
|
1225
|
+
line_end: lineNumberAt(source, lineEndAt(source, index)),
|
|
1226
|
+
symbol,
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
const identityExpressionAt = ({ source, index, identifier }) => {
|
|
1230
|
+
const receiver = source
|
|
1231
|
+
.slice(0, index)
|
|
1232
|
+
.match(/\b([A-Za-z_$][\w$]*)\s*\.\s*$/)?.[1];
|
|
1233
|
+
return receiver ? `${receiver}.${identifier}` : identifier;
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
const identityMappingCandidate = ({
|
|
1237
|
+
filePath,
|
|
1238
|
+
source,
|
|
1239
|
+
index,
|
|
1240
|
+
externalFieldReference,
|
|
1241
|
+
externalNamespace,
|
|
1242
|
+
subjectIdExpression,
|
|
1243
|
+
externalIdExpression,
|
|
1244
|
+
}) => ({
|
|
1245
|
+
canonical_sdk_field_key: "organization_id",
|
|
1246
|
+
canonical_subject_type: "subject:organization",
|
|
1247
|
+
external_field_reference: externalFieldReference,
|
|
1248
|
+
...(externalNamespace ? { external_namespace: externalNamespace } : {}),
|
|
1249
|
+
...(subjectIdExpression
|
|
1250
|
+
? { subject_id_expression: subjectIdExpression }
|
|
1251
|
+
: {}),
|
|
1252
|
+
...(externalIdExpression
|
|
1253
|
+
? { external_id_expression: externalIdExpression }
|
|
1254
|
+
: {}),
|
|
1255
|
+
source_location: identityMappingLocation({
|
|
1256
|
+
filePath,
|
|
1257
|
+
source,
|
|
1258
|
+
index,
|
|
1259
|
+
symbol: "external_identity_mapping",
|
|
1260
|
+
}),
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
const identityIntegrationFor = (sourceByFile, externalIdentityContext) => {
|
|
1264
|
+
const contextFields = Array.isArray(externalIdentityContext?.fields)
|
|
1265
|
+
? externalIdentityContext.fields.filter(
|
|
1266
|
+
(field) =>
|
|
1267
|
+
field &&
|
|
1268
|
+
field.reviewResult === "approved" &&
|
|
1269
|
+
((field.identityRole === "canonical_subject" &&
|
|
1270
|
+
field.semanticType === "subject:organization") ||
|
|
1271
|
+
(field.identityRole === "external_key" &&
|
|
1272
|
+
typeof field.namespace === "string" &&
|
|
1273
|
+
field.namespace.trim())),
|
|
1274
|
+
)
|
|
1275
|
+
: [];
|
|
1276
|
+
const approvedCanonicalOrganizationFields = contextFields.filter(
|
|
1277
|
+
(field) =>
|
|
1278
|
+
field.identityRole === "canonical_subject" &&
|
|
1279
|
+
field.semanticType === "subject:organization" &&
|
|
1280
|
+
/organization_id|organizationid|metadata/i.test(
|
|
1281
|
+
String(field.fieldReference ?? ""),
|
|
1282
|
+
),
|
|
1283
|
+
);
|
|
1284
|
+
const namespaces = [
|
|
1285
|
+
...new Set(
|
|
1286
|
+
contextFields
|
|
1287
|
+
.filter(
|
|
1288
|
+
(field) =>
|
|
1289
|
+
field.identityRole === "external_key" &&
|
|
1290
|
+
typeof field.namespace === "string",
|
|
1291
|
+
)
|
|
1292
|
+
.map((field) => field.namespace),
|
|
1293
|
+
),
|
|
1294
|
+
];
|
|
1295
|
+
const externalContextNamespaceCounts = new Map();
|
|
1296
|
+
for (const field of contextFields) {
|
|
1297
|
+
if (
|
|
1298
|
+
field.identityRole !== "external_key" ||
|
|
1299
|
+
typeof field.namespace !== "string"
|
|
1300
|
+
)
|
|
1301
|
+
continue;
|
|
1302
|
+
externalContextNamespaceCounts.set(
|
|
1303
|
+
field.namespace,
|
|
1304
|
+
(externalContextNamespaceCounts.get(field.namespace) ?? 0) + 1,
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
const ambiguousContextNamespace = [
|
|
1308
|
+
...externalContextNamespaceCounts.values(),
|
|
1309
|
+
].some((count) => count > 1);
|
|
1310
|
+
const candidates = [];
|
|
1311
|
+
let existingLink = false;
|
|
1312
|
+
let hasMetadataCandidate = false;
|
|
1313
|
+
for (const [filePath, source] of sourceByFile) {
|
|
1314
|
+
if (EXISTING_EXTERNAL_LINK_PATTERN.test(source)) {
|
|
1315
|
+
existingLink = true;
|
|
1316
|
+
candidates.push(
|
|
1317
|
+
identityMappingCandidate({
|
|
1318
|
+
filePath,
|
|
1319
|
+
source,
|
|
1320
|
+
index: source.search(EXISTING_EXTERNAL_LINK_PATTERN),
|
|
1321
|
+
externalFieldReference: "existing_cluebase_link",
|
|
1322
|
+
}),
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
let metadataMatch;
|
|
1326
|
+
EXTERNAL_METADATA_ORGANIZATION_PATTERN.lastIndex = 0;
|
|
1327
|
+
metadataMatch = EXTERNAL_METADATA_ORGANIZATION_PATTERN.exec(source);
|
|
1328
|
+
if (metadataMatch) {
|
|
1329
|
+
hasMetadataCandidate = true;
|
|
1330
|
+
candidates.push(
|
|
1331
|
+
identityMappingCandidate({
|
|
1332
|
+
filePath,
|
|
1333
|
+
source,
|
|
1334
|
+
index: metadataMatch.index,
|
|
1335
|
+
externalFieldReference: "external.metadata.organization_id",
|
|
1336
|
+
}),
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
if (!ORGANIZATION_ID_PATTERN.test(source)) continue;
|
|
1340
|
+
const subjectIdExpression = source.match(
|
|
1341
|
+
/\b(?:organization|org)\s*(?:\.\s*id|\[\s*["']id["']\s*\])|\b(?:organizationId|organization_id|orgId|org_id)\b/i,
|
|
1342
|
+
)?.[0];
|
|
1343
|
+
EXTERNAL_ID_PATTERN.lastIndex = 0;
|
|
1344
|
+
for (const match of source.matchAll(EXTERNAL_ID_PATTERN)) {
|
|
1345
|
+
const externalFieldReference = match[1];
|
|
1346
|
+
if (
|
|
1347
|
+
NON_EXTERNAL_ID_NAMES.has(externalFieldReference) ||
|
|
1348
|
+
NON_EXTERNAL_ID_PATTERN.test(externalFieldReference)
|
|
1349
|
+
)
|
|
1350
|
+
continue;
|
|
1351
|
+
candidates.push(
|
|
1352
|
+
identityMappingCandidate({
|
|
1353
|
+
filePath,
|
|
1354
|
+
source,
|
|
1355
|
+
index: match.index ?? 0,
|
|
1356
|
+
externalFieldReference,
|
|
1357
|
+
externalNamespace:
|
|
1358
|
+
namespaces.length === 1 ? namespaces[0] : undefined,
|
|
1359
|
+
subjectIdExpression,
|
|
1360
|
+
externalIdExpression: identityExpressionAt({
|
|
1361
|
+
source,
|
|
1362
|
+
index: match.index ?? 0,
|
|
1363
|
+
identifier: externalFieldReference,
|
|
1364
|
+
}),
|
|
1365
|
+
}),
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const candidatesByLocation = new Map();
|
|
1371
|
+
for (const candidate of candidates) {
|
|
1372
|
+
const key = `${candidate.source_location.file_path}:${candidate.external_field_reference}`;
|
|
1373
|
+
if (!candidatesByLocation.has(key))
|
|
1374
|
+
candidatesByLocation.set(key, candidate);
|
|
1375
|
+
}
|
|
1376
|
+
const uniqueCandidates = [...candidatesByLocation.values()];
|
|
1377
|
+
const candidatesByReference = new Map();
|
|
1378
|
+
for (const candidate of uniqueCandidates) {
|
|
1379
|
+
const current =
|
|
1380
|
+
candidatesByReference.get(candidate.external_field_reference) ?? [];
|
|
1381
|
+
current.push(candidate);
|
|
1382
|
+
candidatesByReference.set(candidate.external_field_reference, current);
|
|
1383
|
+
}
|
|
1384
|
+
const duplicateCandidate = [...candidatesByReference.values()].some(
|
|
1385
|
+
(matches) =>
|
|
1386
|
+
new Set(matches.map((candidate) => candidate.source_location.file_path))
|
|
1387
|
+
.size > 1,
|
|
1388
|
+
);
|
|
1389
|
+
if (
|
|
1390
|
+
existingLink ||
|
|
1391
|
+
(hasMetadataCandidate &&
|
|
1392
|
+
(!externalIdentityContext ||
|
|
1393
|
+
approvedCanonicalOrganizationFields.length === 1) &&
|
|
1394
|
+
uniqueCandidates.some(
|
|
1395
|
+
(candidate) =>
|
|
1396
|
+
candidate.external_field_reference ===
|
|
1397
|
+
"external.metadata.organization_id",
|
|
1398
|
+
))
|
|
1399
|
+
) {
|
|
1400
|
+
return {
|
|
1401
|
+
status: "no_code_change",
|
|
1402
|
+
candidates: uniqueCandidates,
|
|
1403
|
+
reason: null,
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
if (
|
|
1407
|
+
duplicateCandidate ||
|
|
1408
|
+
(externalIdentityContext &&
|
|
1409
|
+
(namespaces.length !== 1 || ambiguousContextNamespace))
|
|
1410
|
+
) {
|
|
1411
|
+
return {
|
|
1412
|
+
status: "blocked",
|
|
1413
|
+
candidates: uniqueCandidates,
|
|
1414
|
+
reason: "ambiguous_external_identity_mapping",
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
if (uniqueCandidates.length === 1) {
|
|
1418
|
+
return {
|
|
1419
|
+
status: "instrumentation_required",
|
|
1420
|
+
candidates: uniqueCandidates,
|
|
1421
|
+
reason: null,
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
if (uniqueCandidates.length > 1) {
|
|
1425
|
+
return {
|
|
1426
|
+
status: "blocked",
|
|
1427
|
+
candidates: uniqueCandidates,
|
|
1428
|
+
reason: "ambiguous_external_identity_mapping",
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
return { status: "no_code_change", candidates: [], reason: null };
|
|
1432
|
+
};
|
|
1433
|
+
|
|
1434
|
+
const buildEvidence = ({
|
|
1435
|
+
filePath,
|
|
1436
|
+
lineStart,
|
|
1437
|
+
lineEnd,
|
|
1438
|
+
symbol,
|
|
1439
|
+
method,
|
|
1440
|
+
path,
|
|
1441
|
+
operationDiscriminator,
|
|
1442
|
+
handlerText,
|
|
1443
|
+
sourceRevisionSha,
|
|
1444
|
+
fileSource,
|
|
1445
|
+
sourceByFile,
|
|
1446
|
+
}) => {
|
|
1447
|
+
const dependencyAnalysis = directDependencyAnalysisFor({
|
|
1448
|
+
filePath,
|
|
1449
|
+
fileSource,
|
|
1450
|
+
handlerSource: handlerText,
|
|
1451
|
+
sourceByFile,
|
|
1452
|
+
});
|
|
1453
|
+
const effects = effectsFor({
|
|
1454
|
+
source: `${handlerText}\n${dependencyAnalysis.source}`,
|
|
1455
|
+
path,
|
|
1456
|
+
});
|
|
1457
|
+
const value = {
|
|
1458
|
+
operation_source_key: operationKeyFor({
|
|
1459
|
+
method,
|
|
1460
|
+
path,
|
|
1461
|
+
discriminator: operationDiscriminator,
|
|
1462
|
+
}),
|
|
1463
|
+
operation_discriminator: operationDiscriminator,
|
|
1464
|
+
route: { method, path },
|
|
1465
|
+
source_location: {
|
|
1466
|
+
file_path: normalizePath(filePath),
|
|
1467
|
+
line_start: lineStart,
|
|
1468
|
+
line_end: lineEnd,
|
|
1469
|
+
symbol: operationDiscriminator
|
|
1470
|
+
? `${symbol}#${operationDiscriminator}`
|
|
1471
|
+
: symbol,
|
|
1472
|
+
},
|
|
1473
|
+
effects: {
|
|
1474
|
+
resource: effects.resource,
|
|
1475
|
+
data_operation: effects.data_operation,
|
|
1476
|
+
persistence_write: effects.persistence_write,
|
|
1477
|
+
external_side_effect: effects.external_side_effect,
|
|
1478
|
+
notification_side_effect: effects.notification_side_effect,
|
|
1479
|
+
downstream_dependency: effects.downstream_dependency,
|
|
1480
|
+
},
|
|
1481
|
+
outcome: {
|
|
1482
|
+
success_path: effects.success_path,
|
|
1483
|
+
destructive_reference: effects.destructive_reference,
|
|
1484
|
+
reversible_reference: effects.reversible_reference,
|
|
1485
|
+
},
|
|
1486
|
+
confidence: confidenceFor({
|
|
1487
|
+
deterministicDiscriminator: Boolean(operationDiscriminator),
|
|
1488
|
+
dataOperation: effects.data_operation,
|
|
1489
|
+
dependencyResolved: dependencyAnalysis.resolved,
|
|
1490
|
+
handlerResolved: !hasUnresolvedNamedHandler(fileSource, sourceByFile),
|
|
1491
|
+
}),
|
|
1492
|
+
source_revision_sha: sourceRevisionSha,
|
|
1493
|
+
analyzer_version: CODE_EVIDENCE_ANALYZER_VERSION,
|
|
1494
|
+
};
|
|
1495
|
+
return value;
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
const evidenceForRoute = ({
|
|
1499
|
+
source,
|
|
1500
|
+
filePath,
|
|
1501
|
+
start,
|
|
1502
|
+
end,
|
|
1503
|
+
method,
|
|
1504
|
+
path,
|
|
1505
|
+
symbol,
|
|
1506
|
+
sourceRevisionSha,
|
|
1507
|
+
language,
|
|
1508
|
+
sourceByFile,
|
|
1509
|
+
}) => {
|
|
1510
|
+
const routeSource = source.slice(start, end + 1);
|
|
1511
|
+
const discriminators = branchDiscriminators(routeSource, language).map(
|
|
1512
|
+
(item) => ({
|
|
1513
|
+
...item,
|
|
1514
|
+
index: item.index + start,
|
|
1515
|
+
}),
|
|
1516
|
+
);
|
|
1517
|
+
const candidates = discriminators.length > 0 ? discriminators : [null];
|
|
1518
|
+
return candidates.map((discriminator) => {
|
|
1519
|
+
const branchSource = discriminator
|
|
1520
|
+
? branchTextFor(source, discriminator, language)
|
|
1521
|
+
: routeSource;
|
|
1522
|
+
const lineStart = discriminator
|
|
1523
|
+
? lineNumberAt(source, discriminator.index)
|
|
1524
|
+
: lineNumberAt(source, start);
|
|
1525
|
+
return buildEvidence({
|
|
1526
|
+
filePath,
|
|
1527
|
+
lineStart,
|
|
1528
|
+
lineEnd: lineNumberAt(source, end),
|
|
1529
|
+
symbol,
|
|
1530
|
+
method,
|
|
1531
|
+
path,
|
|
1532
|
+
operationDiscriminator: discriminator?.value ?? null,
|
|
1533
|
+
handlerText: branchSource,
|
|
1534
|
+
sourceRevisionSha,
|
|
1535
|
+
fileSource: source,
|
|
1536
|
+
sourceByFile,
|
|
1537
|
+
});
|
|
1538
|
+
});
|
|
1539
|
+
};
|
|
1540
|
+
|
|
1541
|
+
const analyzeJavaScriptSource = ({
|
|
1542
|
+
source,
|
|
1543
|
+
filePath,
|
|
1544
|
+
sourceRevisionSha,
|
|
1545
|
+
sourceByFile,
|
|
1546
|
+
}) => {
|
|
1547
|
+
const evidence = [];
|
|
1548
|
+
if (matchesPattern(source, EXPRESS_FRAMEWORK_PATTERN)) {
|
|
1549
|
+
let match;
|
|
1550
|
+
while ((match = ROUTE_METHOD_PATTERN.exec(source))) {
|
|
1551
|
+
const open = source.indexOf("(", match.index);
|
|
1552
|
+
const close = findMatchingDelimiter(source, open);
|
|
1553
|
+
if (close === -1) continue;
|
|
1554
|
+
const argumentsList = splitTopLevel(source.slice(open + 1, close));
|
|
1555
|
+
const path = stringLiteralValue(argumentsList[0] ?? "");
|
|
1556
|
+
const method = match[1].toUpperCase();
|
|
1557
|
+
if (!path || !HTTP_METHODS.has(method)) continue;
|
|
1558
|
+
const handlerStart = open + 1 + (argumentsList[0]?.length ?? 0);
|
|
1559
|
+
const end = findHandlerEnd(source, handlerStart, close);
|
|
1560
|
+
const symbol = functionNameNear(source, handlerStart, end);
|
|
1561
|
+
evidence.push(
|
|
1562
|
+
...evidenceForRoute({
|
|
1563
|
+
source,
|
|
1564
|
+
filePath,
|
|
1565
|
+
start: match.index,
|
|
1566
|
+
end,
|
|
1567
|
+
method,
|
|
1568
|
+
path,
|
|
1569
|
+
symbol,
|
|
1570
|
+
sourceRevisionSha,
|
|
1571
|
+
language: "javascript",
|
|
1572
|
+
sourceByFile,
|
|
1573
|
+
}),
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const controllers = [];
|
|
1579
|
+
if (matchesPattern(source, NEST_FRAMEWORK_PATTERN)) {
|
|
1580
|
+
CONTROLLER_DECORATOR_PATTERN.lastIndex = 0;
|
|
1581
|
+
let controllerMatch;
|
|
1582
|
+
while ((controllerMatch = CONTROLLER_DECORATOR_PATTERN.exec(source))) {
|
|
1583
|
+
controllers.push({
|
|
1584
|
+
index: controllerMatch.index,
|
|
1585
|
+
path: controllerMatch[2] ?? "",
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
NEST_DECORATOR_PATTERN.lastIndex = 0;
|
|
1589
|
+
let decoratorMatch;
|
|
1590
|
+
while ((decoratorMatch = NEST_DECORATOR_PATTERN.exec(source))) {
|
|
1591
|
+
const method = decoratorMatch[1].toUpperCase();
|
|
1592
|
+
const controller = [...controllers]
|
|
1593
|
+
.reverse()
|
|
1594
|
+
.find((candidate) => candidate.index < decoratorMatch.index);
|
|
1595
|
+
const path = joinRoutePath(
|
|
1596
|
+
controller?.path ?? "",
|
|
1597
|
+
decoratorMatch[3] ?? "",
|
|
1598
|
+
);
|
|
1599
|
+
const end = findHandlerEnd(
|
|
1600
|
+
source,
|
|
1601
|
+
decoratorMatch.index + decoratorMatch[0].length,
|
|
1602
|
+
source.length,
|
|
1603
|
+
);
|
|
1604
|
+
const symbol = functionNameNear(
|
|
1605
|
+
source,
|
|
1606
|
+
decoratorMatch.index + decoratorMatch[0].length,
|
|
1607
|
+
end,
|
|
1608
|
+
);
|
|
1609
|
+
evidence.push(
|
|
1610
|
+
...evidenceForRoute({
|
|
1611
|
+
source,
|
|
1612
|
+
filePath,
|
|
1613
|
+
start: decoratorMatch.index,
|
|
1614
|
+
end,
|
|
1615
|
+
method,
|
|
1616
|
+
path,
|
|
1617
|
+
symbol,
|
|
1618
|
+
sourceRevisionSha,
|
|
1619
|
+
language: "javascript",
|
|
1620
|
+
sourceByFile,
|
|
1621
|
+
}),
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
return evidence;
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1628
|
+
const pythonMethodsFromDeclaration = (method, suffix) => {
|
|
1629
|
+
if (method !== "API_ROUTE") return [method];
|
|
1630
|
+
const methods = [...suffix.matchAll(/methods\s*=\s*\[([^\]]+)\]/gi)].flatMap(
|
|
1631
|
+
(match) =>
|
|
1632
|
+
[...match[1].matchAll(/["']([A-Za-z]+)["']/g)].map((item) =>
|
|
1633
|
+
item[1].toUpperCase(),
|
|
1634
|
+
),
|
|
1635
|
+
);
|
|
1636
|
+
return methods.length > 0 ? methods : ["ALL"];
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
const pythonFunctionEnd = (source, functionStart) => {
|
|
1640
|
+
if (functionStart === -1) return lineEndAt(source, 0) - 1;
|
|
1641
|
+
const functionLineStart = source.lastIndexOf("\n", functionStart) + 1;
|
|
1642
|
+
const functionIndent =
|
|
1643
|
+
source.slice(functionLineStart, functionStart).match(/^\s*/)?.[0].length ??
|
|
1644
|
+
0;
|
|
1645
|
+
const lines = source.slice(functionLineStart).split("\n");
|
|
1646
|
+
let offset = functionLineStart + lines[0].length;
|
|
1647
|
+
for (const line of lines.slice(1)) {
|
|
1648
|
+
offset += 1;
|
|
1649
|
+
if (
|
|
1650
|
+
line.trim() &&
|
|
1651
|
+
(line.match(/^\s*/)?.[0].length ?? 0) <= functionIndent
|
|
1652
|
+
) {
|
|
1653
|
+
return offset - 1;
|
|
1654
|
+
}
|
|
1655
|
+
offset += line.length;
|
|
1656
|
+
}
|
|
1657
|
+
return source.length - 1;
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
const analyzePythonSource = ({
|
|
1661
|
+
source,
|
|
1662
|
+
filePath,
|
|
1663
|
+
sourceRevisionSha,
|
|
1664
|
+
sourceByFile,
|
|
1665
|
+
}) => {
|
|
1666
|
+
const evidence = [];
|
|
1667
|
+
if (!matchesPattern(source, FASTAPI_FRAMEWORK_PATTERN)) return evidence;
|
|
1668
|
+
const matches = [...source.matchAll(PYTHON_ROUTE_PATTERN)];
|
|
1669
|
+
for (const match of matches) {
|
|
1670
|
+
const routeMethod = match[1].toUpperCase();
|
|
1671
|
+
const path = match[3];
|
|
1672
|
+
const methods = pythonMethodsFromDeclaration(routeMethod, match[4] ?? "");
|
|
1673
|
+
const functionStart = source.indexOf("def ", match.index + match[0].length);
|
|
1674
|
+
const functionMatch =
|
|
1675
|
+
functionStart === -1
|
|
1676
|
+
? null
|
|
1677
|
+
: source.slice(functionStart).match(/^def\s+([A-Za-z_]\w*)/);
|
|
1678
|
+
const end = pythonFunctionEnd(source, functionStart);
|
|
1679
|
+
for (const method of methods) {
|
|
1680
|
+
evidence.push(
|
|
1681
|
+
...evidenceForRoute({
|
|
1682
|
+
source,
|
|
1683
|
+
filePath,
|
|
1684
|
+
start: match.index,
|
|
1685
|
+
end,
|
|
1686
|
+
method,
|
|
1687
|
+
path,
|
|
1688
|
+
symbol: functionMatch?.[1] ?? "anonymous_route",
|
|
1689
|
+
sourceRevisionSha,
|
|
1690
|
+
language: "python",
|
|
1691
|
+
sourceByFile,
|
|
1692
|
+
}),
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
return evidence;
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
const hasUnresolvedNamedHandler = (source, sourceByFile = new Map()) => {
|
|
1700
|
+
if (!matchesPattern(source, EXPRESS_FRAMEWORK_PATTERN)) return false;
|
|
1701
|
+
const routePattern = new RegExp(
|
|
1702
|
+
ROUTE_METHOD_PATTERN.source,
|
|
1703
|
+
ROUTE_METHOD_PATTERN.flags,
|
|
1704
|
+
);
|
|
1705
|
+
let match;
|
|
1706
|
+
while ((match = routePattern.exec(source))) {
|
|
1707
|
+
const open = source.indexOf("(", match.index);
|
|
1708
|
+
const close = findMatchingDelimiter(source, open);
|
|
1709
|
+
if (close === -1) continue;
|
|
1710
|
+
const argumentsList = splitTopLevel(source.slice(open + 1, close));
|
|
1711
|
+
const handler = argumentsList.at(-1)?.trim() ?? "";
|
|
1712
|
+
if (
|
|
1713
|
+
/^[A-Za-z_$][\w$]*$/.test(handler) &&
|
|
1714
|
+
![...sourceByFile.values()].some((candidate) =>
|
|
1715
|
+
namedFunctionSource(candidate, handler),
|
|
1716
|
+
)
|
|
1717
|
+
)
|
|
1718
|
+
return true;
|
|
1719
|
+
}
|
|
1720
|
+
return false;
|
|
1721
|
+
};
|
|
1722
|
+
|
|
1723
|
+
const lifecycleLocationsFor = ({ source, filePath }) => {
|
|
1724
|
+
const locations = [];
|
|
1725
|
+
LIFECYCLE_PATTERN.lastIndex = 0;
|
|
1726
|
+
let match;
|
|
1727
|
+
while ((match = LIFECYCLE_PATTERN.exec(source))) {
|
|
1728
|
+
locations.push({
|
|
1729
|
+
api: match[1],
|
|
1730
|
+
file_path: normalizePath(filePath),
|
|
1731
|
+
line: lineNumberAt(source, match.index),
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
return locations;
|
|
1735
|
+
};
|
|
1736
|
+
|
|
1737
|
+
const runtimeDiscriminatorMissingFor = ({ row, source, language }) => {
|
|
1738
|
+
const branches = branchDiscriminators(source, language);
|
|
1739
|
+
const branch = branches.find(
|
|
1740
|
+
(candidate) =>
|
|
1741
|
+
candidate.value === row.operation_discriminator &&
|
|
1742
|
+
lineNumberAt(source, candidate.index) === row.source_location.line_start,
|
|
1743
|
+
);
|
|
1744
|
+
return (
|
|
1745
|
+
!branch ||
|
|
1746
|
+
!hasRuntimeDiscriminatorCall(
|
|
1747
|
+
branchTextFor(source, branch, language),
|
|
1748
|
+
language,
|
|
1749
|
+
)
|
|
1750
|
+
);
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
const sortEvidence = (left, right) =>
|
|
1754
|
+
left.operation_source_key.localeCompare(right.operation_source_key) ||
|
|
1755
|
+
left.source_location.file_path.localeCompare(
|
|
1756
|
+
right.source_location.file_path,
|
|
1757
|
+
) ||
|
|
1758
|
+
left.source_location.line_start - right.source_location.line_start;
|
|
1759
|
+
|
|
1760
|
+
const validateSourceRevision = (sourceRevisionSha) => {
|
|
1761
|
+
if (!/^[0-9a-f]{40}$/i.test(sourceRevisionSha ?? "")) {
|
|
1762
|
+
throw new Error("sourceRevisionSha must be a 40-character commit SHA");
|
|
1763
|
+
}
|
|
1764
|
+
return sourceRevisionSha.toLowerCase();
|
|
1765
|
+
};
|
|
1766
|
+
|
|
1767
|
+
export const analyzeCodeEvidence = async ({
|
|
1768
|
+
repoRoot = ".",
|
|
1769
|
+
allowedSourcePaths,
|
|
1770
|
+
excludedSourcePaths,
|
|
1771
|
+
sourceRevisionSha,
|
|
1772
|
+
} = {}) => {
|
|
1773
|
+
const revision = validateSourceRevision(sourceRevisionSha);
|
|
1774
|
+
const resolvedRoot = resolve(repoRoot);
|
|
1775
|
+
const files = await listAllowedSourceFiles({
|
|
1776
|
+
repoRoot: resolvedRoot,
|
|
1777
|
+
allowedSourcePaths,
|
|
1778
|
+
excludedSourcePaths,
|
|
1779
|
+
extensions: SOURCE_EXTENSIONS,
|
|
1780
|
+
});
|
|
1781
|
+
const evidence = [];
|
|
1782
|
+
const sourceByFile = new Map();
|
|
1783
|
+
for (const absolutePath of files) {
|
|
1784
|
+
const filePath = normalizePath(relative(resolvedRoot, absolutePath));
|
|
1785
|
+
sourceByFile.set(filePath, await readFile(absolutePath, "utf8"));
|
|
1786
|
+
}
|
|
1787
|
+
for (const absolutePath of files) {
|
|
1788
|
+
const filePath = normalizePath(relative(resolvedRoot, absolutePath));
|
|
1789
|
+
const source = sourceByFile.get(filePath);
|
|
1790
|
+
if (source === undefined) continue;
|
|
1791
|
+
const extension = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
|
|
1792
|
+
if (extension === ".py") {
|
|
1793
|
+
evidence.push(
|
|
1794
|
+
...analyzePythonSource({
|
|
1795
|
+
source,
|
|
1796
|
+
filePath,
|
|
1797
|
+
sourceRevisionSha: revision,
|
|
1798
|
+
sourceByFile,
|
|
1799
|
+
}),
|
|
1800
|
+
);
|
|
1801
|
+
} else {
|
|
1802
|
+
evidence.push(
|
|
1803
|
+
...analyzeJavaScriptSource({
|
|
1804
|
+
source,
|
|
1805
|
+
filePath,
|
|
1806
|
+
sourceRevisionSha: revision,
|
|
1807
|
+
sourceByFile,
|
|
1808
|
+
}),
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
return evidence.sort(sortEvidence);
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
export const analyzeCodeEvidenceReport = async (options = {}) => {
|
|
1816
|
+
const resolvedRoot = resolve(options.repoRoot ?? ".");
|
|
1817
|
+
const revision = validateSourceRevision(options.sourceRevisionSha);
|
|
1818
|
+
let setupDiscovery = options.setupDiscovery;
|
|
1819
|
+
if (!setupDiscovery && options.setupDiscoveryPath) {
|
|
1820
|
+
try {
|
|
1821
|
+
setupDiscovery = JSON.parse(
|
|
1822
|
+
await readFile(options.setupDiscoveryPath, "utf8"),
|
|
1823
|
+
);
|
|
1824
|
+
} catch {
|
|
1825
|
+
setupDiscovery = undefined;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
let externalIdentityContext = options.externalIdentityContext;
|
|
1829
|
+
if (!externalIdentityContext && options.externalIdentityContextPath) {
|
|
1830
|
+
try {
|
|
1831
|
+
externalIdentityContext = JSON.parse(
|
|
1832
|
+
await readFile(options.externalIdentityContextPath, "utf8"),
|
|
1833
|
+
);
|
|
1834
|
+
} catch {
|
|
1835
|
+
externalIdentityContext = undefined;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
const files = await listAllowedSourceFiles({
|
|
1839
|
+
repoRoot: resolvedRoot,
|
|
1840
|
+
allowedSourcePaths: options.allowedSourcePaths,
|
|
1841
|
+
excludedSourcePaths: options.excludedSourcePaths,
|
|
1842
|
+
extensions: SOURCE_EXTENSIONS,
|
|
1843
|
+
});
|
|
1844
|
+
const lifecycleLocations = [];
|
|
1845
|
+
const insufficientEvidence = [];
|
|
1846
|
+
const sourceByFile = new Map();
|
|
1847
|
+
for (const absolutePath of files) {
|
|
1848
|
+
const filePath = normalizePath(relative(resolvedRoot, absolutePath));
|
|
1849
|
+
const source = await readFile(absolutePath, "utf8");
|
|
1850
|
+
sourceByFile.set(filePath, source);
|
|
1851
|
+
lifecycleLocations.push(...lifecycleLocationsFor({ source, filePath }));
|
|
1852
|
+
const extension = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
|
|
1853
|
+
if (
|
|
1854
|
+
extension === ".py" &&
|
|
1855
|
+
matchesPattern(source, PYTHON_ROUTE_PATTERN) &&
|
|
1856
|
+
!matchesPattern(source, FASTAPI_FRAMEWORK_PATTERN)
|
|
1857
|
+
) {
|
|
1858
|
+
insufficientEvidence.push({
|
|
1859
|
+
file_path: filePath,
|
|
1860
|
+
reason: "unsupported_framework",
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
if (
|
|
1864
|
+
extension !== ".py" &&
|
|
1865
|
+
matchesPattern(source, SERVER_ROUTE_DECLARATION_PATTERN) &&
|
|
1866
|
+
!matchesPattern(source, EXPRESS_FRAMEWORK_PATTERN) &&
|
|
1867
|
+
!matchesPattern(source, NEST_FRAMEWORK_PATTERN)
|
|
1868
|
+
) {
|
|
1869
|
+
insufficientEvidence.push({
|
|
1870
|
+
file_path: filePath,
|
|
1871
|
+
reason: "unsupported_framework",
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
let packageManifest;
|
|
1876
|
+
let packageManifestPath;
|
|
1877
|
+
const dependencyRoots = new Set(["."]);
|
|
1878
|
+
if (typeof setupDiscovery?.backendRootPath === "string") {
|
|
1879
|
+
const rootParts = setupDiscovery.backendRootPath
|
|
1880
|
+
.split("/")
|
|
1881
|
+
.filter(Boolean);
|
|
1882
|
+
for (let index = 1; index <= rootParts.length; index += 1)
|
|
1883
|
+
dependencyRoots.add(rootParts.slice(0, index).join("/"));
|
|
1884
|
+
}
|
|
1885
|
+
for (const dependencyRoot of dependencyRoots) {
|
|
1886
|
+
for (const dependencyFile of DEPENDENCY_FILE_CANDIDATES) {
|
|
1887
|
+
const relativeDependencyFile = normalizePath(
|
|
1888
|
+
join(dependencyRoot, dependencyFile),
|
|
1889
|
+
);
|
|
1890
|
+
try {
|
|
1891
|
+
const source = await readFile(
|
|
1892
|
+
join(resolvedRoot, relativeDependencyFile),
|
|
1893
|
+
"utf8",
|
|
1894
|
+
);
|
|
1895
|
+
sourceByFile.set(
|
|
1896
|
+
relativeDependencyFile,
|
|
1897
|
+
dependencyFile === "package.json" ? "" : source,
|
|
1898
|
+
);
|
|
1899
|
+
if (
|
|
1900
|
+
dependencyFile === "package.json" &&
|
|
1901
|
+
packageManifest === undefined &&
|
|
1902
|
+
(dependencyRoot === setupDiscovery?.backendRootPath ||
|
|
1903
|
+
setupDiscovery?.backendRootPath === undefined)
|
|
1904
|
+
) {
|
|
1905
|
+
packageManifest = JSON.parse(source);
|
|
1906
|
+
packageManifestPath = relativeDependencyFile;
|
|
1907
|
+
}
|
|
1908
|
+
} catch {}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
const evidence = await analyzeCodeEvidence(options);
|
|
1912
|
+
for (const [filePath, source] of sourceByFile) {
|
|
1913
|
+
const fileEvidence = evidence.filter(
|
|
1914
|
+
(row) => row.source_location.file_path === filePath,
|
|
1915
|
+
);
|
|
1916
|
+
const extension = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
|
|
1917
|
+
const recognizedFramework =
|
|
1918
|
+
(extension === ".py" &&
|
|
1919
|
+
matchesPattern(source, FASTAPI_FRAMEWORK_PATTERN)) ||
|
|
1920
|
+
(extension !== ".py" &&
|
|
1921
|
+
(matchesPattern(source, EXPRESS_FRAMEWORK_PATTERN) ||
|
|
1922
|
+
matchesPattern(source, NEST_FRAMEWORK_PATTERN)));
|
|
1923
|
+
if (!recognizedFramework) continue;
|
|
1924
|
+
if (
|
|
1925
|
+
(fileEvidence.length > 0 &&
|
|
1926
|
+
!fileEvidence.some((row) => row.operation_discriminator) &&
|
|
1927
|
+
(extension === ".py"
|
|
1928
|
+
? /^\s*match\s+.+:\s*$/m.test(source)
|
|
1929
|
+
: /\bswitch\s*\(/.test(source))) ||
|
|
1930
|
+
(extension !== ".py" && hasUnresolvedNamedHandler(source, sourceByFile))
|
|
1931
|
+
) {
|
|
1932
|
+
insufficientEvidence.push({
|
|
1933
|
+
file_path: filePath,
|
|
1934
|
+
reason: "unresolved_operation_boundary",
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
const lifecycleApis = new Set(
|
|
1939
|
+
lifecycleLocations.map((location) => location.api),
|
|
1940
|
+
);
|
|
1941
|
+
if (
|
|
1942
|
+
setupDiscovery?.framework &&
|
|
1943
|
+
[...sourceByFile.values()].some((source) =>
|
|
1944
|
+
sourceMatchesBackendInit(
|
|
1945
|
+
setupDiscovery.framework,
|
|
1946
|
+
stripSourceNoise(source, { stripStrings: true }),
|
|
1947
|
+
),
|
|
1948
|
+
)
|
|
1949
|
+
) {
|
|
1950
|
+
lifecycleApis.add("cluebase.init");
|
|
1951
|
+
}
|
|
1952
|
+
const requiredLifecycleApis = Array.isArray(
|
|
1953
|
+
setupDiscovery?.applicableLifecycleApis,
|
|
1954
|
+
)
|
|
1955
|
+
? setupDiscovery.applicableLifecycleApis
|
|
1956
|
+
: REQUIRED_LIFECYCLE_APIS;
|
|
1957
|
+
const missingLifecycleApis = requiredLifecycleApis.filter(
|
|
1958
|
+
(api) => !lifecycleApis.has(api),
|
|
1959
|
+
);
|
|
1960
|
+
const routeKeys = new Map();
|
|
1961
|
+
for (const row of evidence) {
|
|
1962
|
+
const routeKey = `${row.source_location.file_path}:${row.route.method}:${row.route.path}`;
|
|
1963
|
+
const current = routeKeys.get(routeKey) ?? [];
|
|
1964
|
+
current.push(row);
|
|
1965
|
+
routeKeys.set(routeKey, current);
|
|
1966
|
+
}
|
|
1967
|
+
const operationDiscriminatorRequired = [...routeKeys.values()]
|
|
1968
|
+
.filter(
|
|
1969
|
+
(rows) =>
|
|
1970
|
+
rows.length > 1 &&
|
|
1971
|
+
(rows.some((row) => !row.operation_discriminator) ||
|
|
1972
|
+
rows.some((row) => {
|
|
1973
|
+
const source = sourceByFile.get(row.source_location.file_path);
|
|
1974
|
+
return source !== undefined && row.operation_discriminator
|
|
1975
|
+
? runtimeDiscriminatorMissingFor({
|
|
1976
|
+
row,
|
|
1977
|
+
source,
|
|
1978
|
+
language: row.source_location.file_path.endsWith(".py")
|
|
1979
|
+
? "python"
|
|
1980
|
+
: "javascript",
|
|
1981
|
+
})
|
|
1982
|
+
: false;
|
|
1983
|
+
})),
|
|
1984
|
+
)
|
|
1985
|
+
.map((rows) => `${rows[0].route.method}:${rows[0].route.path}`);
|
|
1986
|
+
const identityIntegration = identityIntegrationFor(
|
|
1987
|
+
sourceByFile,
|
|
1988
|
+
externalIdentityContext,
|
|
1989
|
+
);
|
|
1990
|
+
const repair = repairProposalFor({
|
|
1991
|
+
sourceByFile,
|
|
1992
|
+
evidence,
|
|
1993
|
+
missingLifecycleApis,
|
|
1994
|
+
operationDiscriminatorRequired,
|
|
1995
|
+
identityIntegration,
|
|
1996
|
+
setupDiscovery,
|
|
1997
|
+
packageManifest,
|
|
1998
|
+
packageManifestPath,
|
|
1999
|
+
backendRootPath: setupDiscovery?.backendRootPath,
|
|
2000
|
+
});
|
|
2001
|
+
const requiresRepair =
|
|
2002
|
+
operationDiscriminatorRequired.length > 0 ||
|
|
2003
|
+
missingLifecycleApis.length > 0 ||
|
|
2004
|
+
identityIntegration.status === "instrumentation_required";
|
|
2005
|
+
const blocked =
|
|
2006
|
+
insufficientEvidence.length > 0 ||
|
|
2007
|
+
identityIntegration.status === "blocked" ||
|
|
2008
|
+
(requiresRepair && !repair.proposal);
|
|
2009
|
+
const setupStatus = blocked
|
|
2010
|
+
? "blocked"
|
|
2011
|
+
: requiresRepair
|
|
2012
|
+
? "repairable"
|
|
2013
|
+
: "healthy";
|
|
2014
|
+
return {
|
|
2015
|
+
analyzer_version: CODE_EVIDENCE_ANALYZER_VERSION,
|
|
2016
|
+
source_revision_sha: revision,
|
|
2017
|
+
code_evidence: evidence,
|
|
2018
|
+
setup_plan: {
|
|
2019
|
+
status: setupStatus,
|
|
2020
|
+
minimal_changes: [
|
|
2021
|
+
...missingLifecycleApis.map((api) => ({
|
|
2022
|
+
kind: "lifecycle_wiring",
|
|
2023
|
+
api,
|
|
2024
|
+
})),
|
|
2025
|
+
...(identityIntegration.status === "instrumentation_required"
|
|
2026
|
+
? [{ kind: "external_identity_link", api: "cluebase.link" }]
|
|
2027
|
+
: []),
|
|
2028
|
+
],
|
|
2029
|
+
lifecycle_locations: lifecycleLocations,
|
|
2030
|
+
missing_lifecycle_apis: missingLifecycleApis,
|
|
2031
|
+
operation_discriminator_required: operationDiscriminatorRequired,
|
|
2032
|
+
insufficient_evidence: insufficientEvidence,
|
|
2033
|
+
identity_integration: identityIntegration,
|
|
2034
|
+
repair_proposal: repair.proposal,
|
|
2035
|
+
repair_block_reason: blocked ? repair.reason : null,
|
|
2036
|
+
...(setupDiscovery?.language
|
|
2037
|
+
? { verification_language: setupDiscovery.language }
|
|
2038
|
+
: {}),
|
|
2039
|
+
},
|
|
2040
|
+
};
|
|
2041
|
+
};
|