diff --git a/README.md b/README.md
index a90272e..f5e133d 100644
--- a/README.md
+++ b/README.md
@@ -174,8 +174,9 @@ The NestJS adapter currently detects:
- SQL migrations, columns, indexes, keys, constraints, and foreign-key relationships;
- ClickHouse tables, materialized views, engines, partition/order keys, and TTL rules;
- NestJS cron/interval/timeout jobs, repeatable queue jobs, and Kubernetes CronJobs;
-- GitHub Actions and GitLab CI workflows, jobs, dependencies, images, and deploy commands;
-- Dockerfiles, Compose services, and Kubernetes workloads, containers, probes, services, ingress, ConfigMaps, and Secret names;
+- GitHub Actions and GitLab CI workflows, reusable workflows, actions, jobs, dependencies, images, and deploy commands;
+- Dockerfiles and Compose services, health checks, dependencies, networks, volumes, configs, and Secret names;
+- Kubernetes workloads, init containers, probes, services, ingress, autoscaling, volumes, ConfigMaps, Secret names, Kustomize overlays, Helm metadata, and Argo CD applications;
- environment variable contracts and safe examples, while redacting secret-like values;
- external HTTP API hosts;
- unit test relationships;
@@ -254,6 +255,19 @@ request and asynchronous flows, complete data catalog and focused table ERD,
migrations, scheduled jobs, source files, risks, deployment, runtime topology,
environment comparison, and configuration contracts.
+The **System Map** combines business domains, asynchronous runtime, data and external
+systems with a visible Delivery & Runtime band. Its grouped domain anchors keep every
+detected aggregate arrow in the scene, and hovering a component isolates its direct
+system connections. Build/deploy, structure, events, data and external calls use
+different line grammar instead of one generic edge style.
+
+**Path A -> B** answers a direct architecture question without opening a giant
+neighbourhood graph. Select any starting element, select a target, and Atlas renders
+only the exact detected chain between them. Directed mode follows the real execution
+or dependency direction; Any connection can cross an incoming relationship while
+keeping every arrowhead truthful. The Overview also ranks architecture hubs by their
+actual graph degree so highly influential elements are visible immediately.
+
Operations are deliberately separated. **Deployment** follows CI/CD jobs, Docker
build stages, images, and releases. **Runtime** follows ingress, services, workloads,
containers, ConfigMaps, and Secret names. Both switch independently between
@@ -307,6 +321,7 @@ The server exposes these tools:
- `atlas_get_node`
- `atlas_get_dependencies`
- `atlas_get_dependents`
+- `atlas_find_path`
- `atlas_find_routes`
- `atlas_find_flow`
- `atlas_find_async_flows`
@@ -348,11 +363,11 @@ npm run typecheck
```
The tests scan a representative NestJS fixture, validate route-to-database,
-publisher-to-consumer, DI-token, CQRS, runtime, migration, schedule, and delivery flows, exercise all 18 MCP
+publisher-to-consumer, DI-token, CQRS, runtime, migration, schedule, path, and delivery flows, exercise all 19 MCP
tools, verify architecture and deployment risks, and confirm that real secret values
never enter generated artifacts. The performance suite generates 1,000 TypeScript
files, 100 controllers, 300 services, and 1,000 routes; it also checks warm-scan
-reuse, indexed graph queries, viewport culling, and bounded animation.
+reuse, indexed graph and path queries, viewport culling, and bounded animation.
The detailed MVP requirements and their automated evidence are listed in
[`docs/PRD-COMPLIANCE.md`](docs/PRD-COMPLIANCE.md).
diff --git a/assets/viewer/index.template.html b/assets/viewer/index.template.html
index 3b849ab..ab89176 100644
--- a/assets/viewer/index.template.html
+++ b/assets/viewer/index.template.html
@@ -153,6 +153,9 @@
{{ traceLabel }}
+
+ {{ pathLabel }}
+
←
@@ -221,6 +224,17 @@
Architecture risks
+
+ Architecture hubs
+ Highly connected elements that influence many flows.
+
+
+
+ {{ hub.label }} {{ hub.type }}
+ {{ hub.connections }} links
+
+
+
@@ -525,6 +539,19 @@ Architecture risks
+
+
+
+
From: {{ pathFromLabel }}
+
→
+
To: {{ pathToLabel }}
+
+ {{ pb.label }}
+
+
{{ pathInstruction }}
+
+
+
direct call
@@ -752,6 +779,7 @@
(flow.steps || []).forEach((step) => {
+ const list = this._flowsByNode.get(step.id) || [];
+ list.push({ id, flow, kind }); this._flowsByNode.set(step.id, list);
+ });
+ Object.entries(D.flows || {}).forEach(([id, flow]) => indexFlow(id, flow, 'route'));
+ Object.entries(D.asyncFlows || {}).forEach(([id, flow]) => indexFlow(id, flow, 'async'));
this._sceneCache = new Map();
}
node(id) {
@@ -827,6 +862,7 @@ {
const hist = s.sel && s.sel !== id ? s.hist.concat(s.sel) : s.hist;
+ if (s.pathActive && s.pathPicking) {
+ return s.pathPicking === 'from'
+ ? { sel: id, hist, pathFrom: id, pathTo: null, pathPicking: 'to', tech: false, trace: false, traceRoot: null, vb: null, hover: null, searchOpen: false, panelHidden: false }
+ : { sel: id, hist, pathTo: id, pathPicking: null, tech: false, trace: false, traceRoot: null, vb: null, hover: null, searchOpen: false, panelHidden: false };
+ }
return { sel: id, hist, tab: opts.tab || 'overview', tech: false, trace: false, traceRoot: null, vb: null, hover: null,
mode: opts.mode || s.mode, activeFlow: opts.flow !== undefined ? opts.flow : s.activeFlow,
expandedOp: opts.expandedOp !== undefined ? opts.expandedOp : null, searchOpen: false,
@@ -933,7 +974,7 @@ ${this.state.hover.edgeTo || ''}`)
+ : '';
const cacheKey = keys.map((key) => `${key}:${String(this.state[key] ?? '')}`).join('|')
+ + `|mapHover:${mapHover}`
+ `|labels:${String(this.props.showEdgeLabels ?? true)}|variant:${String(this.props.mapVariant ?? '')}`;
const cached = this._sceneCache.get(cacheKey);
if (cached) return cached;
@@ -1075,7 +1120,8 @@ (b.count || 0) - (a.count || 0))
.forEach((edge) => { addRuntime(edge.from); addRuntime(edge.to); });
keyIds(runtimeTypes, 15).forEach(addRuntime);
+ const operationsTypes = ['workflow', 'pipeline_job', 'build_stage', 'container_image', 'deployment', 'container', 'infrastructure_service', 'ingress', 'environment'];
+ const operationsTotal = D.nodes.filter((n) => operationsTypes.includes(n.type)).length;
+ const operations = keyIds(operationsTypes, 15);
const dataIds = D.nodes.filter((n) => n.type === 'database').map((n) => n.id);
const dataTotal = dataIds.length || D.nodes.filter((n) => ['table', 'entity', 'model'].includes(n.type)).length;
const visibleDataIds = (dataIds.length ? dataIds : keyIds(['table', 'entity', 'model'], 5)).slice(0, 5);
@@ -1235,6 +1284,21 @@ {
+ if (edge.from === focusEndpoint || edge.to === focusEndpoint) { connected.add(edge.from); connected.add(edge.to); }
+ });
[...sceneEdges.values()].sort((a, b) => (a.kind === 'async' ? 1 : 0) - (b.kind === 'async' ? 1 : 0)).forEach((e) => {
const a = pos[e.from], b = pos[e.to];
if (!a || !b) return;
const count = e.count || 1;
const labelled = { ...e, verb: count > 1 ? `${e.verb || e.kind} ×${count}` : (e.verb || e.kind) };
- E.push(this.linkEdge(a, b, labelled, false, false, showLabels));
+ const related = !focusEndpoint || e.from === focusEndpoint || e.to === focusEndpoint;
+ E.push(this.linkEdge(a, b, labelled, !!focusEndpoint && related, !!focusEndpoint && !related, showLabels));
+ });
+ if (focusEndpoint) N.forEach((node) => {
+ const source = this.node(node.id);
+ const endpoint = source && exactTypes.has(source.type) ? source.id : (source?.domain ? `d.${source.domain}` : node.id);
+ node.op = connected.has(endpoint) ? 1 : 0.22;
});
const aggNote = lights.length ? (expanded ? `all ${D.domains.length} areas shown` : `${detailed.length} areas in detail · ${lights.length} aggregated (${lightModCount} modules — click to expand)`) : `all ${D.domains.length} areas shown`;
- return { nodes: N, edges: E, groups: G, cols: C, status: `${aggNote} · ${E.length} aggregated directional connections · ${runtime.length} of ${runtimeTotal} runtime components · ${visibleDataIds.length} of ${dataTotal} data stores · ${extIds.length} of ${extTotal} external systems. Full catalogs stay available in the sidebar.` };
+ return { nodes: N, edges: E, groups: G, cols: C, edgeEndpointIds: Object.keys(pos), status: `${aggNote} · ${E.length} aggregated directional connections · ${operations.length} of ${operationsTotal} delivery/runtime components · ${runtime.length} of ${runtimeTotal} async components · ${visibleDataIds.length} of ${dataTotal} data stores · ${extIds.length} of ${extTotal} external systems. Hover a component to isolate its direct system connections.` };
}
sceneModuleGrid(domainFilter) {
@@ -1737,8 +1821,9 @@ {
+ const relation = edge.relation || String(edge.verb || '').replaceAll(' ', '_');
+ if (['handles', 'calls', 'reads', 'writes', 'publishes_to', 'delivers_to', 'enqueues', 'processes', 'targets', 'exposes', 'deploys'].includes(relation)) return 0;
+ if (['injects', 'implements', 'uses', 'connects_to', 'configures', 'builds', 'publishes', 'triggers', 'schedules'].includes(relation)) return 1;
+ if (['depends_on', 'references', 'imports', 'exports', 'provides'].includes(relation)) return 2;
+ return 3;
+ };
+ const queue = [{ id: fromId, depth: 0 }], visited = new Set([fromId]), previous = new Map();
+ let cursor = 0, found = false, safety = false;
+ while (cursor < queue.length && !found) {
+ const current = queue[cursor++];
+ if (current.depth >= 24) continue;
+ const candidates = [
+ ...this.outgoingEdges(current.id).map((edge) => ({ edge, next: edge.to })),
+ ...(both ? this.incomingEdges(current.id).map((edge) => ({ edge, next: edge.from })) : []),
+ ].sort((a, b) => priority(a.edge) - priority(b.edge) || `${a.edge.from}>${a.edge.to}`.localeCompare(`${b.edge.from}>${b.edge.to}`));
+ for (const candidate of candidates) {
+ if (visited.has(candidate.next)) continue;
+ if (visited.size >= 30000) { safety = true; break; }
+ visited.add(candidate.next); previous.set(candidate.next, { previous: current.id, edge: candidate.edge });
+ if (candidate.next === toId) { found = true; break; }
+ queue.push({ id: candidate.next, depth: current.depth + 1 });
+ }
+ }
+ if (!found) return { nodes: [], edges: [], groups: [], cols: [], status: safety ? 'Path search reached the 30,000 element safety limit. Narrow the endpoints.' : `No ${both ? 'connected' : 'directed'} path was detected from ${from.label} to ${to.label}.`, emptyOk: true };
+ const ids = [toId], links = []; let current = toId;
+ while (current !== fromId) {
+ const step = previous.get(current); if (!step) break;
+ ids.push(step.previous); links.push(step.edge); current = step.previous;
+ }
+ ids.reverse(); links.reverse();
+ const N = [], E = [], C = [], pos = {};
+ ids.forEach((id, index) => {
+ const node = this.node(id); if (!node) return;
+ const x = 40 + index * 300, box = { x, y: 100, w: 250, h: 58 }; pos[id] = box;
+ C.push({ x, y: 46, x2: x + 250, ly: 56, label: index === 0 ? 'START' : index === ids.length - 1 ? 'TARGET' : `STEP ${index + 1}`, sub: this.tLabel(node.type), subY: 69, fill: index === 0 || index === ids.length - 1 ? '#df642d' : '#8b9793' });
+ N.push(this.mkNode(node, box.x, box.y, box.w, box.h, { step: index + 1, sub: node.file || node.desc || '', over: index === 0 || index === ids.length - 1 ? { fill: '#fdf3ec', stroke: '#df642d', sw: 2 } : {} }));
+ });
+ links.forEach((edge) => {
+ if (!pos[edge.from] || !pos[edge.to]) return;
+ E.push(this.linkEdge(pos[edge.from], pos[edge.to], edge, true, false, true));
+ });
+ const inferred = links.filter((edge) => (edge.confidence ?? 1) < 0.9 || edge.source === 'heuristic').length;
+ return { nodes: N, edges: E, groups: [], cols: C, status: `${links.length} relationship step${links.length === 1 ? '' : 's'} from ${from.label} to ${to.label}.${both ? ' Arrowheads preserve the real relationship direction.' : ''}${inferred ? ` ${inferred} inferred link${inferred === 1 ? '' : 's'} included.` : ''}` };
+ }
+
sceneContext(sel, moduleMode = false) {
const { D } = this.state;
const showLabels = this.props.showEdgeLabels ?? true;
@@ -2209,10 +2349,11 @@ types.has(node.type) && (!node.details?.environment || node.details.environment === environment));
+ const types = new Set(['ingress', 'infrastructure_service', 'deployment', 'container', 'config_map', 'secret', 'config']);
+ const nodes = D.nodes.filter((node) => types.has(node.type) && (!node.details?.environment || node.details.environment === environment) && (node.type !== 'config' || !!node.details?.environment));
const columns = [
{ label: 'PUBLIC ENTRY', sub: 'traffic entering the environment', types: ['ingress'] },
{ label: 'ROUTING', sub: 'services and internal addresses', types: ['infrastructure_service'] },
{ label: 'WORKLOADS', sub: 'deployed applications', types: ['deployment'] },
{ label: 'CONTAINERS', sub: 'running process boundaries', types: ['container'] },
- { label: 'CONFIGURATION', sub: 'ConfigMaps and secret names', types: ['config_map', 'secret'] },
+ { label: 'CONFIGURATION', sub: 'settings, networks, volumes and secret names', types: ['config_map', 'secret', 'config'] },
];
const N = [], E = [], C = [], pos = {};
columns.forEach((column, col) => {
@@ -2286,6 +2427,7 @@ ['workflow', 'pipeline_job', 'build_stage', 'container_image', 'deployment'].includes(node.type)),
members.filter((node) => ['ingress', 'infrastructure_service', 'container'].includes(node.type)),
- members.filter((node) => ['config_map', 'secret', 'env'].includes(node.type)),
+ members.filter((node) => ['config_map', 'secret', 'env', 'config'].includes(node.type)),
];
const y = 78 + row * 94;
const envBox = { x: 40, y, w: 260, h: 62 }; pos[envNode.id] = envBox;
@@ -2414,6 +2556,7 @@ e.to === sel);
- const out = D.edges.filter((e) => e.from === sel);
+ const inc = this.incomingEdges(sel);
+ const out = this.outgoingEdges(sel);
let srcKey = D.sources[sel] ? sel : D.fileSource[sel];
if (!srcKey && selNode.file) {
const owner = Object.keys(D.sources).find((k) => D.sources[k].file === selNode.file);
if (owner) srcKey = owner;
}
const src = srcKey ? D.sources[srcKey] : null;
- const flowsOf = [];
- Object.entries(D.flows).forEach(([fid, f]) => { if (f.steps.some((s) => s.id === sel)) flowsOf.push({ label: f.title.split(' — ')[0], desc: f.summary, go: () => this.select(f.root, { mode: 'routes', flow: fid }) }); });
- Object.entries(D.asyncFlows).forEach(([fid, f]) => { if (f.steps.some((s) => s.id === sel)) flowsOf.push({ label: f.title.split(' — ')[0], desc: f.summary, go: () => this.select(fid, { mode: 'async', flow: fid }) }); });
+ const flowsOf = this.flowsForNode(sel).map(({ id: fid, flow: f, kind }) => ({ label: f.title.split(' — ')[0], desc: f.summary, go: () => this.select(kind === 'route' ? f.root : fid, { mode: kind === 'route' ? 'routes' : 'async', flow: fid }) }));
const tabs = [{ id: 'overview', label: 'Overview' }];
if (inc.length) tabs.push({ id: 'incoming', label: `Used by · ${inc.length}` });
@@ -2662,11 +2803,11 @@ this.select(D.flows[flowForSel] ? (D.flows[flowForSel].root) : sel, { mode: D.flows[flowForSel] ? 'routes' : 'async', flow: flowForSel }) });
- if (!sel.startsWith('op.')) actions.push({ label: 'Trace dependencies', bg: '#fff', fg: '#1f6f5b', border: '#9fbeb3', go: () => this.setState({ trace: true, traceRoot: sel, traceDepth: '3', tech: false, vb: null, panelHidden: false }) });
+ if (!sel.startsWith('op.')) {
+ actions.push({ label: 'Trace dependencies', bg: '#fff', fg: '#1f6f5b', border: '#9fbeb3', go: () => this.setState({ trace: true, traceRoot: sel, traceDepth: '3', tech: false, pathActive: false, pathFrom: null, pathTo: null, pathPicking: null, vb: null, panelHidden: false }) });
+ actions.push({ label: 'Find path from here', bg: '#fff', fg: '#315d72', border: '#a9bec8', go: () => this.setState({ pathActive: true, pathFrom: sel, pathTo: null, pathPicking: 'to', trace: false, traceRoot: null, tech: false, vb: null, panelHidden: false }) });
+ }
if (src) actions.push({ label: 'View source', bg: '#fff', fg: '#333e3a', border: '#d4dbd7', go: () => this.setState({ tab: 'source' }) });
if (selNode.file && selNode.type !== 'file') {
const fn = D.nodes.find((x) => x.type === 'file' && x.file === selNode.file);
@@ -2732,7 +2876,12 @@ ({ id: `detected:${name}`, name, list })) });
- const degree = (id) => D.edges.filter((edge) => edge.from === id || edge.to === id).length;
+ const degree = (id) => this.nodeDegree(id);
const tableCard = (table) => {
- const columns = D.edges.filter((edge) => edge.from === table.id && edge.verb === 'has column').length;
- const indexes = D.edges.filter((edge) => edge.to === table.id && edge.verb === 'indexes').length;
- const refs = D.edges.filter((edge) => (edge.from === table.id || edge.to === table.id) && edge.verb === 'references' && edge.details?.associationOnly !== true && this.node(edge.from)?.type === 'table' && this.node(edge.to)?.type === 'table').length;
+ const directOut = this.outgoingEdges(table.id), directIn = this.incomingEdges(table.id);
+ const columns = directOut.filter((edge) => edge.verb === 'has column').length;
+ const indexes = directIn.filter((edge) => edge.verb === 'indexes').length;
+ const refs = [...directIn, ...directOut].filter((edge) => edge.verb === 'references' && edge.details?.associationOnly !== true && this.node(edge.from)?.type === 'table' && this.node(edge.to)?.type === 'table').length;
return { label: table.label, desc: table.desc || this.typeExplain(table.type), hot: degree(table.id) >= 5, counts: `${columns || '?'} cols · ${indexes} idx · ${refs} relations`, tip: `${table.label}: ${columns || 'unknown'} columns, ${indexes} indexes, ${refs} relationships`, go: () => this.reveal(table.id) };
};
const visibleDbs = databaseGroups.filter(({ database }) => dbFilter === 'all' || database.id === dbFilter).map(({ database, schemaGroups }) => {
@@ -2814,7 +2964,7 @@ node.type === 'env').sort((a, b) => a.label.localeCompare(b.label));
const grouped = new Map(); variables.forEach((node) => grouped.set(node.domain || 'Project', [...(grouped.get(node.domain || 'Project') || []), node]));
configCatalog = { cfgGroups: [...grouped.entries()].map(([name, list]) => ({ name, count: `${list.length} variables`, vars: list.map((node) => {
- const consumers = D.edges.filter((edge) => edge.to === node.id || edge.from === node.id).map((edge) => this.node(edge.from === node.id ? edge.to : edge.from)).filter(Boolean);
+ const consumers = [...this.incomingEdges(node.id), ...this.outgoingEdges(node.id)].map((edge) => this.node(edge.from === node.id ? edge.to : edge.from)).filter(Boolean);
const secret = node.details?.sensitive === true, required = node.details?.required === true;
return { name: node.label, secret, reqLabel: required ? 'required' : 'optional', reqFg: required ? '#a34517' : '#4e5a56', reqBg: required ? '#fdeee5' : '#eef1ef', purpose: node.details?.purpose || node.desc || 'Runtime configuration setting.', usedBy: consumers.length ? `used by ${consumers.slice(0, 4).map((item) => item.label).join(', ')}` : node.file || 'usage not linked', environment: String(node.details?.environment || 'all detected environments'), value: secret ? 'value hidden' : String(node.details?.exampleValue || node.details?.example || 'no safe example'), rowOp: '1', go: () => this.reveal(node.id) };
}) })) };
@@ -2892,15 +3042,15 @@ ({ label, tip, pressed: this.state[key] ? 'true' : 'false', go: () => this.setState((s) => ({ [key]: !s[key], vb: null })), bg: this.state[key] ? color : '#fff', fg: this.state[key] ? '#fff' : '#4e5a56', border: this.state[key] ? color : '#d4dbd7' })),
traceDepthBtns: [['3', '3', 'Show three relationship steps.'], ['6', '6', 'Show six relationship steps.'], ['12', '12', 'Show twelve relationship steps.'], ['all', 'All', 'Follow every reachable branch until the graph ends or the browser safety limit is reached.']].map(([id, label, tip]) => ({ label, tip, go: () => this.setState({ traceDepth: id, vb: null }), bg: String(this.state.traceDepth) === id ? '#16211d' : '#fff', fg: String(this.state.traceDepth) === id ? '#fff' : '#4e5a56', fw: String(this.state.traceDepth) === id ? 700 : 500 })),
+ showPathToggle: !listScreen && mode !== 'overview',
+ togglePath: () => this.setState((state) => state.pathActive
+ ? { pathActive: false, pathFrom: null, pathTo: null, pathPicking: null, vb: null }
+ : { pathActive: true, pathFrom: state.sel || null, pathTo: null, pathPicking: state.sel ? 'to' : 'from', tech: false, trace: false, traceRoot: null, panelHidden: false, vb: null }),
+ pathPressed: this.state.pathActive ? 'true' : 'false', pathLabel: this.state.pathActive ? 'Close path' : 'Path A → B',
+ pathBg: this.state.pathActive ? '#16211d' : '#fff', pathFg: this.state.pathActive ? '#fff' : '#4e5a56', pathBorder: this.state.pathActive ? '#16211d' : '#d4dbd7',
+ pathActive: !!this.state.pathActive,
+ pathFromLabel: this.state.pathFrom ? (this.node(this.state.pathFrom)?.label || this.state.pathFrom) : 'click an element or use search',
+ pathToLabel: this.state.pathTo ? (this.node(this.state.pathTo)?.label || this.state.pathTo) : 'click an element or use search',
+ pathStartBorder: this.state.pathPicking === 'from' ? '#df642d' : '#d4dbd7', pathStartBg: this.state.pathPicking === 'from' ? '#fdf3ec' : '#fff',
+ pathTargetBorder: this.state.pathPicking === 'to' ? '#df642d' : '#d4dbd7', pathTargetBg: this.state.pathPicking === 'to' ? '#fdf3ec' : '#fff',
+ pickPathStart: () => this.setState({ pathFrom: null, pathTo: null, pathPicking: 'from', vb: null }),
+ pickPathTarget: () => this.setState((state) => ({ pathTo: null, pathPicking: state.pathFrom ? 'to' : 'from', vb: null })),
+ pathInstruction: this.state.pathPicking === 'from' ? 'Choose the starting element.' : this.state.pathPicking === 'to' ? 'Choose the target element.' : this.state.pathFrom && this.state.pathTo ? 'Exact path found.' : 'Choose both endpoints.',
+ pathDirectionBtns: [['outgoing', 'Directed', 'Follow only the real outgoing direction from start to target.'], ['both', 'Any connection', 'Allow traversal in either direction while preserving real arrowheads.']].map(([id, label, tip]) => ({ label, tip, go: () => this.setState({ pathDirection: id, vb: null }), bg: this.state.pathDirection === id ? '#16211d' : '#fff', fg: this.state.pathDirection === id ? '#fff' : '#4e5a56', fw: this.state.pathDirection === id ? 700 : 500 })),
goBack: () => this.setState((s) => { const h = s.hist.slice(); const prev = h.pop(); return prev ? { sel: prev, hist: h, vb: null, tech: false, trace: false, traceRoot: null, expandedOp: null } : {}; }),
backDisabled: !hist.length, backOpacity: hist.length ? '1' : '0.4',
doFit: () => this.setState({ vb: null }),
@@ -2931,10 +3096,10 @@ String(step.uses ?? "")).filter(Boolean);
+ addNode({ id: jobId, type: "pipeline_job", label: jobName, name: jobName, file: file.path, framework: gitlab ? "gitlab-ci" : "github-actions", source: "config", confidence: 1, metadata: {
+ stage: job.stage, runner: job["runs-on"], environment, needs: job.needs, condition: job.if,
+ timeoutMinutes: job["timeout-minutes"], strategy: job.strategy, permissions: job.permissions,
+ actions, serviceCount: Object.keys(asDict(job.services) ?? {}).length,
+ } });
addEdge(workflowId, jobId, "contains");
if (environment) addEdge(jobId, `environment:${environment}`, "runs_in");
for (const dependency of stringList(job.needs)) addEdge(jobId, `pipeline_job:${cleanId(file.path)}:${cleanId(dependency)}`, "depends_on");
- const steps = Array.isArray(job.steps) ? job.steps.map(asDict).filter(Boolean) as Dict[] : [];
+ if (typeof job.uses === "string") {
+ const target = workflowReference(file.path, job.uses);
+ addNode({ id: target.id, type: "workflow", label: target.label, name: target.label, file: target.file, framework: "github-actions", source: "config", confidence: target.local ? 1 : 0.9, metadata: { reusable: true, reference: job.uses } });
+ addEdge(jobId, target.id, "uses", { reusableWorkflow: true });
+ }
+ for (const action of actions) {
+ const actionId = `config:action:${cleanId(action)}`;
+ addNode({ id: actionId, type: "config", label: action, name: action, file: file.path, framework: "github-action", source: "config", confidence: 1, metadata: { kind: "action" } });
+ addEdge(jobId, actionId, "uses");
+ }
const script = [...steps.map((step) => `${step.uses ?? ""}\n${step.run ?? ""}`), ...stringList(job.script)].join("\n");
parseDeliveryCommands(jobId, script, file.path, environment, addNode, addEdge);
}
@@ -430,10 +445,35 @@ function parseCompose(file: ScannedFile, content: string, addNode: AddNode, addE
const services = asDict(doc?.services); if (!services) return;
const environment = environmentFromPath(file.path) ?? "local";
addEnvironment(environment, file.path, addNode);
+ const composeConfigs = asDict(doc?.configs) ?? {};
+ const composeSecrets = asDict(doc?.secrets) ?? {};
+ const composeNetworks = asDict(doc?.networks) ?? {};
+ const composeVolumes = asDict(doc?.volumes) ?? {};
+ for (const [name, value] of Object.entries(composeConfigs)) {
+ const id = composeResourceId("config_map", file.path, name, environment);
+ addNode({ id, type: "config_map", label: name, name, file: file.path, framework: "docker-compose", source: "config", confidence: 1, metadata: { environment, kind: "compose-config", external: asDict(value)?.external === true, valuesStored: false } });
+ addEdge(id, `environment:${environment}`, "runs_in");
+ }
+ for (const [name, value] of Object.entries(composeSecrets)) {
+ const id = composeResourceId("secret", file.path, name, environment);
+ addNode({ id, type: "secret", label: name, name, file: file.path, framework: "docker-compose", source: "config", confidence: 1, metadata: { environment, kind: "compose-secret", external: asDict(value)?.external === true, valuesStored: false } });
+ addEdge(id, `environment:${environment}`, "runs_in");
+ }
+ for (const [kind, resources] of [["network", composeNetworks], ["volume", composeVolumes]] as const) {
+ for (const [name, value] of Object.entries(resources)) {
+ const id = `config:compose-${kind}:${cleanId(file.path)}:${cleanId(name)}`;
+ addNode({ id, type: "config", label: name, name, file: file.path, framework: "docker-compose", source: "config", confidence: 1, metadata: { environment, kind, external: asDict(value)?.external === true } });
+ addEdge(id, `environment:${environment}`, "runs_in");
+ }
+ }
for (const [name, rawService] of Object.entries(services)) {
const service = asDict(rawService); if (!service) continue;
const id = `container:${cleanId(file.path)}:${cleanId(name)}`;
- addNode({ id, type: "container", label: name, name, file: file.path, framework: "docker-compose", source: "config", confidence: 1, metadata: { image: service.image, build: service.build, ports: service.ports, volumes: service.volumes, environment } });
+ addNode({ id, type: "container", label: name, name, file: file.path, framework: "docker-compose", source: "config", confidence: 1, metadata: {
+ image: service.image, build: service.build, command: service.command, ports: service.ports,
+ volumes: service.volumes, environment, healthcheck: service.healthcheck, restart: service.restart,
+ profiles: service.profiles, networkMode: service.network_mode,
+ } });
addEdge(`file:${file.path}`, id, "declares");
addEdge(id, `environment:${environment}`, "runs_in");
if (typeof service.image === "string") {
@@ -442,6 +482,18 @@ function parseCompose(file: ScannedFile, content: string, addNode: AddNode, addE
addEdge(id, imageId, "uses");
}
for (const dependency of stringList(service.depends_on)) addEdge(id, `container:${cleanId(file.path)}:${cleanId(dependency)}`, "depends_on");
+ for (const name of stringList(service.networks)) addEdge(id, `config:compose-network:${cleanId(file.path)}:${cleanId(name)}`, "connects_to");
+ for (const item of stringList(service.volumes)) {
+ const name = item.split(":")[0];
+ if (name && !/^[./~]/.test(name) && Object.hasOwn(composeVolumes, name)) addEdge(id, `config:compose-volume:${cleanId(file.path)}:${cleanId(name)}`, "uses");
+ }
+ for (const name of composeReferenceNames(service.configs)) addEdge(id, composeResourceId("config_map", file.path, name, environment), "configures");
+ for (const name of composeReferenceNames(service.secrets)) addEdge(id, composeResourceId("secret", file.path, name, environment), "configures");
+ for (const envFile of stringList(service.env_file)) {
+ const envId = `config:env-file:${cleanId(file.path)}:${cleanId(envFile)}`;
+ addNode({ id: envId, type: "config", label: envFile, name: envFile, file: file.path, framework: "docker-compose", source: "config", confidence: 1, metadata: { environment, kind: "env-file", valuesStored: false } });
+ addEdge(id, envId, "configures");
+ }
for (const name of environmentNames(service.environment)) {
const envId = `environment_variable:${name}`;
addNode({ id: envId, type: "environment_variable", label: name, name, file: file.path, source: "config", confidence: 1, metadata: { valueStored: false, environment } });
@@ -451,7 +503,8 @@ function parseCompose(file: ScannedFile, content: string, addNode: AddNode, addE
}
function parseKubernetes(file: ScannedFile, content: string, addNode: AddNode, addEdge: AddEdge, warnings: string[]) {
- for (const value of parseYaml(content, file.path, warnings)) {
+ const documents = parseYaml(content, file.path, warnings);
+ for (const value of documents) {
const doc = asDict(value); if (!doc || typeof doc.kind !== "string") continue;
const metadata = asDict(doc.metadata) ?? {};
const name = String(metadata.name ?? basename(file.path));
@@ -482,8 +535,24 @@ function parseKubernetes(file: ScannedFile, content: string, addNode: AddNode, a
addNode({ id, type: secret ? "secret" : "config_map", label: name, name, file: file.path, framework: "kubernetes", source: "config", confidence: 1, metadata: { environment, keys: Object.keys(data), valuesStored: false } });
addEdge(`file:${file.path}`, id, "declares");
if (environment) addEdge(id, `environment:${environment}`, "runs_in");
+ } else if (kind === "Kustomization") {
+ parseKustomization(file, doc, environment, addNode, addEdge);
+ } else if (kind === "HorizontalPodAutoscaler") {
+ const spec = asDict(doc.spec) ?? {};
+ const target = asDict(spec.scaleTargetRef) ?? {};
+ const id = `config:kubernetes-hpa:${cleanId(name)}:${environment ?? "default"}`;
+ addNode({ id, type: "config", label: name, name, file: file.path, framework: "kubernetes", source: "config", confidence: 1, metadata: { kind, environment, minReplicas: spec.minReplicas, maxReplicas: spec.maxReplicas, metrics: spec.metrics } });
+ if (environment) addEdge(id, `environment:${environment}`, "runs_in");
+ if (typeof target.name === "string") addEdge(id, `deployment:${cleanId(target.name)}:${environment ?? "default"}`, "configures");
+ } else if (["NetworkPolicy", "PersistentVolumeClaim", "ServiceAccount"].includes(kind)) {
+ const id = `config:kubernetes-${cleanId(kind)}:${cleanId(name)}:${environment ?? "default"}`;
+ addNode({ id, type: "config", label: name, name, file: file.path, framework: "kubernetes", source: "config", confidence: 1, metadata: { kind, environment } });
+ if (environment) addEdge(id, `environment:${environment}`, "runs_in");
+ } else if (kind === "Application" && /argoproj\.io/i.test(String(doc.apiVersion ?? ""))) {
+ parseArgoApplication(file, name, doc, environment, addNode, addEdge);
}
}
+ if (!documents.some((value) => typeof asDict(value)?.kind === "string")) parseHelmMetadata(file, documents, addNode, addEdge);
}
function parseWorkload(file: ScannedFile, kind: string, name: string, doc: Dict, environment: string | null, addNode: AddNode, addEdge: AddEdge) {
@@ -492,13 +561,21 @@ function parseWorkload(file: ScannedFile, kind: string, name: string, doc: Dict,
const template = asDict(spec.template) ?? {};
const podSpec = asDict(template.spec) ?? {};
const containers = Array.isArray(podSpec.containers) ? podSpec.containers.map(asDict).filter(Boolean) as Dict[] : [];
- addNode({ id, type: "deployment", label: name, name, file: file.path, framework: "kubernetes", source: "config", confidence: 1, metadata: { kind, environment, replicas: spec.replicas, strategy: spec.strategy, labels: asDict(asDict(template.metadata)?.labels), containerCount: containers.length } });
+ const initContainers = Array.isArray(podSpec.initContainers) ? podSpec.initContainers.map(asDict).filter(Boolean) as Dict[] : [];
+ addNode({ id, type: "deployment", label: name, name, file: file.path, framework: "kubernetes", source: "config", confidence: 1, metadata: {
+ kind, environment, replicas: spec.replicas, strategy: spec.strategy ?? spec.updateStrategy,
+ labels: asDict(asDict(template.metadata)?.labels), containerCount: containers.length,
+ initContainerCount: initContainers.length, serviceAccountName: podSpec.serviceAccountName,
+ nodeSelector: podSpec.nodeSelector, affinity: podSpec.affinity, tolerations: podSpec.tolerations,
+ } });
addEdge(`file:${file.path}`, id, "declares");
if (environment) addEdge(id, `environment:${environment}`, "runs_in");
- for (const raw of containers) {
+ const volumes = kubernetesVolumes(podSpec, environment);
+ const runtimeContainers: Dict[] = [...initContainers.map((item) => ({ ...item, atlasInit: true })), ...containers];
+ for (const raw of runtimeContainers) {
const containerName = String(raw.name ?? "container");
const containerId = `container:${cleanId(name)}:${cleanId(containerName)}:${environment ?? "default"}`;
- addNode({ id: containerId, type: "container", label: containerName, name: containerName, file: file.path, framework: "kubernetes", source: "config", confidence: 1, metadata: { image: raw.image, ports: raw.ports, resources: raw.resources, readinessProbe: raw.readinessProbe, livenessProbe: raw.livenessProbe, environment } });
+ addNode({ id: containerId, type: "container", label: containerName, name: containerName, file: file.path, framework: "kubernetes", source: "config", confidence: 1, metadata: { image: raw.image, command: raw.command, args: raw.args, ports: raw.ports, resources: raw.resources, readinessProbe: raw.readinessProbe, livenessProbe: raw.livenessProbe, startupProbe: raw.startupProbe, securityContext: raw.securityContext, volumeMounts: raw.volumeMounts, initContainer: raw.atlasInit === true, environment } });
addEdge(id, containerId, "contains");
if (typeof raw.image === "string") {
const imageId = `container_image:${cleanId(raw.image)}`;
@@ -509,6 +586,10 @@ function parseWorkload(file: ScannedFile, kind: string, name: string, doc: Dict,
const targetId = `${reference.secret ? "secret" : "config_map"}:${cleanId(reference.name)}:${environment ?? "default"}`;
addEdge(containerId, targetId, "configures", { optional: reference.optional });
}
+ for (const mount of Array.isArray(raw.volumeMounts) ? raw.volumeMounts.map(asDict).filter(Boolean) as Dict[] : []) {
+ const volume = volumes.get(String(mount.name ?? ""));
+ if (volume) addEdge(containerId, volume.id, volume.secret || volume.configMap ? "configures" : "uses", { mountPath: mount.mountPath, readOnly: mount.readOnly });
+ }
}
}
@@ -586,6 +667,93 @@ function normalizeEnvironment(value: string): string | null {
return environmentAliases[clean] ?? (/(prod)/.test(clean) ? "production" : /(stag|stage)/.test(clean) ? "staging" : /(dev)/.test(clean) ? "development" : clean.replace(/[^a-z0-9_-]+/g, "-"));
}
+function workflowReference(sourceFile: string, reference: string): { id: string; label: string; file: string; local: boolean } {
+ const local = reference.startsWith("./");
+ const file = local ? reference.replace(/^\.\//, "") : sourceFile;
+ const label = local ? basename(file) : reference;
+ return { id: `workflow:${cleanId(local ? file : reference)}`, label, file, local };
+}
+
+function composeResourceId(type: "config_map" | "secret", file: string, name: string, environment: string): string {
+ return `${type}:compose:${cleanId(file)}:${cleanId(name)}:${environment}`;
+}
+
+function composeReferenceNames(value: unknown): string[] {
+ if (!Array.isArray(value)) return stringList(value);
+ return value.flatMap((item) => {
+ if (typeof item === "string") return [item];
+ const record = asDict(item);
+ return typeof record?.source === "string" ? [record.source] : [];
+ });
+}
+
+function parseKustomization(file: ScannedFile, doc: Dict, environment: string | null, addNode: AddNode, addEdge: AddEdge) {
+ const metadata = asDict(doc.metadata) ?? {};
+ const name = String(metadata.name ?? basename(file.path));
+ const id = `config:kustomization:${cleanId(file.path)}`;
+ const resources = stringList(doc.resources);
+ const patches = [...stringList(doc.patches), ...stringList(doc.patchesStrategicMerge)];
+ const images = Array.isArray(doc.images) ? doc.images.map(asDict).filter(Boolean) as Dict[] : [];
+ addNode({ id, type: "config", label: name, name, file: file.path, framework: "kustomize", source: "config", confidence: 1, metadata: { kind: "Kustomization", environment, namespace: doc.namespace, resources, patches, imageCount: images.length } });
+ if (environment) addEdge(id, `environment:${environment}`, "runs_in");
+ for (const image of images) {
+ const value = String(image.newName ?? image.name ?? "") + (image.newTag ? `:${String(image.newTag)}` : "");
+ if (!value) continue;
+ const imageId = `container_image:${cleanId(value)}`;
+ addNode({ id: imageId, type: "container_image", label: value, name: value, file: file.path, source: "config", confidence: 1, metadata: { environment, overriddenBy: "kustomize" } });
+ addEdge(id, imageId, "uses");
+ }
+}
+
+function parseArgoApplication(file: ScannedFile, name: string, doc: Dict, fallbackEnvironment: string | null, addNode: AddNode, addEdge: AddEdge) {
+ const spec = asDict(doc.spec) ?? {};
+ const destination = asDict(spec.destination) ?? {};
+ const source = asDict(spec.source) ?? {};
+ const environment = normalizeEnvironment(String(destination.namespace ?? "")) ?? fallbackEnvironment;
+ if (environment) addEnvironment(environment, file.path, addNode);
+ const id = `deployment:argocd:${cleanId(name)}:${environment ?? "default"}`;
+ addNode({ id, type: "deployment", label: name, name, file: file.path, framework: "argocd", source: "config", confidence: 1, metadata: {
+ kind: "Argo CD Application", environment, repository: source.repoURL, revision: source.targetRevision,
+ path: source.path, chart: source.chart, destinationServer: destination.server, syncPolicy: spec.syncPolicy,
+ } });
+ if (environment) addEdge(id, `environment:${environment}`, "runs_in");
+}
+
+function parseHelmMetadata(file: ScannedFile, documents: unknown[], addNode: AddNode, addEdge: AddEdge) {
+ if (!/(^|\/)(helm|charts?)(\/|$)/i.test(file.path)) return;
+ const doc = asDict(documents[0]);
+ if (!doc) return;
+ const chart = /(^|\/)Chart\.ya?ml$/i.test(file.path);
+ const values = /(^|\/)values(?:\.[^.]+)?\.ya?ml$/i.test(file.path);
+ if (!chart && !values) return;
+ const environment = environmentFromPath(file.path);
+ if (environment) addEnvironment(environment, file.path, addNode);
+ const name = chart ? String(doc.name ?? basename(file.path)) : basename(file.path);
+ const id = `config:helm:${cleanId(file.path)}`;
+ addNode({ id, type: "config", label: name, name, file: file.path, framework: "helm", source: "config", confidence: 1, metadata: {
+ kind: chart ? "helm-chart" : "helm-values", environment, version: chart ? doc.version : undefined,
+ appVersion: chart ? doc.appVersion : undefined, keys: values ? Object.keys(doc) : undefined,
+ valuesStored: false,
+ } });
+ if (environment) addEdge(id, `environment:${environment}`, "runs_in");
+}
+
+function kubernetesVolumes(podSpec: Dict, environment: string | null): Map {
+ const result = new Map();
+ const volumes = Array.isArray(podSpec.volumes) ? podSpec.volumes.map(asDict).filter(Boolean) as Dict[] : [];
+ for (const volume of volumes) {
+ const name = String(volume.name ?? "");
+ if (!name) continue;
+ const secret = asDict(volume.secret);
+ const configMap = asDict(volume.configMap);
+ const claim = asDict(volume.persistentVolumeClaim);
+ if (typeof secret?.secretName === "string") result.set(name, { id: `secret:${cleanId(secret.secretName)}:${environment ?? "default"}`, secret: true, configMap: false });
+ else if (typeof configMap?.name === "string") result.set(name, { id: `config_map:${cleanId(configMap.name)}:${environment ?? "default"}`, secret: false, configMap: true });
+ else if (typeof claim?.claimName === "string") result.set(name, { id: `config:kubernetes-PersistentVolumeClaim:${cleanId(claim.claimName)}:${environment ?? "default"}`, secret: false, configMap: false });
+ }
+ return result;
+}
+
function extractSqlFragments(content: string): string[] {
const constants = new Map();
for (const match of content.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*([`'"])([^\r\n]*?)\2\s*;?/g)) {
diff --git a/src/core/descriptions.ts b/src/core/descriptions.ts
index 683352d..a5aa3c2 100644
--- a/src/core/descriptions.ts
+++ b/src/core/descriptions.ts
@@ -79,7 +79,7 @@ function isIntelligenceNode(type: GraphNodeType): boolean {
return [
"database", "schema", "table", "column", "index", "constraint", "migration", "materialized_view",
"scheduled_job", "workflow", "pipeline_job", "build_stage", "container_image", "container", "deployment",
- "infrastructure_service", "ingress", "config_map", "secret", "environment", "environment_variable",
+ "infrastructure_service", "ingress", "config_map", "secret", "environment", "environment_variable", "config",
].includes(type);
}
@@ -146,10 +146,13 @@ function describeIntelligenceNode(node: GraphNode, index: GraphIndex, plain: boo
: `Kubernetes ingress${file}${environment}.`;
case "config_map": return plain
? `Provides non-secret runtime settings${environment}; ${Array.isArray(details.keys) ? details.keys.length : 0} setting names were detected.`
- : `Kubernetes ConfigMap${file}${environment}; values are not stored by Atlas.`;
+ : `${node.framework === "docker-compose" ? "Docker Compose config" : "Kubernetes ConfigMap"}${file}${environment}; values are not stored by Atlas.`;
case "secret": return plain
? `Provides protected runtime settings${environment}; Atlas keeps names only and never stores their values.`
- : `Kubernetes Secret${file}${environment}; values are never stored by Atlas.`;
+ : `${node.framework === "docker-compose" ? "Docker Compose secret" : "Kubernetes Secret"}${file}${environment}; values are never stored by Atlas.`;
+ case "config": return plain
+ ? `Defines ${humanWords(String(details.kind ?? node.label)).toLowerCase()}${environment} and connects it to ${outgoing.length + incoming.length} detected architecture elements.`
+ : `${humanWords(String(details.kind ?? "configuration"))}${file}${environment}.`;
case "environment": return plain
? `Groups the delivery and runtime configuration for ${node.label}.`
: `Runtime environment${file}.`;
diff --git a/src/core/graph.ts b/src/core/graph.ts
index 9b11e23..a30a719 100644
--- a/src/core/graph.ts
+++ b/src/core/graph.ts
@@ -215,10 +215,56 @@ export class GraphQuery {
return this.walk(nodeId, "incoming", depth);
}
+ findPath(
+ fromId: string,
+ toId: string,
+ direction: "outgoing" | "both" = "outgoing",
+ maxDepth = 20,
+ ): GraphSubgraph {
+ if (!this.nodeMap.has(fromId) || !this.nodeMap.has(toId)) return { nodes: [], edges: [] };
+ if (fromId === toId) return { nodes: [this.nodeMap.get(fromId)!], edges: [] };
+ const queue: Array<{ id: string; depth: number }> = [{ id: fromId, depth: 0 }];
+ const visited = new Set([fromId]);
+ const previous = new Map();
+ let cursor = 0;
+ while (cursor < queue.length) {
+ const current = queue[cursor++];
+ if (current.depth >= Math.max(1, maxDepth)) continue;
+ const candidates = [
+ ...this.getOutgoing(current.id).map((edge) => ({ edge, next: edge.to })),
+ ...(direction === "both" ? this.getIncoming(current.id).map((edge) => ({ edge, next: edge.from })) : []),
+ ].sort((a, b) => pathEdgePriority(a.edge) - pathEdgePriority(b.edge) || a.edge.id.localeCompare(b.edge.id));
+ for (const candidate of candidates) {
+ if (visited.has(candidate.next)) continue;
+ visited.add(candidate.next);
+ previous.set(candidate.next, { nodeId: current.id, edge: candidate.edge });
+ if (candidate.next === toId) return this.reconstructPath(fromId, toId, previous);
+ queue.push({ id: candidate.next, depth: current.depth + 1 });
+ }
+ }
+ return { nodes: [], edges: [] };
+ }
+
private byType(type: GraphNode["type"]): GraphNode[] {
return this.nodesByType.get(type) ?? [];
}
+ private reconstructPath(fromId: string, toId: string, previous: Map): GraphSubgraph {
+ const nodeIds = [toId];
+ const edges: GraphEdge[] = [];
+ let current = toId;
+ while (current !== fromId) {
+ const step = previous.get(current);
+ if (!step) return { nodes: [], edges: [] };
+ nodeIds.push(step.nodeId);
+ edges.push(step.edge);
+ current = step.nodeId;
+ }
+ nodeIds.reverse();
+ edges.reverse();
+ return { nodes: nodeIds.map((id) => this.nodeMap.get(id)!), edges };
+ }
+
private walk(
startId: string,
direction: "incoming" | "outgoing",
@@ -252,6 +298,13 @@ export class GraphQuery {
}
}
+function pathEdgePriority(edge: GraphEdge): number {
+ if (["handles", "calls", "reads", "writes", "publishes_to", "delivers_to", "enqueues", "processes", "targets", "exposes", "deploys"].includes(edge.type)) return 0;
+ if (["injects", "implements", "uses", "connects_to", "configures", "builds", "publishes", "triggers", "schedules"].includes(edge.type)) return 1;
+ if (["depends_on", "references", "imports", "exports", "provides"].includes(edge.type)) return 2;
+ return 3;
+}
+
function searchableNode(node: GraphNode): string {
return [node.id, node.type, node.label, node.name, node.file, JSON.stringify(node.metadata ?? {})]
.filter(Boolean)
diff --git a/src/index.ts b/src/index.ts
index 7cf637e..769972e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -26,7 +26,7 @@ export { createNestRuntimeInterceptor, RuntimeTracer } from "./runtime/tracer.js
export type { RuntimeTracerOptions } from "./runtime/tracer.js";
export type { FileScanOptions, FileScanResult } from "./scanner/file-scanner.js";
-const ANALYSIS_CACHE_VERSION = 2;
+const ANALYSIS_CACHE_VERSION = 3;
export async function scanProject(options: ScanOptions): Promise {
const started = Date.now();
diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index dda84c5..e0388bf 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -41,6 +41,20 @@ export async function startMcpServer(projectPath: string, outputPath = ".atlas")
inputSchema: { id: z.string().min(1), depth: z.number().int().min(1).max(10).default(2) },
}, async ({ id, depth }) => result({ graph: query.findDependents(id, depth) }));
+ server.registerTool("atlas_find_path", {
+ description: "Find the shortest explainable architecture path between two exact node IDs.",
+ inputSchema: {
+ from: z.string().min(1),
+ to: z.string().min(1),
+ direction: z.enum(["outgoing", "both"]).default("outgoing"),
+ maxDepth: z.number().int().min(1).max(50).default(20),
+ },
+ }, async ({ from, to, direction, maxDepth }) => result({
+ from: query.getNode(from),
+ to: query.getNode(to),
+ path: query.findPath(from, to, direction, maxDepth),
+ }));
+
server.registerTool("atlas_find_routes", { description: "List all detected HTTP routes." }, async () => result({ routes: query.findRoutes() }));
server.registerTool("atlas_find_flow", {
diff --git a/src/version.ts b/src/version.ts
index edad07e..ff287db 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const ATLAS_VERSION = "0.4.0";
+export const ATLAS_VERSION = "0.4.1";
diff --git a/src/viewer/data.ts b/src/viewer/data.ts
index 43a69ad..86a05a4 100644
--- a/src/viewer/data.ts
+++ b/src/viewer/data.ts
@@ -319,12 +319,19 @@ function buildMapEdges(edges: ViewerEdge[], nodes: Map): Vie
});
}
const ranked = [...grouped.values()].sort((a, b) => (b.count ?? 0) - (a.count ?? 0));
- const structureEdges = ranked.filter((edge) => edge.kind === "sync").slice(0, 40);
- const dataEdges = ranked.filter((edge) => edge.kind === "data").slice(0, 30);
- const externalEdges = ranked.filter((edge) => edge.kind === "external").slice(0, 20);
- const asyncEdges = ranked.filter((edge) => edge.kind === "async").slice(0, 50);
+ const operationsTypes = new Set(["workflow", "pipeline_job", "build_stage", "container_image", "deployment", "container", "infrastructure_service", "ingress", "environment"]);
+ const operationsRelations = new Set(["builds", "publishes", "deploys", "releases", "targets", "exposes", "routes_to", "configures", "runs_in"]);
+ const isOperationsEdge = (edge: ViewerEdge) => operationsTypes.has(nodes.get(edge.from)?.type ?? "")
+ || operationsTypes.has(nodes.get(edge.to)?.type ?? "")
+ || operationsRelations.has(edge.relation ?? "");
+ const operationsEdges = ranked.filter(isOperationsEdge).slice(0, 40);
+ const architectureEdges = ranked.filter((edge) => !isOperationsEdge(edge));
+ const structureEdges = architectureEdges.filter((edge) => edge.kind === "sync").slice(0, 40);
+ const dataEdges = architectureEdges.filter((edge) => edge.kind === "data").slice(0, 30);
+ const externalEdges = architectureEdges.filter((edge) => edge.kind === "external").slice(0, 20);
+ const asyncEdges = architectureEdges.filter((edge) => edge.kind === "async").slice(0, 50);
// Separate budgets prevent large schemas from displacing async and external behavior.
- return [...structureEdges, ...dataEdges, ...externalEdges, ...asyncEdges];
+ return [...operationsEdges, ...structureEdges, ...dataEdges, ...externalEdges, ...asyncEdges];
}
function buildMapDataOwners(edges: ViewerEdge[], nodes: Map): Map {
@@ -350,6 +357,7 @@ function buildMapDataOwners(edges: ViewerEdge[], nodes: Map)
function mapEndpoint(node: ViewerNode, dataOwners: Map): string {
if (["topic", "queue", "processor", "broker"].includes(node.type)) return node.id;
+ if (["workflow", "pipeline_job", "build_stage", "container_image", "deployment", "container", "infrastructure_service", "ingress", "environment"].includes(node.type)) return node.id;
if (node.type === "database") return node.id;
if (["schema", "table", "entity", "model", "materialized_view"].includes(node.type)) return dataOwners.get(node.id) ?? node.id;
if (["external", "env"].includes(node.type)) return node.id;
diff --git a/tests/fixtures/nest-app/.github/workflows/delivery.yml b/tests/fixtures/nest-app/.github/workflows/delivery.yml
index 93c28a4..fc342fa 100644
--- a/tests/fixtures/nest-app/.github/workflows/delivery.yml
+++ b/tests/fixtures/nest-app/.github/workflows/delivery.yml
@@ -6,7 +6,10 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
+ - uses: actions/checkout@v4
- run: npm test
+ quality:
+ uses: ./.github/workflows/reusable-quality.yml
deploy-staging:
needs: test
runs-on: ubuntu-latest
@@ -23,4 +26,3 @@ jobs:
- run: docker build -t registry.example.test/atlas-api:production .
- run: docker push registry.example.test/atlas-api:production
- run: kubectl apply -f k8s/production
-
diff --git a/tests/fixtures/nest-app/.github/workflows/reusable-quality.yml b/tests/fixtures/nest-app/.github/workflows/reusable-quality.yml
new file mode 100644
index 0000000..2788ad3
--- /dev/null
+++ b/tests/fixtures/nest-app/.github/workflows/reusable-quality.yml
@@ -0,0 +1,10 @@
+name: Reusable quality checks
+on:
+ workflow_call:
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - run: npm run lint
diff --git a/tests/fixtures/nest-app/docker-compose.development.yml b/tests/fixtures/nest-app/docker-compose.development.yml
index 891cc84..16a3a76 100644
--- a/tests/fixtures/nest-app/docker-compose.development.yml
+++ b/tests/fixtures/nest-app/docker-compose.development.yml
@@ -1,6 +1,10 @@
services:
api:
build: .
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "node", "healthcheck.js"]
+ interval: 10s
ports:
- "3000:3000"
environment:
@@ -8,10 +12,25 @@ services:
DATABASE_URL: postgresql://postgres@database/app
depends_on:
- database
+ networks:
+ - backend
+ configs:
+ - api-settings
+ secrets:
+ - api-token
database:
image: postgres:17-alpine
volumes:
- pg-data:/var/lib/postgresql/data
+ networks:
+ - backend
volumes:
pg-data:
-
+networks:
+ backend:
+configs:
+ api-settings:
+ file: ./config/development.json
+secrets:
+ api-token:
+ file: ./secrets/api-token.txt
diff --git a/tests/fixtures/nest-app/helm/atlas/Chart.yaml b/tests/fixtures/nest-app/helm/atlas/Chart.yaml
new file mode 100644
index 0000000..1b1c789
--- /dev/null
+++ b/tests/fixtures/nest-app/helm/atlas/Chart.yaml
@@ -0,0 +1,4 @@
+apiVersion: v2
+name: atlas-api
+version: 1.0.0
+appVersion: "1.0.0"
diff --git a/tests/fixtures/nest-app/helm/atlas/values.production.yaml b/tests/fixtures/nest-app/helm/atlas/values.production.yaml
new file mode 100644
index 0000000..7447867
--- /dev/null
+++ b/tests/fixtures/nest-app/helm/atlas/values.production.yaml
@@ -0,0 +1,6 @@
+replicaCount: 3
+image:
+ repository: registry.example.test/atlas-api
+ tag: production
+secretNames:
+ - atlas-api-secrets
diff --git a/tests/fixtures/nest-app/k8s/production/app.yml b/tests/fixtures/nest-app/k8s/production/app.yml
index 0363c4e..95bd39e 100644
--- a/tests/fixtures/nest-app/k8s/production/app.yml
+++ b/tests/fixtures/nest-app/k8s/production/app.yml
@@ -13,6 +13,10 @@ spec:
labels:
app: atlas-api
spec:
+ initContainers:
+ - name: migrate
+ image: registry.example.test/atlas-api:production
+ command: ["npm", "run", "migrate"]
containers:
- name: api
image: registry.example.test/atlas-api:production
@@ -38,6 +42,14 @@ spec:
name: atlas-api
- secretRef:
name: atlas-api-secrets
+ volumeMounts:
+ - name: runtime-config
+ mountPath: /etc/atlas
+ readOnly: true
+ volumes:
+ - name: runtime-config
+ configMap:
+ name: atlas-api
---
apiVersion: v1
kind: Service
@@ -87,6 +99,19 @@ metadata:
stringData:
JWT_SECRET: another-value-that-must-never-be-stored
---
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: atlas-api
+ namespace: production
+spec:
+ minReplicas: 3
+ maxReplicas: 12
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: atlas-api
+---
apiVersion: batch/v1
kind: CronJob
metadata:
@@ -104,4 +129,3 @@ spec:
containers:
- name: cleanup
image: registry.example.test/atlas-api:production
-
diff --git a/tests/fixtures/nest-app/k8s/production/argocd.yml b/tests/fixtures/nest-app/k8s/production/argocd.yml
new file mode 100644
index 0000000..56280d1
--- /dev/null
+++ b/tests/fixtures/nest-app/k8s/production/argocd.yml
@@ -0,0 +1,16 @@
+apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+ name: atlas-api
+spec:
+ source:
+ repoURL: https://github.com/example/atlas-api.git
+ targetRevision: main
+ path: k8s/production
+ destination:
+ server: https://kubernetes.default.svc
+ namespace: production
+ syncPolicy:
+ automated:
+ prune: true
+ selfHeal: true
diff --git a/tests/fixtures/nest-app/k8s/production/kustomization.yml b/tests/fixtures/nest-app/k8s/production/kustomization.yml
new file mode 100644
index 0000000..1eeafa9
--- /dev/null
+++ b/tests/fixtures/nest-app/k8s/production/kustomization.yml
@@ -0,0 +1,8 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: production
+resources:
+ - app.yml
+images:
+ - name: registry.example.test/atlas-api
+ newTag: production
diff --git a/tests/graph.test.mjs b/tests/graph.test.mjs
index 286d565..1f22659 100644
--- a/tests/graph.test.mjs
+++ b/tests/graph.test.mjs
@@ -19,5 +19,8 @@ test("graph builder deduplicates edges and query traversal is bidirectional", ()
assert.deepEqual(new Set(query.getNeighbors("service:A", 1).nodes.map((node) => node.id)), new Set(["project:root", "service:A", "service:B"]));
assert.ok(query.findDependencies("service:A", 1).nodes.some((node) => node.id === "service:B"));
assert.ok(query.findDependents("service:A", 1).nodes.some((node) => node.id === "project:root"));
+ assert.deepEqual(query.findPath("project:root", "service:B").nodes.map((node) => node.id), ["project:root", "service:A", "service:B"]);
+ assert.deepEqual(query.findPath("service:B", "project:root", "both").nodes.map((node) => node.id), ["service:B", "service:A", "project:root"]);
+ assert.deepEqual(query.findPath("service:B", "project:root").nodes, []);
assert.equal(query.search("service:A")[0].score, 60);
});
diff --git a/tests/performance.test.mjs b/tests/performance.test.mjs
index 0cde264..6c032ed 100644
--- a/tests/performance.test.mjs
+++ b/tests/performance.test.mjs
@@ -125,4 +125,13 @@ test("serves repeated graph queries from indexes on a large graph", () => {
const duration = performance.now() - started;
assert.equal(relationships, edges.length * 2);
assert.ok(duration < 2000, `indexed graph queries took ${Math.round(duration)}ms`);
+
+ let target = 0;
+ for (let step = 0; step < 10; step += 1) target = (target * 17 + 1) % nodes.length;
+ const pathStarted = performance.now();
+ const path = query.findPath("service:Service0", `service:Service${target}`, "outgoing", 12);
+ const pathDuration = performance.now() - pathStarted;
+ assert.equal(path.nodes[0].id, "service:Service0");
+ assert.equal(path.nodes.at(-1).id, `service:Service${target}`);
+ assert.ok(pathDuration < 1000, `indexed path search took ${Math.round(pathDuration)}ms`);
});
diff --git a/tests/project.test.mjs b/tests/project.test.mjs
index 7ecc090..6e2c231 100644
--- a/tests/project.test.mjs
+++ b/tests/project.test.mjs
@@ -60,10 +60,18 @@ test("covers the complete NestJS MVP architecture surface", async () => {
"scheduled_job:kubernetes:cleanup-expired-sessions:production",
"container:cronjob:cleanup-expired-sessions:cleanup:production",
"workflow:.github:workflows:delivery.yml", "pipeline_job:.github:workflows:delivery.yml:deploy-staging",
+ "workflow:.github:workflows:reusable-quality.yml", "pipeline_job:.github:workflows:delivery.yml:quality",
+ "config:action:actions:checkout@v4",
"build_stage:Dockerfile:runtime", "container_image:registry.example.test:atlas-api:production",
"deployment:atlas-api:staging", "deployment:atlas-api:production",
"infrastructure_service:atlas-api:staging", "infrastructure_service:atlas-api:production",
"ingress:atlas-api:production", "config_map:atlas-api:production", "secret:atlas-api-secrets:production",
+ "container:atlas-api:migrate:production", "config:kubernetes-hpa:atlas-api:production",
+ "config:kustomization:k8s:production:kustomization.yml", "deployment:argocd:atlas-api:production",
+ "config:helm:helm:atlas:Chart.yaml", "config:helm:helm:atlas:values.production.yaml",
+ "config_map:compose:docker-compose.development.yml:api-settings:development",
+ "secret:compose:docker-compose.development.yml:api-token:development",
+ "config:compose-network:docker-compose.development.yml:backend",
"environment:development", "environment:staging", "environment:production",
];
for (const id of requiredNodes) assert.ok(query.getNode(id), `missing node ${id}`);
@@ -140,6 +148,12 @@ test("covers the complete NestJS MVP architecture surface", async () => {
assert.ok(result.graph.edges.some((edge) => edge.from === "infrastructure_service:atlas-api:production" && edge.to === "deployment:atlas-api:production" && edge.type === "targets"));
assert.ok(result.graph.edges.some((edge) => edge.from === "ingress:atlas-api:production" && edge.to === "infrastructure_service:atlas-api:production" && edge.type === "exposes"));
assert.ok(result.graph.edges.some((edge) => edge.from === "container:atlas-api:api:production" && edge.to === "secret:atlas-api-secrets:production" && edge.type === "configures"));
+ assert.ok(result.graph.edges.some((edge) => edge.from === "pipeline_job:.github:workflows:delivery.yml:quality" && edge.to === "workflow:.github:workflows:reusable-quality.yml" && edge.type === "uses"));
+ assert.ok(result.graph.edges.some((edge) => edge.from === "pipeline_job:.github:workflows:delivery.yml:test" && edge.to === "config:action:actions:checkout@v4" && edge.type === "uses"));
+ assert.ok(result.graph.edges.some((edge) => edge.from === "container:docker-compose.development.yml:api" && edge.to === "config_map:compose:docker-compose.development.yml:api-settings:development" && edge.type === "configures"));
+ assert.ok(result.graph.edges.some((edge) => edge.from === "container:docker-compose.development.yml:api" && edge.to === "config:compose-network:docker-compose.development.yml:backend" && edge.type === "connects_to"));
+ assert.ok(result.graph.edges.some((edge) => edge.from === "config:kubernetes-hpa:atlas-api:production" && edge.to === "deployment:atlas-api:production" && edge.type === "configures"));
+ assert.equal(query.getNode("container:atlas-api:migrate:production").metadata.initContainer, true);
const featureASettings = "module:SettingsModule@src/feature-a/settings.module.ts";
const featureBSettings = "module:SettingsModule@src/feature-b/settings.module.ts";
@@ -231,6 +245,7 @@ test("covers the complete NestJS MVP architecture surface", async () => {
assert.ok(viewerData.mapEdges.some((edge) => edge.kind === "async"));
assert.ok(viewerData.mapEdges.some((edge) => edge.kind === "external"));
assert.ok(viewerData.mapEdges.some((edge) => edge.kind === "data" && [edge.from, edge.to].some((id) => id.startsWith("database:"))));
+ assert.ok(viewerData.mapEdges.some((edge) => [edge.from, edge.to].some((id) => /^(workflow|pipeline_job|container_image|deployment|container|infrastructure_service|ingress|environment):/.test(id))), "system map must retain delivery and runtime endpoints");
assert.ok(viewerData.mapEdges.every((edge) => edge.relation !== "has_column"));
assert.ok(viewerData.edges.some((edge) => edge.relation === "reads" && edge.kind === "data"));
assert.ok(viewerData.edges.some((edge) => edge.relation === "writes" && edge.kind === "data"));
@@ -370,8 +385,8 @@ test("covers the complete NestJS MVP architecture surface", async () => {
await client.connect(transport);
try {
const tools = await client.listTools();
- assert.equal(tools.tools.length, 18);
- for (const name of ["atlas_find_node", "atlas_get_node", "atlas_get_dependencies", "atlas_get_dependents", "atlas_find_routes", "atlas_find_flow", "atlas_find_async_flows", "atlas_find_async_flow", "atlas_find_tables", "atlas_find_data_model", "atlas_get_table_profile", "atlas_find_migrations", "atlas_find_schedules", "atlas_find_delivery", "atlas_find_environments", "atlas_find_external_apis", "atlas_search", "atlas_project_summary"]) {
+ assert.equal(tools.tools.length, 19);
+ for (const name of ["atlas_find_node", "atlas_get_node", "atlas_get_dependencies", "atlas_get_dependents", "atlas_find_path", "atlas_find_routes", "atlas_find_flow", "atlas_find_async_flows", "atlas_find_async_flow", "atlas_find_tables", "atlas_find_data_model", "atlas_get_table_profile", "atlas_find_migrations", "atlas_find_schedules", "atlas_find_delivery", "atlas_find_environments", "atlas_find_external_apis", "atlas_search", "atlas_project_summary"]) {
assert.ok(tools.tools.some((tool) => tool.name === name), `missing MCP tool ${name}`);
}
const routes = await client.callTool({ name: "atlas_find_routes", arguments: {} });
@@ -390,6 +405,8 @@ test("covers the complete NestJS MVP architecture surface", async () => {
assert.match(JSON.stringify(nodeDetails), /method:UsersService\.create/);
const dependents = await client.callTool({ name: "atlas_get_dependents", arguments: { id: "service:UsersService", depth: 2 } });
assert.match(JSON.stringify(dependents), /controller:UsersController/);
+ const path = await client.callTool({ name: "atlas_find_path", arguments: { from: "route:POST:/api/users", to: "table:User" } });
+ assert.match(JSON.stringify(path), /table:User/);
const tables = await client.callTool({ name: "atlas_find_tables", arguments: {} });
assert.match(JSON.stringify(tables), /table:User/);
const externalApis = await client.callTool({ name: "atlas_find_external_apis", arguments: {} });
diff --git a/tests/viewer-interactions.test.mjs b/tests/viewer-interactions.test.mjs
index 8a12a16..b09fb14 100644
--- a/tests/viewer-interactions.test.mjs
+++ b/tests/viewer-interactions.test.mjs
@@ -91,6 +91,31 @@ test("trace controls progressively disclose depth and filter async work", async
assert.ok(!viewer.scene().nodes.some((node) => node.id === "message_topic:orders.created"));
});
+test("path explorer selects two endpoints and renders only their exact chain", async () => {
+ const viewer = await createViewer();
+ viewer.state = { ...viewer.state, mode: "routes", sel: "route:POST:/api/users" };
+
+ let values = viewer.renderVals();
+ assert.equal(values.pathLabel, "Path A → B");
+ values.togglePath();
+ assert.equal(viewer.state.pathFrom, "route:POST:/api/users");
+ assert.equal(viewer.state.pathPicking, "to");
+
+ viewer.select("table:User");
+ assert.equal(viewer.state.pathTo, "table:User");
+ assert.equal(viewer.state.pathPicking, null);
+ const scene = viewer.scene();
+ assert.equal(scene.nodes[0].id, "route:POST:/api/users");
+ assert.equal(scene.nodes.at(-1).id, "table:User");
+ assert.equal(scene.edges.length, scene.nodes.length - 1);
+ assert.ok(scene.nodes.every((node, index) => node.step === index + 1));
+ assert.match(scene.status, /relationship steps? from POST \/api\/users to User/);
+
+ values = viewer.renderVals();
+ values.pathDirectionBtns.find((button) => button.label === "Any connection").go();
+ assert.equal(viewer.state.pathDirection, "both");
+});
+
test("project search ranks exact endpoints above incidental file matches", async () => {
const viewer = await createViewer();
viewer.state = { ...viewer.state, q: "POST /api/users", searchOpen: true };
@@ -100,6 +125,17 @@ test("project search ranks exact endpoints above incidental file matches", async
assert.equal(results[0].type, "HTTP route");
});
+test("system map keeps group connections and exposes delivery and runtime", async () => {
+ const viewer = await createViewer();
+ viewer.state = { ...viewer.state, mode: "map", mapVariant: "clusters" };
+ const scene = viewer.sceneMap();
+ assert.ok(scene.groups.some((group) => /Delivery & runtime/.test(group.label)));
+ assert.ok(scene.nodes.some((node) => ["workflow", "pipeline_job", "container_image", "deployment", "container", "infrastructure_service", "ingress", "environment"].includes(viewer.node(node.id)?.type)));
+ assert.ok(scene.edgeEndpointIds.some((id) => id.startsWith("d.")), "domain group anchors must be retained");
+ const optimized = viewer.optimizeScene(scene, null);
+ assert.equal(optimized.edges.length, scene.edges.length, "level-of-detail must not remove edges connected to visible domain groups");
+});
+
test("focused context uses one methodology and recenters without duplicate cards", async () => {
const viewer = await createViewer();
const moduleNode = viewer.state.D.nodes.find((node) => node.type === "module"