archbyte 0.4.0 → 0.4.2
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/bin/archbyte.js +2 -20
- package/dist/agents/pipeline/merger.d.ts +2 -2
- package/dist/agents/pipeline/merger.js +152 -27
- package/dist/agents/pipeline/types.d.ts +29 -1
- package/dist/agents/pipeline/types.js +0 -1
- package/dist/agents/providers/claude-sdk.js +32 -8
- package/dist/agents/runtime/types.d.ts +4 -0
- package/dist/agents/runtime/types.js +2 -2
- 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 +62 -19
- package/dist/cli/arch-diff.d.ts +38 -0
- package/dist/cli/arch-diff.js +61 -0
- package/dist/cli/patrol.d.ts +5 -3
- package/dist/cli/patrol.js +417 -65
- package/dist/cli/setup.js +2 -7
- package/dist/cli/shared.d.ts +11 -0
- package/dist/cli/shared.js +61 -0
- package/dist/cli/validate.d.ts +0 -1
- package/dist/cli/validate.js +0 -16
- package/dist/server/src/index.js +537 -17
- package/package.json +1 -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-DmO1qYan.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());
|
|
@@ -820,6 +1168,35 @@ function createHttpServer() {
|
|
|
820
1168
|
res.end(JSON.stringify({ running: runningWorkflows.has("__generate__") }));
|
|
821
1169
|
return;
|
|
822
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
|
+
}
|
|
823
1200
|
// API: Run workflow
|
|
824
1201
|
if (url.startsWith("/api/workflow/run/") && req.method === "POST") {
|
|
825
1202
|
const id = url.split("/").pop();
|
|
@@ -904,13 +1281,13 @@ function createHttpServer() {
|
|
|
904
1281
|
req.on("end", () => {
|
|
905
1282
|
const { interval } = JSON.parse(body || "{}");
|
|
906
1283
|
const bin = getArchbyteBin();
|
|
907
|
-
const args = [bin, "patrol"
|
|
1284
|
+
const args = [bin, "patrol"];
|
|
908
1285
|
if (interval)
|
|
909
1286
|
args.push("--interval", String(interval));
|
|
910
1287
|
patrolProcess = spawn(process.execPath, args, {
|
|
911
1288
|
cwd: config.workspaceRoot,
|
|
912
|
-
stdio: ["ignore", "
|
|
913
|
-
env: { ...process.env, FORCE_COLOR: "
|
|
1289
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
1290
|
+
env: { ...process.env, FORCE_COLOR: "1" },
|
|
914
1291
|
});
|
|
915
1292
|
patrolRunning = true;
|
|
916
1293
|
broadcastOpsEvent({ type: "patrol:started" });
|
|
@@ -1317,12 +1694,135 @@ async function startHttpServer() {
|
|
|
1317
1694
|
});
|
|
1318
1695
|
});
|
|
1319
1696
|
}
|
|
1320
|
-
//
|
|
1321
|
-
function
|
|
1322
|
-
|
|
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)
|
|
1323
1738
|
return;
|
|
1324
|
-
|
|
1325
|
-
|
|
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;
|
|
1326
1826
|
console.error("[archbyte] Diagram changed, reloading...");
|
|
1327
1827
|
currentArchitecture = await loadArchitecture();
|
|
1328
1828
|
broadcastUpdate();
|
|
@@ -1369,7 +1869,8 @@ function setupShutdown() {
|
|
|
1369
1869
|
process.on("SIGTERM", shutdown);
|
|
1370
1870
|
process.on("SIGINT", shutdown);
|
|
1371
1871
|
}
|
|
1372
|
-
// 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)
|
|
1373
1874
|
function loadLicenseInfo() {
|
|
1374
1875
|
const defaults = {
|
|
1375
1876
|
loggedIn: false,
|
|
@@ -1404,7 +1905,26 @@ function loadLicenseInfo() {
|
|
|
1404
1905
|
if (creds.expiresAt && new Date(creds.expiresAt) < new Date()) {
|
|
1405
1906
|
return { ...defaults, loggedIn: true, email: creds.email ?? null };
|
|
1406
1907
|
}
|
|
1407
|
-
|
|
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
|
+
}
|
|
1408
1928
|
return {
|
|
1409
1929
|
loggedIn: true,
|
|
1410
1930
|
email: creds.email ?? null,
|
package/package.json
CHANGED
package/templates/archbyte.yaml
CHANGED
|
@@ -101,3 +101,11 @@ rules:
|
|
|
101
101
|
# from: { type: service, not: { id: api-gateway } }
|
|
102
102
|
# to: { type: external }
|
|
103
103
|
# level: warn
|
|
104
|
+
|
|
105
|
+
# ── Patrol ──
|
|
106
|
+
# Configure the continuous monitoring daemon (archbyte patrol).
|
|
107
|
+
# patrol:
|
|
108
|
+
# ignore:
|
|
109
|
+
# - "docs/"
|
|
110
|
+
# - "*.md"
|
|
111
|
+
# - "scripts/"
|