septum 0.1.2 → 0.1.4
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 +7 -2
- package/package.json +1 -1
- package/src/cli/commands/slice.ts +4 -46
- package/src/cli/commands/topology.ts +119 -0
- package/src/cli/index.ts +11 -0
- package/src/cli/templates/skill.ts +7 -4
- package/src/core/config/loader.ts +21 -8
- package/src/core/database/repositories/dependency.repository.ts +70 -0
- package/src/core/database/repositories/environment.repository.ts +207 -0
- package/src/core/database/repositories/file.repository.ts +7 -0
- package/src/core/database/repositories/symbol.repository.ts +28 -9
- package/src/core/database/repository.ts +19 -1
- package/src/core/database/schema.ts +153 -120
- package/src/core/discovery/topology-detector.ts +71 -3
- package/src/core/ingestion/pipeline.ts +98 -1
- package/src/core/parser/behavioral-analyzer.ts +119 -0
- package/src/core/parser/boundary-tracker.ts +160 -0
- package/src/core/parser/extractors/base.ts +2 -0
- package/src/core/parser/extractors/blade.ts +158 -0
- package/src/core/parser/extractors/go.ts +5 -1
- package/src/core/parser/extractors/laravel-semantic.ts +506 -31
- package/src/core/parser/extractors/php.ts +5 -1
- package/src/core/parser/extractors/python.ts +3 -1
- package/src/core/parser/extractors/registry.ts +21 -0
- package/src/core/parser/extractors/typescript.ts +5 -1
- package/src/core/parser/schema/providers/laravel-migration-provider.ts +215 -0
- package/src/core/parser/schema/providers/prisma-schema-provider.ts +89 -0
- package/src/core/parser/schema/schema-provider-registry.ts +43 -0
- package/src/core/parser/schema/schema-provider.interface.ts +33 -0
- package/src/core/parser/tree-sitter.ts +2 -0
- package/src/core/resolver/path-resolver.ts +147 -0
- package/src/core/resolver/vertical-slice-tracer.ts +56 -8
- package/src/core/topology/environment-detector.ts +860 -0
- package/src/core/topology/types.ts +83 -0
- package/src/index.ts +3 -0
- package/src/mcp/schemas.ts +264 -46
- package/src/mcp/server.ts +130 -26
- package/src/mcp/tools/check-boundary.ts +58 -8
- package/src/mcp/tools/get-domain-catalog.ts +31 -1
- package/src/mcp/tools/get-environment-topology.ts +141 -0
- package/src/mcp/tools/get-symbol-hotspots.ts +13 -3
- package/src/mcp/tools/locate-symbol.ts +119 -3
- package/src/mcp/tools/register-domain.ts +1 -1
- package/src/mcp/tools/trace-vertical-slice.ts +11 -65
- package/src/types/index.ts +20 -1
- package/src/version.ts +3 -3
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { SeptumRepository } from "../database/repository.ts";
|
|
4
|
+
import type {
|
|
5
|
+
CanonicalPlatformTag,
|
|
6
|
+
CrossLayerInterplay,
|
|
7
|
+
EnvironmentLayer,
|
|
8
|
+
EnvironmentLayerInfo,
|
|
9
|
+
EnvironmentTopology,
|
|
10
|
+
TopologyNode,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
|
|
13
|
+
export class EnvironmentDetector {
|
|
14
|
+
public static detect(
|
|
15
|
+
workspaceRoot: string,
|
|
16
|
+
repo?: SeptumRepository,
|
|
17
|
+
forceRefresh: boolean = false
|
|
18
|
+
): EnvironmentTopology {
|
|
19
|
+
// 1. Fetch any previously verified layers and nodes from SQLite SSOT
|
|
20
|
+
const savedLayers: Partial<Record<EnvironmentLayer, EnvironmentLayerInfo>> =
|
|
21
|
+
repo && !forceRefresh ? repo.environment.getAllLayers() : {};
|
|
22
|
+
const savedNodes = repo && !forceRefresh ? repo.environment.getAllNodes() : [];
|
|
23
|
+
const savedNodeMap = new Map(savedNodes.map((n) => [n.id, n]));
|
|
24
|
+
|
|
25
|
+
// 2. Perform root file-based heuristic inspection for each of the 6 layers
|
|
26
|
+
const detectedEdge = this.inspectEdgeLayer(workspaceRoot);
|
|
27
|
+
const detectedGateway = this.inspectGatewayLayer(workspaceRoot);
|
|
28
|
+
const detectedHost = this.inspectHostLayer(workspaceRoot, detectedGateway);
|
|
29
|
+
const detectedRuntime = this.inspectRuntimeLayer(workspaceRoot);
|
|
30
|
+
const detectedStorage = this.inspectStorageLayer(workspaceRoot);
|
|
31
|
+
const detectedTelemetry = this.inspectTelemetryLayer(workspaceRoot);
|
|
32
|
+
|
|
33
|
+
// 3. Merge layers: Database verified layers take precedence
|
|
34
|
+
const layers: Record<EnvironmentLayer, EnvironmentLayerInfo> = {
|
|
35
|
+
edge: savedLayers.edge?.status === "VERIFIED" ? savedLayers.edge : detectedEdge,
|
|
36
|
+
gateway: savedLayers.gateway?.status === "VERIFIED" ? savedLayers.gateway : detectedGateway,
|
|
37
|
+
host: savedLayers.host?.status === "VERIFIED" ? savedLayers.host : detectedHost,
|
|
38
|
+
runtime: savedLayers.runtime?.status === "VERIFIED" ? savedLayers.runtime : detectedRuntime,
|
|
39
|
+
storage: savedLayers.storage?.status === "VERIFIED" ? savedLayers.storage : detectedStorage,
|
|
40
|
+
telemetry: savedLayers.telemetry?.status === "VERIFIED" ? savedLayers.telemetry : detectedTelemetry,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// 4. Save layers to SQLite if not already stored
|
|
44
|
+
if (repo) {
|
|
45
|
+
for (const layerKey of Object.keys(layers) as EnvironmentLayer[]) {
|
|
46
|
+
if (!savedLayers[layerKey] || forceRefresh) {
|
|
47
|
+
repo.environment.saveLayer(layers[layerKey]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 5. Discover Multi-Node Entities (Monorepo / Subservices / Default Nodes)
|
|
53
|
+
const detectedNodes: TopologyNode[] = [];
|
|
54
|
+
const subservices = this.probeSubservices(workspaceRoot);
|
|
55
|
+
|
|
56
|
+
if (subservices.length > 0) {
|
|
57
|
+
// Monorepo / Multi-Service layout
|
|
58
|
+
for (const sub of subservices) {
|
|
59
|
+
const subRuntime = this.inspectRuntimeLayer(sub.path);
|
|
60
|
+
const subGateway = this.inspectGatewayLayer(sub.path);
|
|
61
|
+
const nodeId = `runtime:${sub.name}`;
|
|
62
|
+
detectedNodes.push({
|
|
63
|
+
id: nodeId,
|
|
64
|
+
layer: "runtime",
|
|
65
|
+
name: `${sub.name.toUpperCase()} Engine`,
|
|
66
|
+
platform: subRuntime.platform || "Service Runtime",
|
|
67
|
+
canonical_tag: subRuntime.canonical_tag || "unspecified",
|
|
68
|
+
role: sub.type === "frontend" ? "frontend_spa" : "web_api",
|
|
69
|
+
status: subRuntime.status,
|
|
70
|
+
detected_from: [path.relative(workspaceRoot, sub.path)],
|
|
71
|
+
constraints: subRuntime.constraints || [],
|
|
72
|
+
connected_to: sub.type === "backend" ? ["storage:primary"] : [`runtime:backend`],
|
|
73
|
+
unresolved_inquiry: subRuntime.unresolved_inquiry,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (subGateway.status !== "AMBIGUOUS" && subGateway.platform !== "Standard Ingress / Unknown Web Server") {
|
|
77
|
+
detectedNodes.push({
|
|
78
|
+
id: `gateway:${sub.name}`,
|
|
79
|
+
layer: "gateway",
|
|
80
|
+
name: `${sub.name.toUpperCase()} Ingress`,
|
|
81
|
+
platform: subGateway.platform || "Web Server",
|
|
82
|
+
canonical_tag: subGateway.canonical_tag || "unspecified",
|
|
83
|
+
status: subGateway.status,
|
|
84
|
+
detected_from: subGateway.detected_from || [],
|
|
85
|
+
constraints: subGateway.constraints || [],
|
|
86
|
+
connected_to: [nodeId],
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Include Edge, Host, Storage, Telemetry default nodes
|
|
91
|
+
detectedNodes.push(
|
|
92
|
+
{
|
|
93
|
+
id: "edge:primary",
|
|
94
|
+
layer: "edge",
|
|
95
|
+
name: "Perimeter Edge WAF",
|
|
96
|
+
platform: layers.edge.platform || "Edge",
|
|
97
|
+
canonical_tag: layers.edge.canonical_tag || "unspecified",
|
|
98
|
+
status: layers.edge.status,
|
|
99
|
+
detected_from: layers.edge.detected_from || [],
|
|
100
|
+
constraints: layers.edge.constraints || [],
|
|
101
|
+
unresolved_inquiry: layers.edge.unresolved_inquiry,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "host:primary",
|
|
105
|
+
layer: "host",
|
|
106
|
+
name: "Compute Host",
|
|
107
|
+
platform: layers.host.platform || "Host",
|
|
108
|
+
canonical_tag: layers.host.canonical_tag || "unspecified",
|
|
109
|
+
status: layers.host.status,
|
|
110
|
+
detected_from: layers.host.detected_from || [],
|
|
111
|
+
constraints: layers.host.constraints || [],
|
|
112
|
+
unresolved_inquiry: layers.host.unresolved_inquiry,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "storage:primary",
|
|
116
|
+
layer: "storage",
|
|
117
|
+
name: "Primary Storage",
|
|
118
|
+
platform: layers.storage.platform || "Database",
|
|
119
|
+
canonical_tag: layers.storage.canonical_tag || "unspecified",
|
|
120
|
+
status: layers.storage.status,
|
|
121
|
+
detected_from: layers.storage.detected_from || [],
|
|
122
|
+
constraints: layers.storage.constraints || [],
|
|
123
|
+
unresolved_inquiry: layers.storage.unresolved_inquiry,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: "telemetry:primary",
|
|
127
|
+
layer: "telemetry",
|
|
128
|
+
name: "Telemetry Pipeline",
|
|
129
|
+
platform: layers.telemetry.platform || "Logger",
|
|
130
|
+
canonical_tag: "unspecified",
|
|
131
|
+
status: layers.telemetry.status,
|
|
132
|
+
detected_from: layers.telemetry.detected_from || [],
|
|
133
|
+
constraints: layers.telemetry.constraints || [],
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
// Standard Single-Service Topology Nodes
|
|
138
|
+
detectedNodes.push(
|
|
139
|
+
{
|
|
140
|
+
id: "edge:primary",
|
|
141
|
+
layer: "edge",
|
|
142
|
+
name: "Perimeter Edge WAF",
|
|
143
|
+
platform: layers.edge.platform || "Edge",
|
|
144
|
+
canonical_tag: layers.edge.canonical_tag || "unspecified",
|
|
145
|
+
status: layers.edge.status,
|
|
146
|
+
detected_from: layers.edge.detected_from || [],
|
|
147
|
+
constraints: layers.edge.constraints || [],
|
|
148
|
+
unresolved_inquiry: layers.edge.unresolved_inquiry,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: "gateway:primary",
|
|
152
|
+
layer: "gateway",
|
|
153
|
+
name: "Web Ingress",
|
|
154
|
+
platform: layers.gateway.platform || "Gateway",
|
|
155
|
+
canonical_tag: layers.gateway.canonical_tag || "unspecified",
|
|
156
|
+
status: layers.gateway.status,
|
|
157
|
+
detected_from: layers.gateway.detected_from || [],
|
|
158
|
+
constraints: layers.gateway.constraints || [],
|
|
159
|
+
connected_to: ["runtime:primary"],
|
|
160
|
+
unresolved_inquiry: layers.gateway.unresolved_inquiry,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: "host:primary",
|
|
164
|
+
layer: "host",
|
|
165
|
+
name: "Primary Host",
|
|
166
|
+
platform: layers.host.platform || "Host",
|
|
167
|
+
canonical_tag: layers.host.canonical_tag || "unspecified",
|
|
168
|
+
status: layers.host.status,
|
|
169
|
+
detected_from: layers.host.detected_from || [],
|
|
170
|
+
constraints: layers.host.constraints || [],
|
|
171
|
+
unresolved_inquiry: layers.host.unresolved_inquiry,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: "runtime:primary",
|
|
175
|
+
layer: "runtime",
|
|
176
|
+
name: "Application Runtime",
|
|
177
|
+
platform: layers.runtime.platform || "Runtime",
|
|
178
|
+
canonical_tag: layers.runtime.canonical_tag || "unspecified",
|
|
179
|
+
status: layers.runtime.status,
|
|
180
|
+
detected_from: layers.runtime.detected_from || [],
|
|
181
|
+
constraints: layers.runtime.constraints || [],
|
|
182
|
+
connected_to: ["storage:primary"],
|
|
183
|
+
unresolved_inquiry: layers.runtime.unresolved_inquiry,
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "storage:primary",
|
|
187
|
+
layer: "storage",
|
|
188
|
+
name: "Database & Cache",
|
|
189
|
+
platform: layers.storage.platform || "Storage",
|
|
190
|
+
canonical_tag: layers.storage.canonical_tag || "unspecified",
|
|
191
|
+
status: layers.storage.status,
|
|
192
|
+
detected_from: layers.storage.detected_from || [],
|
|
193
|
+
constraints: layers.storage.constraints || [],
|
|
194
|
+
unresolved_inquiry: layers.storage.unresolved_inquiry,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: "telemetry:primary",
|
|
198
|
+
layer: "telemetry",
|
|
199
|
+
name: "Observability Pipeline",
|
|
200
|
+
platform: layers.telemetry.platform || "Logger",
|
|
201
|
+
canonical_tag: "unspecified",
|
|
202
|
+
status: layers.telemetry.status,
|
|
203
|
+
detected_from: layers.telemetry.detected_from || [],
|
|
204
|
+
constraints: layers.telemetry.constraints || [],
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 6. Merge with SQLite SSOT Nodes (Verified or manually added nodes take precedence)
|
|
210
|
+
const finalNodes: TopologyNode[] = [];
|
|
211
|
+
const processedIds = new Set<string>();
|
|
212
|
+
|
|
213
|
+
for (const dNode of detectedNodes) {
|
|
214
|
+
const saved = savedNodeMap.get(dNode.id);
|
|
215
|
+
if (saved && saved.status === "VERIFIED") {
|
|
216
|
+
finalNodes.push(saved);
|
|
217
|
+
} else {
|
|
218
|
+
finalNodes.push(dNode);
|
|
219
|
+
}
|
|
220
|
+
processedIds.add(dNode.id);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Include any custom nodes from DB that were not part of heuristic detection
|
|
224
|
+
for (const sNode of savedNodes) {
|
|
225
|
+
if (!processedIds.has(sNode.id)) {
|
|
226
|
+
finalNodes.push(sNode);
|
|
227
|
+
processedIds.add(sNode.id);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 7. Save detected nodes to SQLite SSOT
|
|
232
|
+
if (repo) {
|
|
233
|
+
for (const node of finalNodes) {
|
|
234
|
+
if (!savedNodeMap.has(node.id) || forceRefresh) {
|
|
235
|
+
repo.environment.saveNode(node);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const isDistributed =
|
|
241
|
+
finalNodes.length > 6 ||
|
|
242
|
+
finalNodes.filter((n) => n.layer === "host").length > 1 ||
|
|
243
|
+
subservices.length > 0 ||
|
|
244
|
+
finalNodes.some((n) => Boolean(n.domain_or_ip && n.domain_or_ip.includes(".")));
|
|
245
|
+
|
|
246
|
+
// 8. Generate Cross-Layer & Distributed Interplays
|
|
247
|
+
const interplays = this.analyzeInterplays(layers, finalNodes);
|
|
248
|
+
|
|
249
|
+
// 9. Collect pending inquiries from both layers and nodes
|
|
250
|
+
const pendingInquiries: string[] = [];
|
|
251
|
+
for (const node of finalNodes) {
|
|
252
|
+
if ((node.status === "AMBIGUOUS" || node.status === "UNRESOLVED") && node.unresolved_inquiry) {
|
|
253
|
+
pendingInquiries.push(`[Node: ${node.id} (${node.layer.toUpperCase()})] ${node.unresolved_inquiry}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const layerKey of Object.keys(layers) as EnvironmentLayer[]) {
|
|
257
|
+
const info = layers[layerKey];
|
|
258
|
+
if ((info.status === "AMBIGUOUS" || info.status === "UNRESOLVED") && info.unresolved_inquiry) {
|
|
259
|
+
const layerStr = `[Layer: ${layerKey.toUpperCase()}] ${info.unresolved_inquiry}`;
|
|
260
|
+
if (!pendingInquiries.some((q) => q.includes(info.unresolved_inquiry!))) {
|
|
261
|
+
pendingInquiries.push(layerStr);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const overallStatus = pendingInquiries.length === 0 ? "RESOLVED" : "NEEDS_CLARIFICATION";
|
|
267
|
+
const architecturalSummary = this.composeSummary(layers, overallStatus, isDistributed, finalNodes.length);
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
is_distributed: isDistributed,
|
|
271
|
+
nodes: finalNodes,
|
|
272
|
+
layers,
|
|
273
|
+
overall_status: overallStatus,
|
|
274
|
+
interplays,
|
|
275
|
+
pending_inquiries: pendingInquiries,
|
|
276
|
+
architectural_summary: architecturalSummary,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private static inspectEdgeLayer(root: string): EnvironmentLayerInfo {
|
|
281
|
+
const wranglerExists = fs.existsSync(path.join(root, "wrangler.toml")) || fs.existsSync(path.join(root, "wrangler.json"));
|
|
282
|
+
const envMap = this.parseEnvFileSafe(path.join(root, ".env.example"));
|
|
283
|
+
const realEnv = this.parseEnvFileSafe(path.join(root, ".env"));
|
|
284
|
+
|
|
285
|
+
let hasCloudflareTurnstile = false;
|
|
286
|
+
let hasCloudfront = false;
|
|
287
|
+
for (const key of [...envMap.keys(), ...realEnv.keys()]) {
|
|
288
|
+
if (key.startsWith("CLOUDFLARE_") || key.startsWith("TURNSTILE_")) hasCloudflareTurnstile = true;
|
|
289
|
+
if (key.startsWith("CLOUDFRONT_") || key.startsWith("AWS_CF_")) hasCloudfront = true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (wranglerExists) {
|
|
293
|
+
return {
|
|
294
|
+
layer: "edge",
|
|
295
|
+
platform: "Cloudflare (Workers / Pages / Edge)",
|
|
296
|
+
canonical_tag: "cdn_edge",
|
|
297
|
+
status: "DETECTED",
|
|
298
|
+
detected_from: ["wrangler.toml"],
|
|
299
|
+
constraints: [
|
|
300
|
+
"Edge execution boundary applies with Cloudflare edge runtime limits.",
|
|
301
|
+
"DNSSEC, WAF, and DDoS mitigation operate at the Cloudflare edge layer.",
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (hasCloudflareTurnstile) {
|
|
307
|
+
return {
|
|
308
|
+
layer: "edge",
|
|
309
|
+
platform: "Cloudflare (Edge Proxy, WAF & Bot Mitigation)",
|
|
310
|
+
canonical_tag: "cdn_edge",
|
|
311
|
+
status: "DETECTED",
|
|
312
|
+
detected_from: [".env"],
|
|
313
|
+
constraints: [
|
|
314
|
+
"Client real IP must be extracted from 'CF-Connecting-IP' header.",
|
|
315
|
+
"Application rate limiting (e.g. Throttle middleware) must trust Cloudflare IP ranges in TrustedProxy to avoid blocking proxy nodes.",
|
|
316
|
+
"Bot mitigation challenges operate at Cloudflare Edge before origin receives HTTP traffic.",
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (hasCloudfront) {
|
|
322
|
+
return {
|
|
323
|
+
layer: "edge",
|
|
324
|
+
platform: "AWS CloudFront CDN",
|
|
325
|
+
canonical_tag: "cdn_edge",
|
|
326
|
+
status: "DETECTED",
|
|
327
|
+
detected_from: [".env"],
|
|
328
|
+
constraints: [
|
|
329
|
+
"Client IP extracted via 'X-Forwarded-For' header with trusted proxy validation.",
|
|
330
|
+
"Edge cache invalidation requires CloudFront API triggers.",
|
|
331
|
+
],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
layer: "edge",
|
|
337
|
+
platform: "Direct Ingress / Unresolved Edge",
|
|
338
|
+
canonical_tag: "unspecified",
|
|
339
|
+
status: "AMBIGUOUS",
|
|
340
|
+
detected_from: [],
|
|
341
|
+
unresolved_inquiry: "Apakah domain menggunakan Edge WAF/Proxy (seperti Cloudflare) atau lalu lintas langsung mengarah ke IP server hosting?",
|
|
342
|
+
constraints: [
|
|
343
|
+
"Jika menggunakan Cloudflare: Wajib setting Trusted Proxies agar rate limiting tidak menganggap seluruh traffic berasal dari IP Cloudflare.",
|
|
344
|
+
],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private static inspectGatewayLayer(root: string): EnvironmentLayerInfo {
|
|
349
|
+
const rootHtaccess = path.join(root, ".htaccess");
|
|
350
|
+
const publicHtaccess = path.join(root, "public", ".htaccess");
|
|
351
|
+
const htaccessPath = fs.existsSync(publicHtaccess) ? publicHtaccess : fs.existsSync(rootHtaccess) ? rootHtaccess : null;
|
|
352
|
+
|
|
353
|
+
const nginxConf = fs.existsSync(path.join(root, "nginx.conf")) || fs.existsSync(path.join(root, "docker", "nginx", "nginx.conf"));
|
|
354
|
+
const caddyfile = fs.existsSync(path.join(root, "Caddyfile"));
|
|
355
|
+
|
|
356
|
+
if (htaccessPath) {
|
|
357
|
+
const content = this.readFileSafe(htaccessPath);
|
|
358
|
+
const isLiteSpeed = content.includes("Litespeed") || content.includes("LSCache") || content.includes("<IfModule LiteSpeed>");
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
layer: "gateway",
|
|
362
|
+
platform: isLiteSpeed ? "LiteSpeed Web Server" : "Apache / LiteSpeed Web Server",
|
|
363
|
+
canonical_tag: "shared_hosting",
|
|
364
|
+
status: "DETECTED",
|
|
365
|
+
detected_from: [path.relative(root, htaccessPath)],
|
|
366
|
+
constraints: [
|
|
367
|
+
"Routing, security headers, and rewrite rules are evaluated via .htaccess.",
|
|
368
|
+
"Jika LiteSpeed aktif: Perhatikan module LSCache agar respons dinamis (seperti session/Inertia state) tidak ter-cache secara publik.",
|
|
369
|
+
"Pastikan blokade akses file tersembunyi (.env, .git) terlindungi di level .htaccess.",
|
|
370
|
+
],
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (nginxConf) {
|
|
375
|
+
return {
|
|
376
|
+
layer: "gateway",
|
|
377
|
+
platform: "Nginx Reverse Proxy",
|
|
378
|
+
canonical_tag: "vps",
|
|
379
|
+
status: "DETECTED",
|
|
380
|
+
detected_from: ["nginx.conf"],
|
|
381
|
+
constraints: [
|
|
382
|
+
"Inbound routing, fastcgi buffer size, dan client_max_body_size dikendalikan oleh konfigurasi Nginx.",
|
|
383
|
+
"Header 'X-Forwarded-For' dan 'X-Forwarded-Proto' wajib diteruskan ke runtime aplikasi.",
|
|
384
|
+
],
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (caddyfile) {
|
|
389
|
+
return {
|
|
390
|
+
layer: "gateway",
|
|
391
|
+
platform: "Caddy Web Server",
|
|
392
|
+
canonical_tag: "vps",
|
|
393
|
+
status: "DETECTED",
|
|
394
|
+
detected_from: ["Caddyfile"],
|
|
395
|
+
constraints: ["Automatic TLS termination dan HTTP/3 active secara default."],
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
layer: "gateway",
|
|
401
|
+
platform: "Standard Ingress / Unknown Web Server",
|
|
402
|
+
canonical_tag: "unspecified",
|
|
403
|
+
status: "AMBIGUOUS",
|
|
404
|
+
detected_from: [],
|
|
405
|
+
unresolved_inquiry: "Web server apa yang menangani ingress HTTP (LiteSpeed, Nginx, Apache, atau Caddy)?",
|
|
406
|
+
constraints: ["Konfigurasi rewrite rules dan upload limit harus disesuaikan dengan web server yang aktif."],
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private static inspectHostLayer(root: string, gateway: EnvironmentLayerInfo): EnvironmentLayerInfo {
|
|
411
|
+
const hasDocker = fs.existsSync(path.join(root, "Dockerfile")) || fs.existsSync(path.join(root, "docker-compose.yml")) || fs.existsSync(path.join(root, "docker-compose.yaml"));
|
|
412
|
+
const hasFly = fs.existsSync(path.join(root, "fly.toml"));
|
|
413
|
+
const hasVercel = fs.existsSync(path.join(root, "vercel.json"));
|
|
414
|
+
const hasRender = fs.existsSync(path.join(root, "render.yaml"));
|
|
415
|
+
|
|
416
|
+
if (hasDocker) {
|
|
417
|
+
return {
|
|
418
|
+
layer: "host",
|
|
419
|
+
platform: "Containerized Compute (Docker / Dedicated VPS)",
|
|
420
|
+
canonical_tag: "docker",
|
|
421
|
+
status: "DETECTED",
|
|
422
|
+
detected_from: ["Dockerfile / docker-compose.yml"],
|
|
423
|
+
constraints: [
|
|
424
|
+
"Isolated rootfs and process namespace.",
|
|
425
|
+
"Process supervision (systemd/supervisord) and persistent daemon background workers are supported.",
|
|
426
|
+
],
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (hasFly) {
|
|
431
|
+
return {
|
|
432
|
+
layer: "host",
|
|
433
|
+
platform: "Fly.io MicroVMs",
|
|
434
|
+
canonical_tag: "paas",
|
|
435
|
+
status: "DETECTED",
|
|
436
|
+
detected_from: ["fly.toml"],
|
|
437
|
+
constraints: ["Ephemeral rootfs with persistent volume mounts."],
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (hasVercel) {
|
|
442
|
+
return {
|
|
443
|
+
layer: "host",
|
|
444
|
+
platform: "Vercel Serverless Edge",
|
|
445
|
+
canonical_tag: "serverless",
|
|
446
|
+
status: "DETECTED",
|
|
447
|
+
detected_from: ["vercel.json"],
|
|
448
|
+
constraints: ["Stateless request execution; no long-running socket or daemon processes."],
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (hasRender) {
|
|
453
|
+
return {
|
|
454
|
+
layer: "host",
|
|
455
|
+
platform: "Render Cloud Platform",
|
|
456
|
+
canonical_tag: "paas",
|
|
457
|
+
status: "DETECTED",
|
|
458
|
+
detected_from: ["render.yaml"],
|
|
459
|
+
constraints: ["Managed compute container with managed SSL."],
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Heuristic: If .htaccess is present with PHP/Laravel and no Docker manifests exist
|
|
464
|
+
if (gateway.detected_from?.some((f) => f.includes(".htaccess"))) {
|
|
465
|
+
return {
|
|
466
|
+
layer: "host",
|
|
467
|
+
platform: "Shared Hosting (Hostinger / cPanel) or Unmanaged VPS",
|
|
468
|
+
canonical_tag: "shared_hosting",
|
|
469
|
+
status: "AMBIGUOUS",
|
|
470
|
+
detected_from: gateway.detected_from,
|
|
471
|
+
unresolved_inquiry: "Apakah aplikasi di-hosting pada Shared Hosting (seperti Hostinger LiteSpeed/cPanel) atau VPS mandiri?",
|
|
472
|
+
constraints: [
|
|
473
|
+
"Jika Shared Hosting: Akses root/sudo tidak tersedia, memori dan batas CPU dikontrol ketat oleh cgroups/CloudLinux.",
|
|
474
|
+
"Jika Shared Hosting: Background queue worker tidak bisa berjalan sebagai daemon persisten (seperti Horizon); antrean wajib dieksekusi via scheduled cron batch ('queue:work --stop-when-empty').",
|
|
475
|
+
"Jika Shared Hosting: Eksekusi command shell (proc_open/exec) sering dibatasi di php.ini.",
|
|
476
|
+
],
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
layer: "host",
|
|
482
|
+
platform: "Virtual Server / Unknown Host",
|
|
483
|
+
canonical_tag: "unspecified",
|
|
484
|
+
status: "UNRESOLVED",
|
|
485
|
+
detected_from: [],
|
|
486
|
+
unresolved_inquiry: "Di platform hosting apa aplikasi akan di-deploy (Shared Hosting, VPS Linux, Kubernetes, atau PaaS)?",
|
|
487
|
+
constraints: ["Karakteristik hak akses process daemon dan cron scheduler bergantung pada tipe host."],
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private static inspectRuntimeLayer(root: string): EnvironmentLayerInfo {
|
|
492
|
+
const composerJson = path.join(root, "composer.json");
|
|
493
|
+
const packageJson = path.join(root, "package.json");
|
|
494
|
+
const goMod = path.join(root, "go.mod");
|
|
495
|
+
|
|
496
|
+
if (fs.existsSync(composerJson)) {
|
|
497
|
+
const content = this.readJsonSafe(composerJson);
|
|
498
|
+
const phpVersion = content?.require?.php || ">=8.1";
|
|
499
|
+
const isLaravel = Boolean(content?.require?.["laravel/framework"]);
|
|
500
|
+
|
|
501
|
+
const inertiaVue = this.checkInertiaVue(packageJson);
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
layer: "runtime",
|
|
505
|
+
platform: `PHP ${phpVersion} (FPM / FastCGI)${isLaravel ? " + Laravel" : ""}${inertiaVue ? " + Inertia/Vue" : ""}`,
|
|
506
|
+
status: "DETECTED",
|
|
507
|
+
detected_from: ["composer.json", ...(inertiaVue ? ["package.json"] : [])],
|
|
508
|
+
constraints: [
|
|
509
|
+
"Stateless request-response lifecycle with opcode cache.",
|
|
510
|
+
"Memory limit and max_execution_time governed by hosting php.ini pool.",
|
|
511
|
+
...(inertiaVue ? ["Inertia page visits utilize X-Inertia header protocol; cache rules must preserve dynamic state."] : []),
|
|
512
|
+
],
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (fs.existsSync(goMod)) {
|
|
517
|
+
return {
|
|
518
|
+
layer: "runtime",
|
|
519
|
+
platform: "Golang (Compiled Native Binary)",
|
|
520
|
+
status: "DETECTED",
|
|
521
|
+
detected_from: ["go.mod"],
|
|
522
|
+
constraints: [
|
|
523
|
+
"Single binary execution with goroutine concurrency pool.",
|
|
524
|
+
"No external PHP-FPM or interpreter dependency.",
|
|
525
|
+
],
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (fs.existsSync(packageJson)) {
|
|
530
|
+
const content = this.readJsonSafe(packageJson);
|
|
531
|
+
const nodeVersion = content?.engines?.node || ">=18";
|
|
532
|
+
return {
|
|
533
|
+
layer: "runtime",
|
|
534
|
+
platform: `Node.js (${nodeVersion}) / Bun`,
|
|
535
|
+
status: "DETECTED",
|
|
536
|
+
detected_from: ["package.json"],
|
|
537
|
+
constraints: [
|
|
538
|
+
"Single-threaded asynchronous event loop.",
|
|
539
|
+
"Uncaught promise rejections or long CPU tasks block the entire runtime loop.",
|
|
540
|
+
],
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
layer: "runtime",
|
|
546
|
+
platform: "Generic Runtime",
|
|
547
|
+
status: "UNRESOLVED",
|
|
548
|
+
detected_from: [],
|
|
549
|
+
unresolved_inquiry: "Apa bahasa dan runtime execution model yang digunakan oleh aplikasi ini?",
|
|
550
|
+
constraints: [],
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private static inspectStorageLayer(root: string): EnvironmentLayerInfo {
|
|
555
|
+
const envMap = this.parseEnvFileSafe(path.join(root, ".env.example"));
|
|
556
|
+
const realEnv = this.parseEnvFileSafe(path.join(root, ".env"));
|
|
557
|
+
const mergedEnv = new Map([...envMap, ...realEnv]);
|
|
558
|
+
const detectedStorage: string[] = [];
|
|
559
|
+
|
|
560
|
+
const dbConn = mergedEnv.get("DB_CONNECTION")?.toLowerCase() || "";
|
|
561
|
+
const dbPort = mergedEnv.get("DB_PORT") || "";
|
|
562
|
+
|
|
563
|
+
if (dbConn === "mysql" || dbPort === "3306" || [...mergedEnv.keys()].some(k => k.startsWith("MYSQL_"))) {
|
|
564
|
+
detectedStorage.push("MySQL / MariaDB");
|
|
565
|
+
} else if (dbConn === "pgsql" || dbPort === "5432" || [...mergedEnv.keys()].some(k => k.startsWith("POSTGRES_"))) {
|
|
566
|
+
detectedStorage.push("PostgreSQL");
|
|
567
|
+
} else if (dbConn === "sqlite" || fs.existsSync(path.join(root, "database", "database.sqlite"))) {
|
|
568
|
+
detectedStorage.push("SQLite (File-based)");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const queueConn = mergedEnv.get("QUEUE_CONNECTION")?.toLowerCase() || "";
|
|
572
|
+
const redisHost = mergedEnv.get("REDIS_HOST");
|
|
573
|
+
if (queueConn === "redis" || redisHost || mergedEnv.has("REDIS_CLIENT")) {
|
|
574
|
+
detectedStorage.push("Redis (Cache & Queues)");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (detectedStorage.length > 0) {
|
|
578
|
+
return {
|
|
579
|
+
layer: "storage",
|
|
580
|
+
platform: detectedStorage.join(" + "),
|
|
581
|
+
canonical_tag: "managed_db",
|
|
582
|
+
status: "DETECTED",
|
|
583
|
+
detected_from: [".env"],
|
|
584
|
+
constraints: [
|
|
585
|
+
"Database connection pool limits and socket timeouts must be configured.",
|
|
586
|
+
"Pada Shared Hosting: Akses MySQL biasanya terikat ke 'localhost' / loopback dengan batas koneksi maksimal.",
|
|
587
|
+
],
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
layer: "storage",
|
|
593
|
+
platform: "Relational Database / Storage",
|
|
594
|
+
canonical_tag: "unspecified",
|
|
595
|
+
status: "AMBIGUOUS",
|
|
596
|
+
detected_from: [],
|
|
597
|
+
unresolved_inquiry: "Database dan caching layer apa yang digunakan (MySQL, Postgres, SQLite, Redis)?",
|
|
598
|
+
constraints: ["Koneksi database wajib dikunci dengan connection pooling dan transactional isolation yang sesuai."],
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private static inspectTelemetryLayer(root: string): EnvironmentLayerInfo {
|
|
603
|
+
const envExample = this.readFileSafe(path.join(root, ".env.example")) || this.readFileSafe(path.join(root, ".env"));
|
|
604
|
+
const composerJson = this.readJsonSafe(path.join(root, "composer.json"));
|
|
605
|
+
const packageJson = this.readJsonSafe(path.join(root, "package.json"));
|
|
606
|
+
|
|
607
|
+
const hasSentry = envExample.includes("SENTRY_DSN") || Boolean(composerJson?.require?.["sentry/sentry-laravel"]) || Boolean(packageJson?.dependencies?.["@sentry/vue"] || packageJson?.dependencies?.["@sentry/browser"]);
|
|
608
|
+
const hasOtel = envExample.includes("OTEL_") || Boolean(packageJson?.dependencies?.["@opentelemetry/api"]);
|
|
609
|
+
|
|
610
|
+
if (hasSentry) {
|
|
611
|
+
return {
|
|
612
|
+
layer: "telemetry",
|
|
613
|
+
platform: "Sentry Error & Performance Monitoring",
|
|
614
|
+
status: "DETECTED",
|
|
615
|
+
detected_from: ["Sentry SDK in dependencies / .env"],
|
|
616
|
+
constraints: ["Unhandled exceptions and frontend breadcrumbs sent to Sentry with trace sampling."],
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (hasOtel) {
|
|
621
|
+
return {
|
|
622
|
+
layer: "telemetry",
|
|
623
|
+
platform: "OpenTelemetry Distributed Tracing",
|
|
624
|
+
status: "DETECTED",
|
|
625
|
+
detected_from: ["OpenTelemetry SDK"],
|
|
626
|
+
constraints: ["Context propagation with W3C traceparent headers."],
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
layer: "telemetry",
|
|
632
|
+
platform: "Application Log Files (Local / Daily)",
|
|
633
|
+
status: "DETECTED",
|
|
634
|
+
detected_from: ["Standard Framework Logger"],
|
|
635
|
+
constraints: [
|
|
636
|
+
"Rotasi log harian (daily log rotation) wajib dijaga agar disk server/hosting tidak penuh.",
|
|
637
|
+
"Error logs harus memuat correlation ID dan context data tanpa membocorkan kredensial PII.",
|
|
638
|
+
],
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private static analyzeInterplays(
|
|
643
|
+
layers: Record<EnvironmentLayer, EnvironmentLayerInfo>,
|
|
644
|
+
nodes: TopologyNode[] = []
|
|
645
|
+
): CrossLayerInterplay[] {
|
|
646
|
+
const interplays: CrossLayerInterplay[] = [];
|
|
647
|
+
|
|
648
|
+
// Canonical tag resolution
|
|
649
|
+
const isCloudflare =
|
|
650
|
+
layers.edge.canonical_tag === "cdn_edge" ||
|
|
651
|
+
this.matchCanonicalTag(layers.edge.platform) === "cdn_edge" ||
|
|
652
|
+
layers.edge.platform?.toLowerCase().includes("cloudflare") ||
|
|
653
|
+
nodes.some((n) => n.canonical_tag === "cdn_edge" || n.platform?.toLowerCase().includes("cloudflare"));
|
|
654
|
+
|
|
655
|
+
const isSharedHosting =
|
|
656
|
+
layers.host.canonical_tag === "shared_hosting" ||
|
|
657
|
+
this.matchCanonicalTag(layers.host.platform) === "shared_hosting" ||
|
|
658
|
+
nodes.some((n) => n.canonical_tag === "shared_hosting" || this.matchCanonicalTag(n.platform) === "shared_hosting");
|
|
659
|
+
|
|
660
|
+
const isLiteSpeedOrApache =
|
|
661
|
+
layers.gateway.canonical_tag === "shared_hosting" ||
|
|
662
|
+
layers.gateway.platform?.toLowerCase().includes("litespeed") ||
|
|
663
|
+
layers.gateway.platform?.toLowerCase().includes("apache");
|
|
664
|
+
|
|
665
|
+
// Interplay 1: Cloudflare WAF + Web Server + App Rate Limiting
|
|
666
|
+
if (isCloudflare) {
|
|
667
|
+
interplays.push({
|
|
668
|
+
title: "Perimeter Bot Protection & Trusted Proxy Invariant",
|
|
669
|
+
involved_layers: ["edge", "gateway", "runtime"],
|
|
670
|
+
involved_node_ids: nodes.filter((n) => n.layer === "edge" || n.layer === "runtime").map((n) => n.id),
|
|
671
|
+
risk_or_rule: "Cloudflare bertindak sebagai reverse proxy. Jika aplikasi tidak men-trust IP range Cloudflare, maka seluruh request klien tampak berasal dari IP node Cloudflare.",
|
|
672
|
+
required_action: "Di runtime (Laravel/Express), konfigurasikan TrustedProxies middleware untuk mempercayai Cloudflare IPs dan membaca client IP asli dari header 'CF-Connecting-IP'. Ini krusial agar Throttle / Rate Limiter tidak memblokir Cloudflare secara massal saat ada serangan bot.",
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Interplay 2: Shared Hosting + Queue Workers
|
|
677
|
+
if (isSharedHosting) {
|
|
678
|
+
interplays.push({
|
|
679
|
+
title: "Shared Hosting Background Execution Invariant",
|
|
680
|
+
involved_layers: ["host", "runtime"],
|
|
681
|
+
involved_node_ids: nodes.filter((n) => n.layer === "host" || n.layer === "runtime").map((n) => n.id),
|
|
682
|
+
risk_or_rule: "Shared Hosting (seperti Hostinger LiteSpeed) membatasi daemon background persisten dan membunuh proses long-running yang memakan CPU/memory.",
|
|
683
|
+
required_action: "Jangan mengandalkan daemon persisten ('php artisan queue:listen' atau Horizon). Gunakan scheduler cron berkala dengan opsi batch ('php artisan queue:work --stop-when-empty --max-time=50') atau webhook serverless untuk memproses antrean.",
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Interplay 3: LiteSpeed Caching vs Single Page / Inertia State
|
|
688
|
+
if (isLiteSpeedOrApache && layers.runtime.platform?.toLowerCase().includes("inertia")) {
|
|
689
|
+
interplays.push({
|
|
690
|
+
title: "LiteSpeed Cache vs Inertia Dynamic State Invariant",
|
|
691
|
+
involved_layers: ["gateway", "runtime"],
|
|
692
|
+
involved_node_ids: nodes.filter((n) => n.layer === "gateway" || n.layer === "runtime").map((n) => n.id),
|
|
693
|
+
risk_or_rule: "Fitur caching agresif pada LiteSpeed (.htaccess LSCache) dapat secara tidak sengaja meng-cache payload JSON Inertia atau respons halaman sesi pengguna.",
|
|
694
|
+
required_action: "Pastikan aturan .htaccess mengecualikan caching pada request yang membawa header 'X-Inertia' atau cookie autentikasi ('laravel_session', 'XSRF-TOKEN').",
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Distributed Interplay 4: Cross-Domain Cookie & CORS Invariant
|
|
699
|
+
const domains = nodes.map((n) => n.domain_or_ip).filter((d): d is string => Boolean(d && d.includes(".")));
|
|
700
|
+
const hasSeparateFrontendBackend =
|
|
701
|
+
nodes.some((n) => n.role === "frontend_spa" || n.id.includes("frontend")) &&
|
|
702
|
+
nodes.some((n) => n.role === "web_api" || n.id.includes("backend"));
|
|
703
|
+
const hasMultipleDomains = new Set(domains).size > 1;
|
|
704
|
+
|
|
705
|
+
if (hasMultipleDomains || hasSeparateFrontendBackend) {
|
|
706
|
+
interplays.push({
|
|
707
|
+
title: "Cross-Domain Cookie & CORS Security Invariant",
|
|
708
|
+
involved_layers: ["edge", "gateway", "runtime"],
|
|
709
|
+
involved_node_ids: nodes.filter((n) => n.layer === "runtime" || n.layer === "gateway").map((n) => n.id),
|
|
710
|
+
risk_or_rule: "Frontend dan Backend beroperasi pada domain/subdomain berbeda atau decoupled SPA. Browser memblokir pengiriman cookie session/CSRF dan memicu CORS preflight (OPTIONS).",
|
|
711
|
+
required_action: "Konfigurasikan backend CORS credentials=true dengan whitelist domain frontend spesifik. Untuk session cookie, setel 'SameSite=None; Secure' serta domain wildcard '.rootdomain.com' jika berbagi root domain.",
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Distributed Interplay 5: Database Migration Concurrency Invariant
|
|
716
|
+
const computeNodes = nodes.filter((n) => n.layer === "host" || (n.layer === "runtime" && n.role !== "frontend_spa"));
|
|
717
|
+
if (computeNodes.length > 1) {
|
|
718
|
+
interplays.push({
|
|
719
|
+
title: "Distributed Database Migration Concurrency Invariant",
|
|
720
|
+
involved_layers: ["host", "storage"],
|
|
721
|
+
involved_node_ids: computeNodes.map((n) => n.id),
|
|
722
|
+
risk_or_rule: "Terdapat lebih dari 1 instance host/compute (multi-VPS / multi-node) yang terhubung ke database bersama. Menjalankan migrasi database serentak saat deployment memicu deadlock tabel atau migration race condition.",
|
|
723
|
+
required_action: "Pastikan script migrasi (seperti 'artisan migrate --isolated') hanya dijalankan oleh satu designated deploy pipeline runner sebelum node lain menerima traffic.",
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Distributed Interplay 6: Distributed Scheduler Duplication Invariant
|
|
728
|
+
const hostNodes = nodes.filter((n) => n.layer === "host");
|
|
729
|
+
if (hostNodes.length > 1) {
|
|
730
|
+
interplays.push({
|
|
731
|
+
title: "Distributed Scheduler & Cron Invariant",
|
|
732
|
+
involved_layers: ["host", "runtime"],
|
|
733
|
+
involved_node_ids: hostNodes.map((n) => n.id),
|
|
734
|
+
risk_or_rule: "Beberapa server host aktif secara paralel. Jika scheduler cron dipasang di semua instance VPS, task terjadwal (seperti batch email atau billing) akan terduplikasi.",
|
|
735
|
+
required_action: "Kunci scheduler menggunakan atomic cache mutex lock ('schedule:run' dengan 'withoutOverlapping()') atau tetapkan 1 VPS khusus sebagai Dedicated Worker Node.",
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return interplays;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private static composeSummary(
|
|
743
|
+
layers: Record<EnvironmentLayer, EnvironmentLayerInfo>,
|
|
744
|
+
status: "RESOLVED" | "NEEDS_CLARIFICATION",
|
|
745
|
+
isDistributed: boolean = false,
|
|
746
|
+
nodeCount: number = 6
|
|
747
|
+
): string {
|
|
748
|
+
const topologyMode = isDistributed ? `Distributed Multi-Node (${nodeCount} nodes)` : "Monolithic Single-Node";
|
|
749
|
+
const edge = layers.edge.platform || "Direct";
|
|
750
|
+
const gateway = layers.gateway.platform || "Standard Ingress";
|
|
751
|
+
const host = layers.host.platform || "Unknown Host";
|
|
752
|
+
const runtime = layers.runtime.platform || "Standard Runtime";
|
|
753
|
+
const storage = layers.storage.platform || "Standard Storage";
|
|
754
|
+
|
|
755
|
+
return `Architecture Plane: [${topologyMode}]. Pipeline: [Edge: ${edge}] ➔ [Gateway: ${gateway}] ➔ [Host: ${host}] ➔ [Runtime: ${runtime}] ➔ [Storage: ${storage}]. Status: ${status}.`;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private static checkInertiaVue(packageJsonPath: string): boolean {
|
|
759
|
+
if (!fs.existsSync(packageJsonPath)) return false;
|
|
760
|
+
const content = this.readJsonSafe(packageJsonPath);
|
|
761
|
+
return Boolean(
|
|
762
|
+
content?.dependencies?.["@inertiajs/vue3"] ||
|
|
763
|
+
content?.dependencies?.["@inertiajs/inertia-vue3"] ||
|
|
764
|
+
content?.devDependencies?.["@inertiajs/vue3"]
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
public static parseEnvFileSafe(filePath: string): Map<string, string> {
|
|
769
|
+
const map = new Map<string, string>();
|
|
770
|
+
const raw = this.readFileSafe(filePath);
|
|
771
|
+
if (!raw) return map;
|
|
772
|
+
|
|
773
|
+
const lines = raw.split(/\r?\n/);
|
|
774
|
+
for (const line of lines) {
|
|
775
|
+
const trimmed = line.trim();
|
|
776
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
777
|
+
const eqIdx = trimmed.indexOf("=");
|
|
778
|
+
if (eqIdx === -1) continue;
|
|
779
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
780
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
781
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
782
|
+
val = val.slice(1, -1);
|
|
783
|
+
}
|
|
784
|
+
map.set(key, val);
|
|
785
|
+
}
|
|
786
|
+
return map;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
public static matchCanonicalTag(str?: string): CanonicalPlatformTag {
|
|
790
|
+
if (!str) return "unspecified";
|
|
791
|
+
const s = str.toLowerCase();
|
|
792
|
+
if (s.includes("shared") || s.includes("hostinger") || s.includes("cpanel") || s.includes("litespeed")) return "shared_hosting";
|
|
793
|
+
if (s.includes("docker") || s.includes("compose") || s.includes("container")) return "docker";
|
|
794
|
+
if (s.includes("k8s") || s.includes("kubernetes") || s.includes("helm")) return "kubernetes";
|
|
795
|
+
if (s.includes("vps") || s.includes("dedicated") || s.includes("digitalocean") || s.includes("linode") || s.includes("ubuntu")) return "vps";
|
|
796
|
+
if (s.includes("cloudflare") || s.includes("cloudfront") || s.includes("fastly") || s.includes("akamai")) return "cdn_edge";
|
|
797
|
+
if (s.includes("vercel") || s.includes("serverless") || s.includes("lambda")) return "serverless";
|
|
798
|
+
if (s.includes("fly.io") || s.includes("render") || s.includes("railway") || s.includes("heroku")) return "paas";
|
|
799
|
+
if (s.includes("rds") || s.includes("managed") || s.includes("supabase") || s.includes("planetscale")) return "managed_db";
|
|
800
|
+
return "unspecified";
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
public static probeSubservices(workspaceRoot: string): { path: string; name: string; type: "backend" | "frontend" | "service" }[] {
|
|
804
|
+
const subservices: { path: string; name: string; type: "backend" | "frontend" | "service" }[] = [];
|
|
805
|
+
const candidateDirs = ["backend", "frontend", "api", "web", "client", "ui", "services", "apps"];
|
|
806
|
+
for (const dir of candidateDirs) {
|
|
807
|
+
const fullDir = path.join(workspaceRoot, dir);
|
|
808
|
+
if (fs.existsSync(fullDir)) {
|
|
809
|
+
try {
|
|
810
|
+
const stat = fs.statSync(fullDir);
|
|
811
|
+
if (!stat.isDirectory()) continue;
|
|
812
|
+
|
|
813
|
+
if (dir === "apps" || dir === "services") {
|
|
814
|
+
const entries = fs.readdirSync(fullDir, { withFileTypes: true });
|
|
815
|
+
for (const e of entries) {
|
|
816
|
+
if (e.isDirectory()) {
|
|
817
|
+
const nestedPath = path.join(fullDir, e.name);
|
|
818
|
+
const isFrontend = e.name.toLowerCase().includes("web") || e.name.toLowerCase().includes("ui") || e.name.toLowerCase().includes("client");
|
|
819
|
+
subservices.push({
|
|
820
|
+
path: nestedPath,
|
|
821
|
+
name: `${dir}-${e.name}`,
|
|
822
|
+
type: isFrontend ? "frontend" : "service",
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
} else {
|
|
827
|
+
const isFrontend = dir === "frontend" || dir === "web" || dir === "client" || dir === "ui";
|
|
828
|
+
subservices.push({ path: fullDir, name: dir, type: isFrontend ? "frontend" : "backend" });
|
|
829
|
+
}
|
|
830
|
+
} catch {
|
|
831
|
+
// Ignore filesystem errors
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return subservices;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
private static readFileSafe(filePath: string): string {
|
|
839
|
+
try {
|
|
840
|
+
if (fs.existsSync(filePath)) {
|
|
841
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
842
|
+
}
|
|
843
|
+
} catch {
|
|
844
|
+
// Ignored
|
|
845
|
+
}
|
|
846
|
+
return "";
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
private static readJsonSafe(filePath: string): Record<string, any> | null {
|
|
850
|
+
try {
|
|
851
|
+
if (fs.existsSync(filePath)) {
|
|
852
|
+
const text = fs.readFileSync(filePath, "utf-8");
|
|
853
|
+
return JSON.parse(text);
|
|
854
|
+
}
|
|
855
|
+
} catch {
|
|
856
|
+
// Ignored
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
}
|