archbyte 0.3.5 → 0.4.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 +42 -0
- package/bin/archbyte.js +26 -25
- package/dist/agents/pipeline/merger.d.ts +2 -2
- package/dist/agents/pipeline/merger.js +165 -35
- package/dist/agents/pipeline/types.d.ts +29 -1
- package/dist/agents/pipeline/types.js +0 -1
- package/dist/agents/providers/claude-sdk.d.ts +7 -0
- package/dist/agents/providers/claude-sdk.js +83 -0
- package/dist/agents/providers/router.d.ts +5 -0
- package/dist/agents/providers/router.js +23 -1
- package/dist/agents/runtime/types.d.ts +6 -2
- package/dist/agents/runtime/types.js +6 -1
- package/dist/agents/static/component-detector.js +35 -3
- package/dist/agents/static/connection-mapper.d.ts +1 -1
- package/dist/agents/static/connection-mapper.js +74 -1
- package/dist/agents/static/index.js +5 -2
- package/dist/agents/static/types.d.ts +26 -0
- package/dist/cli/analyze.js +65 -18
- package/dist/cli/arch-diff.d.ts +38 -0
- package/dist/cli/arch-diff.js +61 -0
- package/dist/cli/auth.d.ts +8 -2
- package/dist/cli/auth.js +241 -31
- package/dist/cli/config.js +31 -5
- package/dist/cli/export.js +64 -2
- package/dist/cli/patrol.d.ts +5 -3
- package/dist/cli/patrol.js +417 -65
- package/dist/cli/setup.js +76 -8
- package/dist/cli/shared.d.ts +11 -0
- package/dist/cli/shared.js +61 -0
- package/dist/cli/ui.d.ts +9 -0
- package/dist/cli/ui.js +59 -5
- package/dist/cli/validate.d.ts +0 -1
- package/dist/cli/validate.js +0 -16
- package/dist/server/src/index.js +593 -19
- package/package.json +4 -1
- package/templates/archbyte.yaml +8 -0
- package/ui/dist/assets/index-DDCNauh7.css +1 -0
- package/ui/dist/assets/index-DO4t5Xu1.js +72 -0
- package/ui/dist/index.html +2 -2
- package/dist/cli/mcp-server.d.ts +0 -1
- package/dist/cli/mcp-server.js +0 -443
- package/dist/cli/mcp.d.ts +0 -1
- package/dist/cli/mcp.js +0 -98
- package/ui/dist/assets/index-0_XpUUZQ.css +0 -1
- package/ui/dist/assets/index-BTo0zV5E.js +0 -70
package/dist/server/src/index.js
CHANGED
|
@@ -14,6 +14,9 @@ const UI_DIST = path.resolve(__dirname, "../../../ui/dist");
|
|
|
14
14
|
let config;
|
|
15
15
|
let sseClients = new Set();
|
|
16
16
|
let diagramWatcher = null;
|
|
17
|
+
let isAnalyzing = false;
|
|
18
|
+
const sessionChanges = [];
|
|
19
|
+
const MAX_SESSION_CHANGES = 50;
|
|
17
20
|
let currentArchitecture = null;
|
|
18
21
|
// Process tracking for run-from-UI
|
|
19
22
|
const runningWorkflows = new Map();
|
|
@@ -103,7 +106,7 @@ function createHttpServer() {
|
|
|
103
106
|
const reqOrigin = req.headers.origin || "";
|
|
104
107
|
const allowedOrigin = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(reqOrigin) ? reqOrigin : `http://localhost:${config.port}`;
|
|
105
108
|
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
|
|
106
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
109
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS");
|
|
107
110
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
108
111
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
109
112
|
res.setHeader("X-Frame-Options", "SAMEORIGIN");
|
|
@@ -254,8 +257,81 @@ function createHttpServer() {
|
|
|
254
257
|
}));
|
|
255
258
|
return;
|
|
256
259
|
}
|
|
257
|
-
// API:
|
|
258
|
-
if (url
|
|
260
|
+
// API: Impact analysis — blast radius BFS from a node
|
|
261
|
+
if (url.startsWith("/api/impact/") && req.method === "GET") {
|
|
262
|
+
const arch = currentArchitecture || (await loadArchitecture());
|
|
263
|
+
if (!arch) {
|
|
264
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
265
|
+
res.end(JSON.stringify({ error: "No architecture loaded" }));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const urlParts = url.split("?");
|
|
269
|
+
const nodeId = decodeURIComponent(urlParts[0].replace("/api/impact/", ""));
|
|
270
|
+
const params = new URL(url, `http://localhost:${config.port}`).searchParams;
|
|
271
|
+
const depth = Math.min(Math.max(parseInt(params.get("depth") || "2", 10) || 2, 1), 5);
|
|
272
|
+
const direction = params.get("direction") || "both";
|
|
273
|
+
// Build adjacency lists
|
|
274
|
+
const forward = new Map();
|
|
275
|
+
const reverse = new Map();
|
|
276
|
+
for (const nd of arch.nodes) {
|
|
277
|
+
forward.set(nd.id, []);
|
|
278
|
+
reverse.set(nd.id, []);
|
|
279
|
+
}
|
|
280
|
+
for (const edge of arch.edges) {
|
|
281
|
+
forward.get(edge.source)?.push(edge.target);
|
|
282
|
+
reverse.get(edge.target)?.push(edge.source);
|
|
283
|
+
}
|
|
284
|
+
const nodeMap = new Map();
|
|
285
|
+
for (const nd of arch.nodes)
|
|
286
|
+
nodeMap.set(nd.id, nd);
|
|
287
|
+
// BFS helper
|
|
288
|
+
function bfs(startId, adj, maxDepth) {
|
|
289
|
+
const visited = new Map();
|
|
290
|
+
visited.set(startId, 0);
|
|
291
|
+
const queue = [{ id: startId, d: 0 }];
|
|
292
|
+
const result = [];
|
|
293
|
+
while (queue.length > 0) {
|
|
294
|
+
const { id, d } = queue.shift();
|
|
295
|
+
if (d >= maxDepth)
|
|
296
|
+
continue;
|
|
297
|
+
for (const neighbor of adj.get(id) || []) {
|
|
298
|
+
if (!visited.has(neighbor)) {
|
|
299
|
+
const nd = d + 1;
|
|
300
|
+
visited.set(neighbor, nd);
|
|
301
|
+
result.push({ id: neighbor, label: nodeMap.get(neighbor)?.label.split("\n")[0] || neighbor, depth: nd });
|
|
302
|
+
queue.push({ id: neighbor, d: nd });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
const upstream = (direction === "downstream") ? [] : bfs(nodeId, reverse, depth);
|
|
309
|
+
const downstream = (direction === "upstream") ? [] : bfs(nodeId, forward, depth);
|
|
310
|
+
// Collect all affected node IDs (including origin)
|
|
311
|
+
const affectedSet = new Set([nodeId]);
|
|
312
|
+
for (const n of upstream)
|
|
313
|
+
affectedSet.add(n.id);
|
|
314
|
+
for (const n of downstream)
|
|
315
|
+
affectedSet.add(n.id);
|
|
316
|
+
// Affected edges: edges between any two nodes in the affected set
|
|
317
|
+
const affectedEdges = [];
|
|
318
|
+
for (const edge of arch.edges) {
|
|
319
|
+
if (affectedSet.has(edge.source) && affectedSet.has(edge.target)) {
|
|
320
|
+
affectedEdges.push(edge.id);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
324
|
+
res.end(JSON.stringify({
|
|
325
|
+
nodeId,
|
|
326
|
+
upstream,
|
|
327
|
+
downstream,
|
|
328
|
+
affectedEdges,
|
|
329
|
+
totalAffected: affectedSet.size - 1,
|
|
330
|
+
}));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// API: Audit — unified validation + health endpoint
|
|
334
|
+
if ((url === "/api/audit" || url === "/api/validate") && req.method === "GET") {
|
|
259
335
|
const arch = currentArchitecture || (await loadArchitecture());
|
|
260
336
|
if (!arch) {
|
|
261
337
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
@@ -282,6 +358,7 @@ function createHttpServer() {
|
|
|
282
358
|
level: "error",
|
|
283
359
|
message: `"${src.label}" (${src.layer}) -> "${tgt.label}" (${tgt.layer})`,
|
|
284
360
|
nodeIds: [src.id, tgt.id],
|
|
361
|
+
ruleType: "builtin",
|
|
285
362
|
});
|
|
286
363
|
}
|
|
287
364
|
}
|
|
@@ -299,6 +376,7 @@ function createHttpServer() {
|
|
|
299
376
|
level: "warn",
|
|
300
377
|
message: `"${nd.label}" has ${count} connections (threshold: 6)`,
|
|
301
378
|
nodeIds: [nd.id],
|
|
379
|
+
ruleType: "builtin",
|
|
302
380
|
});
|
|
303
381
|
}
|
|
304
382
|
}
|
|
@@ -315,6 +393,7 @@ function createHttpServer() {
|
|
|
315
393
|
level: "warn",
|
|
316
394
|
message: `"${nd.label}" has no connections`,
|
|
317
395
|
nodeIds: [nd.id],
|
|
396
|
+
ruleType: "builtin",
|
|
318
397
|
});
|
|
319
398
|
}
|
|
320
399
|
}
|
|
@@ -356,19 +435,288 @@ function createHttpServer() {
|
|
|
356
435
|
level: "error",
|
|
357
436
|
message: `Cycle: ${cycle.map((id) => nodeMap.get(id)?.label || id).join(" -> ")}`,
|
|
358
437
|
nodeIds: cycle.filter((id, i, arr) => arr.indexOf(id) === i),
|
|
438
|
+
ruleType: "builtin",
|
|
359
439
|
});
|
|
360
440
|
}
|
|
441
|
+
// Custom rules from archbyte.yaml
|
|
442
|
+
try {
|
|
443
|
+
const yamlPath = path.join(config.workspaceRoot, ".archbyte/archbyte.yaml");
|
|
444
|
+
if (existsSync(yamlPath)) {
|
|
445
|
+
const yamlContent = readFileSync(yamlPath, "utf-8");
|
|
446
|
+
const rulesMatch = yamlContent.match(/^rules:\s*\n((?:\s+.*\n)*)/m);
|
|
447
|
+
if (rulesMatch) {
|
|
448
|
+
const rulesBlock = rulesMatch[1];
|
|
449
|
+
const ruleRegex = /^\s+-\s+name:\s*"?([^"\n]+)"?\s*\n\s+from:\s*"?([^"\n]+)"?\s*\n\s+to:\s*"?([^"\n]+)"?\s*\n\s+level:\s*"?([^"\n]+)"?/gm;
|
|
450
|
+
let m;
|
|
451
|
+
const customRules = [];
|
|
452
|
+
while ((m = ruleRegex.exec(rulesBlock)) !== null) {
|
|
453
|
+
customRules.push({ name: m[1].trim(), from: m[2].trim(), to: m[3].trim(), level: m[4].trim() });
|
|
454
|
+
}
|
|
455
|
+
// Helper: check if a node matches a custom rule matcher (type:xxx, layer:xxx, or exact id)
|
|
456
|
+
const matchesNode = (nd, matcher) => {
|
|
457
|
+
if (matcher.startsWith("type:"))
|
|
458
|
+
return nd.type === matcher.slice(5);
|
|
459
|
+
if (matcher.startsWith("layer:"))
|
|
460
|
+
return nd.layer === matcher.slice(6);
|
|
461
|
+
return nd.id === matcher;
|
|
462
|
+
};
|
|
463
|
+
for (const rule of customRules) {
|
|
464
|
+
for (const edge of arch.edges) {
|
|
465
|
+
const src = nodeMap.get(edge.source);
|
|
466
|
+
const tgt = nodeMap.get(edge.target);
|
|
467
|
+
if (!src || !tgt)
|
|
468
|
+
continue;
|
|
469
|
+
if (matchesNode(src, rule.from) && matchesNode(tgt, rule.to)) {
|
|
470
|
+
violations.push({
|
|
471
|
+
rule: rule.name,
|
|
472
|
+
level: rule.level === "error" ? "error" : "warn",
|
|
473
|
+
message: `"${src.label}" -> "${tgt.label}" violates custom rule "${rule.name}"`,
|
|
474
|
+
nodeIds: [src.id, tgt.id],
|
|
475
|
+
ruleType: "custom",
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch { /* ignore custom rule errors */ }
|
|
484
|
+
// === STRUCTURAL ISSUES ===
|
|
485
|
+
// Build degree maps for health metrics
|
|
486
|
+
const inDeg = new Map();
|
|
487
|
+
const outDeg = new Map();
|
|
488
|
+
const connCount = new Map();
|
|
489
|
+
const fwdAdj = new Map();
|
|
490
|
+
for (const nd of realNodes) {
|
|
491
|
+
inDeg.set(nd.id, 0);
|
|
492
|
+
outDeg.set(nd.id, 0);
|
|
493
|
+
connCount.set(nd.id, 0);
|
|
494
|
+
fwdAdj.set(nd.id, []);
|
|
495
|
+
}
|
|
496
|
+
for (const edge of arch.edges) {
|
|
497
|
+
outDeg.set(edge.source, (outDeg.get(edge.source) || 0) + 1);
|
|
498
|
+
inDeg.set(edge.target, (inDeg.get(edge.target) || 0) + 1);
|
|
499
|
+
connCount.set(edge.source, (connCount.get(edge.source) || 0) + 1);
|
|
500
|
+
connCount.set(edge.target, (connCount.get(edge.target) || 0) + 1);
|
|
501
|
+
fwdAdj.get(edge.source)?.push(edge.target);
|
|
502
|
+
}
|
|
503
|
+
// DFS cycle detection for metrics
|
|
504
|
+
const visitedH = new Set();
|
|
505
|
+
const inStackH = new Set();
|
|
506
|
+
const nodesInCycles = new Set();
|
|
507
|
+
let circularDependenciesCount = 0;
|
|
508
|
+
function dfsHealth(nodeId) {
|
|
509
|
+
visitedH.add(nodeId);
|
|
510
|
+
inStackH.add(nodeId);
|
|
511
|
+
for (const neighbor of fwdAdj.get(nodeId) || []) {
|
|
512
|
+
if (!visitedH.has(neighbor)) {
|
|
513
|
+
dfsHealth(neighbor);
|
|
514
|
+
}
|
|
515
|
+
else if (inStackH.has(neighbor)) {
|
|
516
|
+
nodesInCycles.add(neighbor);
|
|
517
|
+
nodesInCycles.add(nodeId);
|
|
518
|
+
circularDependenciesCount++;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
inStackH.delete(nodeId);
|
|
522
|
+
}
|
|
523
|
+
for (const nd of realNodes) {
|
|
524
|
+
if (!visitedH.has(nd.id))
|
|
525
|
+
dfsHealth(nd.id);
|
|
526
|
+
}
|
|
527
|
+
// Health nodes
|
|
528
|
+
const totalConnections = Array.from(connCount.values()).reduce((a, b) => a + b, 0);
|
|
529
|
+
const avgConn = realNodes.length > 0 ? totalConnections / realNodes.length : 0;
|
|
530
|
+
const maxConn = Math.max(...Array.from(connCount.values()), 1);
|
|
531
|
+
const healthNodes = realNodes.map((nd) => {
|
|
532
|
+
const cc = connCount.get(nd.id) || 0;
|
|
533
|
+
const isOrphan = cc === 0;
|
|
534
|
+
const isHub = cc > 6;
|
|
535
|
+
const inCycle = nodesInCycles.has(nd.id);
|
|
536
|
+
const normalizedFanInOutRatio = maxConn > 0 ? Math.round((cc / maxConn) * 10 * 10) / 10 : 0;
|
|
537
|
+
let modularityIndex = 100;
|
|
538
|
+
if (isOrphan)
|
|
539
|
+
modularityIndex -= 30;
|
|
540
|
+
if (isHub)
|
|
541
|
+
modularityIndex -= 20;
|
|
542
|
+
if (inCycle)
|
|
543
|
+
modularityIndex -= 25;
|
|
544
|
+
if (avgConn > 0 && cc > avgConn * 1.5)
|
|
545
|
+
modularityIndex -= Math.min(25, Math.round(((cc - avgConn) / avgConn) * 25));
|
|
546
|
+
modularityIndex = Math.max(0, modularityIndex);
|
|
547
|
+
return {
|
|
548
|
+
id: nd.id,
|
|
549
|
+
label: nd.label.split("\n")[0],
|
|
550
|
+
connectionCount: cc,
|
|
551
|
+
inDegree: inDeg.get(nd.id) || 0,
|
|
552
|
+
outDegree: outDeg.get(nd.id) || 0,
|
|
553
|
+
isOrphan,
|
|
554
|
+
isHub,
|
|
555
|
+
inCycle,
|
|
556
|
+
normalizedFanInOutRatio,
|
|
557
|
+
modularityIndex,
|
|
558
|
+
couplingScore: normalizedFanInOutRatio,
|
|
559
|
+
healthScore: modularityIndex,
|
|
560
|
+
};
|
|
561
|
+
});
|
|
562
|
+
// Calculate metrics
|
|
563
|
+
const disconnectedComponentCount = healthNodes.filter((n) => n.isOrphan).length;
|
|
564
|
+
const meanCouplingRatio = avgConn > 0 ? Math.round((totalConnections / realNodes.length / maxConn) * 10 * 10) / 10 : 0;
|
|
565
|
+
const maxFanInOutDegree = maxConn;
|
|
566
|
+
const disconnectionRatio = realNodes.length > 0 ? Math.round((disconnectedComponentCount / realNodes.length) * 100) : 0;
|
|
567
|
+
const highDegreeHubCount = healthNodes.filter((n) => n.isHub).length;
|
|
568
|
+
// Convert violations to issues
|
|
569
|
+
const ruleViolations = violations.map((v) => ({
|
|
570
|
+
type: 'rule-violation',
|
|
571
|
+
rule: v.rule,
|
|
572
|
+
level: v.level,
|
|
573
|
+
message: v.message,
|
|
574
|
+
nodeIds: v.nodeIds,
|
|
575
|
+
ruleType: v.ruleType,
|
|
576
|
+
}));
|
|
577
|
+
// Generate structural issues from metrics
|
|
578
|
+
const structuralIssues = [];
|
|
579
|
+
if (circularDependenciesCount > 0) {
|
|
580
|
+
for (const nodeId of Array.from(nodesInCycles).slice(0, 5)) {
|
|
581
|
+
structuralIssues.push({
|
|
582
|
+
type: 'structural',
|
|
583
|
+
metric: 'circular-deps',
|
|
584
|
+
level: 'warn',
|
|
585
|
+
message: `${nodeMap.get(nodeId)?.label || nodeId} is part of a circular dependency`,
|
|
586
|
+
nodeIds: [nodeId],
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
for (const node of healthNodes) {
|
|
591
|
+
if (node.isHub) {
|
|
592
|
+
structuralIssues.push({
|
|
593
|
+
type: 'structural',
|
|
594
|
+
metric: 'high-hub',
|
|
595
|
+
level: 'warn',
|
|
596
|
+
message: `${node.label} is a high-degree hub with ${node.connectionCount} connections`,
|
|
597
|
+
nodeIds: [node.id],
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
if (node.isOrphan) {
|
|
601
|
+
structuralIssues.push({
|
|
602
|
+
type: 'structural',
|
|
603
|
+
metric: 'orphan',
|
|
604
|
+
level: 'warn',
|
|
605
|
+
message: `${node.label} has no connections (orphan component)`,
|
|
606
|
+
nodeIds: [node.id],
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const allIssues = [...ruleViolations, ...structuralIssues];
|
|
611
|
+
const ruleViolationCount = ruleViolations.length;
|
|
612
|
+
const structuralIssueCount = structuralIssues.length;
|
|
361
613
|
const errors = violations.filter((v) => v.level === "error").length;
|
|
362
|
-
const warnings = violations.filter((v) => v.level === "warn").length;
|
|
363
614
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
364
615
|
res.end(JSON.stringify({
|
|
365
616
|
passed: errors === 0,
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
617
|
+
issues: allIssues,
|
|
618
|
+
summary: {
|
|
619
|
+
ruleViolations: ruleViolationCount,
|
|
620
|
+
structuralIssues: structuralIssueCount,
|
|
621
|
+
totalIssues: allIssues.length,
|
|
622
|
+
},
|
|
623
|
+
metrics: {
|
|
624
|
+
circularDependencies: circularDependenciesCount,
|
|
625
|
+
highDegreeHubCount,
|
|
626
|
+
disconnectedComponentCount,
|
|
627
|
+
meanCouplingRatio,
|
|
628
|
+
maxFanInOutDegree,
|
|
629
|
+
},
|
|
630
|
+
nodes: healthNodes,
|
|
631
|
+
rules: {
|
|
632
|
+
builtin: [
|
|
633
|
+
{ name: "no-layer-bypass", level: "error", description: "Prevent connections that skip layers" },
|
|
634
|
+
{ name: "max-connections", level: "warn", description: "Flag components with too many connections" },
|
|
635
|
+
{ name: "no-orphans", level: "warn", description: "Flag components with no connections" },
|
|
636
|
+
{ name: "no-circular-deps", level: "error", description: "Detect circular dependency chains" },
|
|
637
|
+
],
|
|
638
|
+
custom: [],
|
|
639
|
+
},
|
|
369
640
|
}));
|
|
370
641
|
return;
|
|
371
642
|
}
|
|
643
|
+
// API: Health (deprecated - redirect to audit for backward compat)
|
|
644
|
+
if (url === "/api/health" && req.method === "GET") {
|
|
645
|
+
res.writeHead(307, { "Location": "/api/audit" });
|
|
646
|
+
res.end();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
// API: Rules — GET returns current rules config
|
|
650
|
+
if (url === "/api/rules" && req.method === "GET") {
|
|
651
|
+
const builtin = [
|
|
652
|
+
{ name: "no-layer-bypass", level: "error", description: "Prevent connections that skip layers" },
|
|
653
|
+
{ name: "max-connections", level: "warn", threshold: 6, description: "Flag components with too many connections" },
|
|
654
|
+
{ name: "no-orphans", level: "warn", description: "Flag components with no connections" },
|
|
655
|
+
{ name: "no-circular-deps", level: "error", description: "Detect circular dependency chains" },
|
|
656
|
+
];
|
|
657
|
+
let custom = [];
|
|
658
|
+
try {
|
|
659
|
+
const yamlPath = path.join(config.workspaceRoot, ".archbyte/archbyte.yaml");
|
|
660
|
+
if (existsSync(yamlPath)) {
|
|
661
|
+
const yamlContent = readFileSync(yamlPath, "utf-8");
|
|
662
|
+
// Parse custom rules from YAML
|
|
663
|
+
const rulesMatch = yamlContent.match(/^rules:\s*\n((?:\s+.*\n)*)/m);
|
|
664
|
+
if (rulesMatch) {
|
|
665
|
+
const rulesBlock = rulesMatch[1];
|
|
666
|
+
const ruleRegex = /^\s+-\s+name:\s*"?([^"\n]+)"?\s*\n\s+from:\s*"?([^"\n]+)"?\s*\n\s+to:\s*"?([^"\n]+)"?\s*\n\s+level:\s*"?([^"\n]+)"?/gm;
|
|
667
|
+
let m;
|
|
668
|
+
while ((m = ruleRegex.exec(rulesBlock)) !== null) {
|
|
669
|
+
custom.push({ name: m[1].trim(), from: m[2].trim(), to: m[3].trim(), level: m[4].trim() });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
catch { /* ignore */ }
|
|
675
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
676
|
+
res.end(JSON.stringify({ builtin, custom }));
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
// API: Rules — PUT saves rules config
|
|
680
|
+
if (url === "/api/rules" && req.method === "PUT") {
|
|
681
|
+
let body = "";
|
|
682
|
+
req.on("data", (chunk) => { body += chunk.toString(); });
|
|
683
|
+
req.on("end", async () => {
|
|
684
|
+
try {
|
|
685
|
+
const { custom } = JSON.parse(body);
|
|
686
|
+
const yamlDir = path.join(config.workspaceRoot, ".archbyte");
|
|
687
|
+
if (!existsSync(yamlDir))
|
|
688
|
+
await mkdir(yamlDir, { recursive: true });
|
|
689
|
+
const yamlPath = path.join(yamlDir, "archbyte.yaml");
|
|
690
|
+
let yamlContent = "";
|
|
691
|
+
if (existsSync(yamlPath)) {
|
|
692
|
+
yamlContent = readFileSync(yamlPath, "utf-8");
|
|
693
|
+
}
|
|
694
|
+
// Remove existing rules section
|
|
695
|
+
yamlContent = yamlContent.replace(/^rules:\s*\n((?:\s+.*\n)*)/m, "");
|
|
696
|
+
// Build new rules section
|
|
697
|
+
if (custom && custom.length > 0) {
|
|
698
|
+
let rulesYaml = "rules:\n";
|
|
699
|
+
for (const rule of custom) {
|
|
700
|
+
// Validate rule fields to prevent YAML injection
|
|
701
|
+
const safeName = rule.name.replace(/["\n\r]/g, "");
|
|
702
|
+
const safeFrom = rule.from.replace(/["\n\r]/g, "");
|
|
703
|
+
const safeTo = rule.to.replace(/["\n\r]/g, "");
|
|
704
|
+
const safeLevel = (rule.level === "error" || rule.level === "warn") ? rule.level : "warn";
|
|
705
|
+
rulesYaml += ` - name: "${safeName}"\n from: "${safeFrom}"\n to: "${safeTo}"\n level: "${safeLevel}"\n`;
|
|
706
|
+
}
|
|
707
|
+
yamlContent = yamlContent.trimEnd() + "\n\n" + rulesYaml;
|
|
708
|
+
}
|
|
709
|
+
await writeFile(yamlPath, yamlContent.trimEnd() + "\n");
|
|
710
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
711
|
+
res.end(JSON.stringify({ ok: true }));
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
715
|
+
res.end(JSON.stringify({ error: String(error) }));
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
372
720
|
// API: Export architecture as text format
|
|
373
721
|
if (url.startsWith("/api/export") && req.method === "GET") {
|
|
374
722
|
const arch = currentArchitecture || (await loadArchitecture());
|
|
@@ -379,12 +727,21 @@ function createHttpServer() {
|
|
|
379
727
|
}
|
|
380
728
|
const params = new URL(url, `http://localhost:${config.port}`).searchParams;
|
|
381
729
|
const format = params.get("format") || "mermaid";
|
|
382
|
-
const supported = ["mermaid", "markdown", "json", "plantuml", "dot"];
|
|
730
|
+
const supported = ["mermaid", "markdown", "json", "plantuml", "dot", "html"];
|
|
383
731
|
if (!supported.includes(format)) {
|
|
384
732
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
385
733
|
res.end(JSON.stringify({ error: `Unknown format: ${format}. Supported: ${supported.join(", ")}` }));
|
|
386
734
|
return;
|
|
387
735
|
}
|
|
736
|
+
// HTML export requires Pro tier
|
|
737
|
+
if (format === "html") {
|
|
738
|
+
const license = loadLicenseInfo();
|
|
739
|
+
if (license.tier !== "premium") {
|
|
740
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
741
|
+
res.end(JSON.stringify({ error: "HTML export requires a Pro subscription." }));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
388
745
|
const realNodes = arch.nodes;
|
|
389
746
|
const nodeMap = new Map();
|
|
390
747
|
for (const nd of realNodes)
|
|
@@ -515,6 +872,43 @@ function createHttpServer() {
|
|
|
515
872
|
ext = "json";
|
|
516
873
|
break;
|
|
517
874
|
}
|
|
875
|
+
case "html": {
|
|
876
|
+
// Build self-contained interactive HTML file
|
|
877
|
+
const uiAssetsDir = path.join(UI_DIST, "assets");
|
|
878
|
+
if (!existsSync(uiAssetsDir)) {
|
|
879
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
880
|
+
res.end(JSON.stringify({ error: "UI build not found. Run: npm run build:ui" }));
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const { readdirSync } = await import("fs");
|
|
884
|
+
const uiAssetFiles = readdirSync(uiAssetsDir);
|
|
885
|
+
let cssInline = "";
|
|
886
|
+
for (const f of uiAssetFiles.filter((f) => f.endsWith(".css"))) {
|
|
887
|
+
cssInline += readFileSync(path.join(uiAssetsDir, f), "utf-8") + "\n";
|
|
888
|
+
}
|
|
889
|
+
let jsInline = "";
|
|
890
|
+
for (const f of uiAssetFiles.filter((f) => f.endsWith(".js"))) {
|
|
891
|
+
jsInline += readFileSync(path.join(uiAssetsDir, f), "utf-8") + "\n";
|
|
892
|
+
}
|
|
893
|
+
content = `<!DOCTYPE html>
|
|
894
|
+
<html lang="en" data-theme="dark">
|
|
895
|
+
<head>
|
|
896
|
+
<meta charset="UTF-8">
|
|
897
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
898
|
+
<title>${config.name} - ArchByte Architecture</title>
|
|
899
|
+
<style>${cssInline}</style>
|
|
900
|
+
</head>
|
|
901
|
+
<body>
|
|
902
|
+
<script>try{if(localStorage.getItem('archbyte-theme')==='light')document.documentElement.setAttribute('data-theme','light')}catch(e){}</script>
|
|
903
|
+
<div id="root"></div>
|
|
904
|
+
<script>window.__ARCHBYTE_DATA__ = ${JSON.stringify(arch)};</script>
|
|
905
|
+
<script type="module">${jsInline}</script>
|
|
906
|
+
</body>
|
|
907
|
+
</html>`;
|
|
908
|
+
contentType = "text/html";
|
|
909
|
+
ext = "html";
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
518
912
|
default: { // markdown
|
|
519
913
|
const lines = [`# Architecture — ${config.name}`, "", `> Generated by ArchByte on ${new Date().toISOString().slice(0, 10)}`, ""];
|
|
520
914
|
lines.push("## Summary", "");
|
|
@@ -774,6 +1168,35 @@ function createHttpServer() {
|
|
|
774
1168
|
res.end(JSON.stringify({ running: runningWorkflows.has("__generate__") }));
|
|
775
1169
|
return;
|
|
776
1170
|
}
|
|
1171
|
+
// API: Run analyze (static or LLM) + generate
|
|
1172
|
+
if (url === "/api/analyze" && req.method === "POST") {
|
|
1173
|
+
if (isAnalyzing) {
|
|
1174
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
1175
|
+
res.end(JSON.stringify({ error: "Analysis already running" }));
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
let body = "";
|
|
1179
|
+
req.on("data", (chunk) => { body += chunk.toString(); });
|
|
1180
|
+
req.on("end", () => {
|
|
1181
|
+
const { mode } = JSON.parse(body || "{}");
|
|
1182
|
+
runAnalyzePipeline(mode === "llm" ? "llm" : "static");
|
|
1183
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1184
|
+
res.end(JSON.stringify({ ok: true, mode: mode || "static" }));
|
|
1185
|
+
});
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
// API: Analyze status
|
|
1189
|
+
if (url === "/api/analyze/status" && req.method === "GET") {
|
|
1190
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1191
|
+
res.end(JSON.stringify({ running: isAnalyzing }));
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
// API: Session changes feed — returns in-memory session change records (newest first)
|
|
1195
|
+
if (url === "/api/session-changes" && req.method === "GET") {
|
|
1196
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1197
|
+
res.end(JSON.stringify(sessionChanges));
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
777
1200
|
// API: Run workflow
|
|
778
1201
|
if (url.startsWith("/api/workflow/run/") && req.method === "POST") {
|
|
779
1202
|
const id = url.split("/").pop();
|
|
@@ -858,13 +1281,13 @@ function createHttpServer() {
|
|
|
858
1281
|
req.on("end", () => {
|
|
859
1282
|
const { interval } = JSON.parse(body || "{}");
|
|
860
1283
|
const bin = getArchbyteBin();
|
|
861
|
-
const args = [bin, "patrol"
|
|
1284
|
+
const args = [bin, "patrol"];
|
|
862
1285
|
if (interval)
|
|
863
1286
|
args.push("--interval", String(interval));
|
|
864
1287
|
patrolProcess = spawn(process.execPath, args, {
|
|
865
1288
|
cwd: config.workspaceRoot,
|
|
866
|
-
stdio: ["ignore", "
|
|
867
|
-
env: { ...process.env, FORCE_COLOR: "
|
|
1289
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
1290
|
+
env: { ...process.env, FORCE_COLOR: "1" },
|
|
868
1291
|
});
|
|
869
1292
|
patrolRunning = true;
|
|
870
1293
|
broadcastOpsEvent({ type: "patrol:started" });
|
|
@@ -1271,12 +1694,135 @@ async function startHttpServer() {
|
|
|
1271
1694
|
});
|
|
1272
1695
|
});
|
|
1273
1696
|
}
|
|
1274
|
-
//
|
|
1275
|
-
function
|
|
1276
|
-
|
|
1697
|
+
// Diff two architecture snapshots (lightweight, inline version of cli/diff.ts)
|
|
1698
|
+
function diffArchitectures(prev, curr) {
|
|
1699
|
+
const prevNodeMap = new Map();
|
|
1700
|
+
for (const n of prev.nodes)
|
|
1701
|
+
prevNodeMap.set(n.id, n);
|
|
1702
|
+
const currNodeMap = new Map();
|
|
1703
|
+
for (const n of curr.nodes)
|
|
1704
|
+
currNodeMap.set(n.id, n);
|
|
1705
|
+
const prevNodeIds = new Set(prev.nodes.map((n) => n.id));
|
|
1706
|
+
const currNodeIds = new Set(curr.nodes.map((n) => n.id));
|
|
1707
|
+
const addedNodes = curr.nodes
|
|
1708
|
+
.filter((n) => !prevNodeIds.has(n.id))
|
|
1709
|
+
.map((n) => ({ id: n.id, label: n.label, type: n.type, layer: n.layer }));
|
|
1710
|
+
const removedNodes = prev.nodes
|
|
1711
|
+
.filter((n) => !currNodeIds.has(n.id))
|
|
1712
|
+
.map((n) => ({ id: n.id, label: n.label, type: n.type, layer: n.layer }));
|
|
1713
|
+
const modifiedNodes = [];
|
|
1714
|
+
for (const n of curr.nodes) {
|
|
1715
|
+
const old = prevNodeMap.get(n.id);
|
|
1716
|
+
if (!old)
|
|
1717
|
+
continue;
|
|
1718
|
+
for (const field of ["label", "type", "layer"]) {
|
|
1719
|
+
if (old[field] !== n[field]) {
|
|
1720
|
+
modifiedNodes.push({ id: n.id, field, from: old[field], to: n[field] });
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
const edgeKey = (e) => `${e.source}->${e.target}`;
|
|
1725
|
+
const prevEdgeKeys = new Set(prev.edges.map(edgeKey));
|
|
1726
|
+
const currEdgeKeys = new Set(curr.edges.map(edgeKey));
|
|
1727
|
+
const addedEdges = curr.edges
|
|
1728
|
+
.filter((e) => !prevEdgeKeys.has(edgeKey(e)))
|
|
1729
|
+
.map((e) => ({ source: e.source, target: e.target, label: e.label }));
|
|
1730
|
+
const removedEdges = prev.edges
|
|
1731
|
+
.filter((e) => !currEdgeKeys.has(edgeKey(e)))
|
|
1732
|
+
.map((e) => ({ source: e.source, target: e.target, label: e.label }));
|
|
1733
|
+
return { addedNodes, removedNodes, modifiedNodes, addedEdges, removedEdges };
|
|
1734
|
+
}
|
|
1735
|
+
// Run analyze → generate pipeline (used by /api/analyze and patrol)
|
|
1736
|
+
function runAnalyzePipeline(mode = "static", fileChanges) {
|
|
1737
|
+
if (isAnalyzing)
|
|
1277
1738
|
return;
|
|
1278
|
-
|
|
1279
|
-
|
|
1739
|
+
isAnalyzing = true;
|
|
1740
|
+
const pipelineStart = Date.now();
|
|
1741
|
+
// Snapshot current architecture for diffing after pipeline completes
|
|
1742
|
+
const prevArchitecture = currentArchitecture
|
|
1743
|
+
? { ...currentArchitecture, nodes: [...currentArchitecture.nodes], edges: [...currentArchitecture.edges] }
|
|
1744
|
+
: null;
|
|
1745
|
+
broadcastOpsEvent({ type: "analyzing:started", mode });
|
|
1746
|
+
const bin = getArchbyteBin();
|
|
1747
|
+
const analyzeArgs = [bin, "analyze", "--force"];
|
|
1748
|
+
if (mode === "static")
|
|
1749
|
+
analyzeArgs.push("--static");
|
|
1750
|
+
const analyzeChild = spawn(process.execPath, analyzeArgs, {
|
|
1751
|
+
cwd: config.workspaceRoot,
|
|
1752
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1753
|
+
env: { ...process.env, FORCE_COLOR: "0" },
|
|
1754
|
+
});
|
|
1755
|
+
analyzeChild.on("close", (analyzeCode) => {
|
|
1756
|
+
if (analyzeCode !== 0) {
|
|
1757
|
+
isAnalyzing = false;
|
|
1758
|
+
broadcastOpsEvent({ type: "analyzing:finished", code: analyzeCode, success: false });
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
// Chain: generate after successful analyze
|
|
1762
|
+
const genChild = spawn(process.execPath, [bin, "generate"], {
|
|
1763
|
+
cwd: config.workspaceRoot,
|
|
1764
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1765
|
+
env: { ...process.env, FORCE_COLOR: "0" },
|
|
1766
|
+
});
|
|
1767
|
+
genChild.on("close", async (genCode) => {
|
|
1768
|
+
isAnalyzing = false;
|
|
1769
|
+
const durationMs = Date.now() - pipelineStart;
|
|
1770
|
+
// Build session change record if we have a previous architecture to diff against
|
|
1771
|
+
if (genCode === 0) {
|
|
1772
|
+
try {
|
|
1773
|
+
const newArch = await loadArchitecture();
|
|
1774
|
+
if (newArch && prevArchitecture) {
|
|
1775
|
+
const diff = diffArchitectures(prevArchitecture, newArch);
|
|
1776
|
+
// Deduplicate file changes: keep latest event per path
|
|
1777
|
+
const dedupedFiles = new Map();
|
|
1778
|
+
for (const fc of (fileChanges || [])) {
|
|
1779
|
+
dedupedFiles.set(fc.path, fc);
|
|
1780
|
+
}
|
|
1781
|
+
const filesChanged = [...dedupedFiles.values()];
|
|
1782
|
+
const record = {
|
|
1783
|
+
id: `sc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1784
|
+
timestamp: new Date().toISOString(),
|
|
1785
|
+
filesChanged,
|
|
1786
|
+
diff,
|
|
1787
|
+
summary: {
|
|
1788
|
+
filesChanged: filesChanged.length,
|
|
1789
|
+
nodesAdded: diff.addedNodes.length,
|
|
1790
|
+
nodesRemoved: diff.removedNodes.length,
|
|
1791
|
+
nodesModified: diff.modifiedNodes.length,
|
|
1792
|
+
edgesAdded: diff.addedEdges.length,
|
|
1793
|
+
edgesRemoved: diff.removedEdges.length,
|
|
1794
|
+
},
|
|
1795
|
+
durationMs,
|
|
1796
|
+
mode,
|
|
1797
|
+
};
|
|
1798
|
+
sessionChanges.unshift(record);
|
|
1799
|
+
if (sessionChanges.length > MAX_SESSION_CHANGES)
|
|
1800
|
+
sessionChanges.pop();
|
|
1801
|
+
broadcastOpsEvent({ type: "session:change", change: record });
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
catch {
|
|
1805
|
+
// Non-fatal — session change tracking is best-effort
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
broadcastOpsEvent({ type: "analyzing:finished", code: genCode, success: genCode === 0 });
|
|
1809
|
+
});
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
// Setup file watcher — watches the .archbyte/ directory so it works
|
|
1813
|
+
// even when architecture.json doesn't exist yet (first-time users)
|
|
1814
|
+
function setupWatcher() {
|
|
1815
|
+
const archbyteDir = path.dirname(config.diagramPath);
|
|
1816
|
+
const diagramFilename = path.basename(config.diagramPath);
|
|
1817
|
+
diagramWatcher = watch(archbyteDir, {
|
|
1818
|
+
ignoreInitial: true,
|
|
1819
|
+
depth: 0,
|
|
1820
|
+
});
|
|
1821
|
+
diagramWatcher.on("all", async (event, filePath) => {
|
|
1822
|
+
if (path.basename(filePath) !== diagramFilename)
|
|
1823
|
+
return;
|
|
1824
|
+
if (event !== "change" && event !== "add")
|
|
1825
|
+
return;
|
|
1280
1826
|
console.error("[archbyte] Diagram changed, reloading...");
|
|
1281
1827
|
currentArchitecture = await loadArchitecture();
|
|
1282
1828
|
broadcastUpdate();
|
|
@@ -1323,7 +1869,8 @@ function setupShutdown() {
|
|
|
1323
1869
|
process.on("SIGTERM", shutdown);
|
|
1324
1870
|
process.on("SIGINT", shutdown);
|
|
1325
1871
|
}
|
|
1326
|
-
// License info helper — reads
|
|
1872
|
+
// License info helper — reads login state from credentials.json,
|
|
1873
|
+
// tier from server-verified tier-cache.json (1-hour TTL, email-stamped)
|
|
1327
1874
|
function loadLicenseInfo() {
|
|
1328
1875
|
const defaults = {
|
|
1329
1876
|
loggedIn: false,
|
|
@@ -1343,14 +1890,41 @@ function loadLicenseInfo() {
|
|
|
1343
1890
|
const credPath = path.join(home, ".archbyte", "credentials.json");
|
|
1344
1891
|
if (!existsSync(credPath))
|
|
1345
1892
|
return defaults;
|
|
1346
|
-
const
|
|
1893
|
+
const raw = JSON.parse(readFileSync(credPath, "utf-8"));
|
|
1894
|
+
// Multi-account format (version 2): extract active account
|
|
1895
|
+
let creds;
|
|
1896
|
+
if (raw?.version === 2 && raw.accounts && typeof raw.active === "string") {
|
|
1897
|
+
creds = raw.accounts[raw.active] ?? {};
|
|
1898
|
+
}
|
|
1899
|
+
else {
|
|
1900
|
+
creds = raw;
|
|
1901
|
+
}
|
|
1347
1902
|
if (!creds.token)
|
|
1348
1903
|
return defaults;
|
|
1349
1904
|
// Check expiry
|
|
1350
1905
|
if (creds.expiresAt && new Date(creds.expiresAt) < new Date()) {
|
|
1351
1906
|
return { ...defaults, loggedIn: true, email: creds.email ?? null };
|
|
1352
1907
|
}
|
|
1353
|
-
|
|
1908
|
+
// Read tier from server-verified tier-cache.json (not user-editable credentials)
|
|
1909
|
+
let isPremium = false;
|
|
1910
|
+
const tierCachePath = path.join(home, ".archbyte", "tier-cache.json");
|
|
1911
|
+
if (existsSync(tierCachePath)) {
|
|
1912
|
+
try {
|
|
1913
|
+
const cache = JSON.parse(readFileSync(tierCachePath, "utf-8"));
|
|
1914
|
+
// Only trust cache if it matches current account and is within 1-hour TTL
|
|
1915
|
+
const cacheAge = cache.verifiedAt ? Date.now() - new Date(cache.verifiedAt).getTime() : Infinity;
|
|
1916
|
+
if (cache.email === creds.email && cacheAge < 60 * 60 * 1000) {
|
|
1917
|
+
isPremium = cache.tier === "premium";
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
catch {
|
|
1921
|
+
// Corrupt cache — fall through to credentials fallback
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
// Fallback: if no valid tier cache, use credentials tier (less secure but functional)
|
|
1925
|
+
if (!isPremium && creds.tier === "premium") {
|
|
1926
|
+
isPremium = true;
|
|
1927
|
+
}
|
|
1354
1928
|
return {
|
|
1355
1929
|
loggedIn: true,
|
|
1356
1930
|
email: creds.email ?? null,
|