diff --git a/README.md b/README.md
index 23b2b6f..a90272e 100644
--- a/README.md
+++ b/README.md
@@ -251,9 +251,15 @@ names are confidential.
The viewer works without a cloud backend. Deterministic scenes cover the system map,
request and asynchronous flows, complete data catalog and focused table ERD,
-migrations, scheduled jobs, source files, risks, and Delivery & Runtime. Delivery
-switches independently between development, staging, and production, so unrelated
-environment topology is not mixed into one unreadable map.
+migrations, scheduled jobs, source files, risks, deployment, runtime topology,
+environment comparison, and configuration contracts.
+
+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
+development, staging, and production. **Environments** compares those scopes without
+mixing their complete topologies into one unreadable map. Secret values are never
+stored or displayed.
Large scenes use adaptive detail: off-screen elements are not rendered, distant
cards switch to a lightweight form, edge labels appear when useful, and animation
diff --git a/assets/viewer/index.template.html b/assets/viewer/index.template.html
index 3479df9..3b849ab 100644
--- a/assets/viewer/index.template.html
+++ b/assets/viewer/index.template.html
@@ -30,7 +30,7 @@
::-webkit-scrollbar-thumb { background: #c9d1cd; border-radius: 5px; border: 2px solid #f2f4f3; }
@keyframes atlasFade { from { opacity: 0; } to { opacity: 1; } }
@keyframes atlasDash { to { stroke-dashoffset: -20; } }
- .atlas-edge-async { animation: atlasDash 1.8s linear infinite; }
+ .atlas-edge-flow { animation: atlasDash 1.8s linear infinite; }
@media (prefers-reduced-motion: reduce) { .atlas-edge { animation: none !important; } }
.atlas-scroll-screen { scrollbar-gutter: stable; }
.atlas-table-row { transition: background 120ms ease, border-color 120ms ease; }
@@ -412,6 +412,10 @@
Architecture risks
+
+
+
+
@@ -450,7 +454,7 @@ Architecture risks
-
+
@@ -527,6 +531,9 @@ Architecture risks
event / queue
read / write
external
+ structure
+ build / deploy
+ configuration
@@ -914,7 +921,9 @@ `${key}: ${Array.isArray(value) ? value.join(', ') : value}`).join(' · ') : '';
@@ -1103,7 +1120,9 @@ (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;
- E.push(this.linkEdge(a, b, e, false, false, showLabels && variant !== 'clusters'));
+ 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 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} · ${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, 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.` };
}
sceneModuleGrid(domainFilter) {
const { D } = this.state;
- const N = [], G = [];
+ const N = [], E = [], G = [], pos = {};
const allDoms = domainFilter ? D.domains.filter((d) => d.id === domainFilter) : D.domains;
const doms = allDoms.slice(0, domainFilter ? 1 : 9);
doms.forEach((dm, i) => {
const perRow = domainFilter ? 1 : 3;
const col = i % perRow, row = Math.floor(i / perRow);
const allModuleIds = dm.allModules || dm.modules;
- const moduleIds = allModuleIds.slice(0, domainFilter ? 8 : 3);
+ const moduleIds = allModuleIds.slice(0, domainFilter ? 18 : 3);
const h = 42 + moduleIds.length * 54 + 6;
const x = 40 + col * 360, y = 40 + row * 260;
const grp = { x, y, w: 320, h, fill: '#fff', stroke: '#ccd5d1', label: dm.name, sub: dm.counts, footY: y + h - 32 };
G.push(grp);
+ pos['d.' + dm.id] = { x, y, w: 320, h };
moduleIds.forEach((mid, j) => {
const m = this.node(mid);
- N.push(this.mkNode(m, x + 10, y + 38 + j * 54, 300, 46, { sub: `${this.plural((m.metrics && m.metrics.routes) || 0, 'route')} · ${this.plural((m.metrics && m.metrics.services) || 0, 'service')} · ${this.plural((m.metrics && m.metrics.tests) || 0, 'test')}` }));
+ const box = { x: x + 10, y: y + 38 + j * 54, w: 300, h: 46 }; pos[mid] = box;
+ N.push(this.mkNode(m, box.x, box.y, box.w, box.h, { sub: `${this.plural((m.metrics && m.metrics.routes) || 0, 'route')} · ${this.plural((m.metrics && m.metrics.services) || 0, 'service')} · ${this.plural((m.metrics && m.metrics.tests) || 0, 'test')}` }));
});
});
+ if (domainFilter) {
+ const visible = new Set(N.map((node) => node.id));
+ D.edges.filter((edge) => (edge.relation === 'imports' || edge.verb === 'imports') && visible.has(edge.from) && visible.has(edge.to))
+ .forEach((edge) => E.push(this.linkEdge(pos[edge.from], pos[edge.to], edge, false, false, true)));
+ } else {
+ D.mapEdges.filter((edge) => (edge.relation === 'imports' || edge.verb === 'imports') && pos[edge.from] && pos[edge.to])
+ .forEach((edge) => E.push(this.linkEdge(pos[edge.from], pos[edge.to], { ...edge, verb: edge.count > 1 ? `imports ×${edge.count}` : 'imports' }, false, false, true)));
+ }
const shownModules = N.length;
const totalModules = allDoms.reduce((sum, dm) => sum + (dm.allModules || dm.modules).length, 0);
const scope = domainFilter ? `Area “${doms[0] ? doms[0].name : ''}”` : `${doms.length} key areas of ${D.domains.length}`;
- return { nodes: N, edges: [], groups: G, cols: [], status: `${scope} · ${shownModules} representative modules of ${totalModules}. Select any module in the catalog to see its full story.` };
+ return { nodes: N, edges: E, groups: G, cols: [], status: `${scope} · ${shownModules} representative modules of ${totalModules} · ${E.length} directional import relationships. Select a module to see its incoming dependencies, outgoing dependencies and owned components.` };
}
// Generic semantic profile for modules without a curated one — built from the graph.
@@ -2191,9 +2222,9 @@ seed.has(node.id));
const columns = [
- { label: 'CI / CD', sub: 'workflows and jobs', types: ['workflow', 'pipeline_job'] },
- { label: 'BUILD', sub: 'Docker stages and images', types: ['build_stage', 'container_image'] },
- { label: 'DEPLOY', sub: `workloads in ${environment}`, types: ['deployment', 'container'] },
- { label: 'NETWORK', sub: 'services and ingress', types: ['infrastructure_service', 'ingress'] },
- { label: 'CONFIGURATION', sub: 'ConfigMaps and Secrets', types: ['config_map', 'secret'] },
+ { label: 'WORKFLOW', sub: 'what starts delivery', types: ['workflow'] },
+ { label: 'CI / CD JOBS', sub: 'test, build and release jobs', types: ['pipeline_job'] },
+ { label: 'BUILD', sub: 'Docker build stages', types: ['build_stage'] },
+ { label: 'ARTIFACT', sub: 'container images produced', types: ['container_image'] },
+ { label: 'DEPLOY', sub: `release into ${environment}`, types: ['deployment'] },
];
const N = [], E = [], C = [], pos = {};
columns.forEach((column, col) => {
const x = 40 + col * 285;
- C.push({ x, y: 34, subY: 47, x2: x + 230, ly: 56, label: column.label, sub: column.sub, fill: col === 2 ? '#df642d' : '#8b9793' });
- nodes.filter((node) => column.types.includes(node.type)).forEach((node, row) => { const b = { x, y: 76 + row * 72, w: 230, h: 54 }; pos[node.id] = b; N.push(this.mkNode(node, b.x, b.y, b.w, b.h, { sub: node.type === 'secret' ? 'names only · values never stored' : node.details?.image || node.details?.kind || '' })); });
+ C.push({ x, y: 34, subY: 47, x2: x + 230, ly: 56, label: column.label, sub: column.sub, fill: col === 4 ? '#c56a22' : '#8b9793' });
+ nodes.filter((node) => column.types.includes(node.type)).forEach((node, row) => {
+ const b = { x, y: 76 + row * 72, w: 230, h: 54 }; pos[node.id] = b;
+ const sub = node.type === 'pipeline_job' ? (node.details?.stage || node.details?.runner || 'pipeline job')
+ : node.type === 'build_stage' ? (node.details?.baseImage || 'Docker stage')
+ : node.type === 'deployment' ? (node.details?.kind || `deploys to ${environment}`)
+ : node.details?.image || node.details?.kind || '';
+ N.push(this.mkNode(node, b.x, b.y, b.w, b.h, { sub }));
+ });
});
D.edges.filter((edge) => pos[edge.from] && pos[edge.to]).forEach((edge) => E.push(this.linkEdge(pos[edge.from], pos[edge.to], edge, false, false, true)));
const exact = nodes.filter((node) => node.details?.environment === environment).length;
- const status = exact ? `${environment}: ${exact} environment-specific components · ${nodes.length} delivery/runtime components shown.` : `${environment}: no environment-specific runtime configuration detected. Shared CI/build stages are shown where available.`;
+ const status = exact ? `${environment}: ${exact} environment-specific release components · ${nodes.length} pipeline components shown.` : `${environment}: no environment-specific deployment target detected. Shared CI/build evidence is shown where available.`;
return { nodes: N, edges: E, groups: [], cols: C, status, emptyOk: nodes.length === 0 };
}
- sceneDeliveryCompare() {
+ sceneRuntime(environment) {
const { D } = this.state;
- const types = new Set(['workflow', 'pipeline_job', 'build_stage', 'container_image', 'container', 'deployment', 'infrastructure_service', 'ingress', 'config_map', 'secret']);
- const candidates = D.nodes.filter((node) => types.has(node.type) && (!node.details?.environment || ['staging', 'production'].includes(node.details.environment)));
- const groups = new Map();
- candidates.forEach((node) => {
- const key = `${node.type}:${node.label.toLowerCase().replace(/\b(staging|production|prod|stage)\b/g, '').replace(/[^a-z0-9]+/g, '-')}`;
- const group = groups.get(key) || { key, staging: null, production: null, shared: null };
- const env = node.details?.environment;
- if (env === 'staging') group.staging = node; else if (env === 'production') group.production = node; else group.shared = node;
- groups.set(key, group);
- });
- const columnFor = (node) => ['workflow', 'pipeline_job'].includes(node.type) ? 0 : ['build_stage', 'container_image'].includes(node.type) ? 1 : ['deployment', 'container'].includes(node.type) ? 2 : ['infrastructure_service', 'ingress'].includes(node.type) ? 3 : 4;
- const columns = [['CI / CD', 'shared workflows'], ['BUILD', 'shared image pipeline'], ['DEPLOY', 'staging vs production'], ['NETWORK', 'services and ingress'], ['CONFIGURATION', 'ConfigMaps and secret names']];
- const N = [], E = [], C = [], posById = {}, rows = [0, 0, 0, 0, 0], statusCounts = { same: 0, changed: 0, missing: 0 };
- columns.forEach(([label, sub], index) => C.push({ x: 40 + index * 285, y: 34, subY: 47, x2: 270 + index * 285, ly: 56, label, sub, fill: index === 2 ? '#df642d' : '#8b9793' }));
- [...groups.values()].sort((a, b) => (a.production || a.staging || a.shared).label.localeCompare((b.production || b.staging || b.shared).label)).forEach((group) => {
- const node = group.production || group.staging || group.shared;
- const comparable = (value) => JSON.stringify(Object.fromEntries(Object.entries(value?.details || {}).filter(([key]) => key !== 'environment')));
- const missing = !group.shared && (!group.production || !group.staging);
- const changed = !missing && group.production && group.staging && comparable(group.production) !== comparable(group.staging);
- const status = missing ? 'missing' : changed ? 'changed' : 'same'; statusCounts[status] += 1;
- const col = columnFor(node), row = rows[col]++, b = { x: 40 + col * 285, y: 76 + row * 72, w: 230, h: 56 };
- const sub = group.shared ? 'shared by staging and production' : missing ? `${group.production ? 'production only' : 'staging only'}` : changed ? 'configuration differs' : 'same in both environments';
- const over = status === 'missing' ? { stroke: '#1e8068', fill: '#eef7f3', sw: 2 } : status === 'changed' ? { stroke: '#b27a12', fill: '#fdf6e5', sw: 1.6 } : {};
- N.push(this.mkNode(node, b.x, b.y, b.w, b.h, { sub, over }));
- [group.shared, group.staging, group.production].filter(Boolean).forEach((item) => { posById[item.id] = b; });
+ const types = new Set(['ingress', 'infrastructure_service', 'deployment', 'container', 'config_map', 'secret']);
+ const nodes = D.nodes.filter((node) => types.has(node.type) && (!node.details?.environment || node.details.environment === 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'] },
+ ];
+ const N = [], E = [], C = [], pos = {};
+ columns.forEach((column, col) => {
+ const x = 40 + col * 285;
+ C.push({ x, y: 34, subY: 47, x2: x + 230, ly: 56, label: column.label, sub: column.sub, fill: col === 2 ? '#df642d' : '#8b9793' });
+ nodes.filter((node) => column.types.includes(node.type)).forEach((node, row) => {
+ const b = { x, y: 76 + row * 72, w: 230, h: 56 }; pos[node.id] = b;
+ const sub = node.type === 'secret' ? 'names only · values never stored'
+ : node.type === 'deployment' ? `${node.details?.kind || 'workload'} · ${node.details?.replicas ?? 'replicas not specified'}`
+ : node.type === 'container' ? (node.details?.image || 'container image not resolved')
+ : node.type === 'ingress' ? 'external HTTP entry'
+ : node.type === 'infrastructure_service' ? 'routes traffic to a workload'
+ : `${(node.details?.keys || []).length} configuration keys`;
+ N.push(this.mkNode(node, b.x, b.y, b.w, b.h, { sub }));
+ });
});
- const seen = new Set();
- D.edges.forEach((edge) => {
- const a = posById[edge.from], b = posById[edge.to]; if (!a || !b || a === b) return;
- const key = `${a.x}:${a.y}>${b.x}:${b.y}:${edge.verb}`; if (seen.has(key)) return; seen.add(key);
- E.push(this.linkEdge(a, b, edge, false, false, true));
+ D.edges.filter((edge) => pos[edge.from] && pos[edge.to]).forEach((edge) => E.push(this.linkEdge(pos[edge.from], pos[edge.to], edge, false, false, true)));
+ return { nodes: N, edges: E, groups: [], cols: C, status: `${environment}: ${nodes.length} runtime components · ${E.length} detected runtime connections.`, emptyOk: nodes.length === 0 };
+ }
+
+ sceneEnvironments() {
+ const { D } = this.state;
+ const order = { development: 0, local: 1, staging: 2, production: 3 };
+ const environments = D.nodes.filter((node) => node.type === 'environment')
+ .sort((a, b) => (order[a.details?.environment || a.name] ?? 9) - (order[b.details?.environment || b.name] ?? 9) || a.label.localeCompare(b.label));
+ const N = [], E = [], C = [], pos = {};
+ const columns = [['ENVIRONMENT', 'detected runtime scope'], ['DELIVERY', 'jobs, images and deployments'], ['RUNTIME', 'ingress, services and containers'], ['CONFIGURATION', 'ConfigMaps, secrets and variables']];
+ columns.forEach(([label, sub], col) => C.push({ x: 40 + col * 320, y: 34, subY: 47, x2: 300 + col * 320, ly: 56, label, sub, fill: col === 0 ? '#df642d' : '#8b9793' }));
+ this._syn = this._syn || {};
+ environments.forEach((envNode, row) => {
+ const environment = envNode.details?.environment || envNode.name || envNode.label.toLowerCase();
+ const members = D.nodes.filter((node) => node.details?.environment === environment);
+ const sets = [
+ members.filter((node) => ['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)),
+ ];
+ const y = 78 + row * 94;
+ const envBox = { x: 40, y, w: 260, h: 62 }; pos[envNode.id] = envBox;
+ N.push(this.mkNode(envNode, envBox.x, envBox.y, envBox.w, envBox.h, { sub: `${members.length} environment-specific components` }));
+ sets.forEach((items, index) => {
+ const id = `environment-summary:${environment}:${index}`;
+ const labels = items.slice(0, 3).map((node) => node.label).join(', ');
+ const synthetic = { id, type: index === 0 ? 'deployment' : index === 1 ? 'container' : 'config_map', label: `${items.length} ${index === 0 ? 'delivery' : index === 1 ? 'runtime' : 'configuration'} components`, desc: labels || 'No environment-specific evidence detected.' };
+ this._syn[id] = synthetic;
+ const b = { x: 360 + index * 320, y, w: 260, h: 62 }; pos[id] = b;
+ N.push(this.mkNode(synthetic, b.x, b.y, b.w, b.h, {
+ sub: labels || 'nothing detected',
+ onClick: () => this.setState({ mode: index === 0 ? 'deployment' : index === 1 ? 'runtime' : 'configuration', deliveryEnv: environment, sel: null, vb: null, hist: [] }),
+ over: items.length ? {} : { fill: '#fafbfa', stroke: '#d8dedb' },
+ }));
+ E.push(this.linkEdge(envBox, b, { from: envNode.id, to: id, verb: index === 0 ? 'delivers' : index === 1 ? 'runs' : 'configures', relation: index === 0 ? 'deploys' : index === 1 ? 'targets' : 'configures', kind: 'sync' }, false, !items.length, true));
+ });
});
- return { nodes: N, edges: E, groups: [], cols: C, status: `Staging vs production: ${statusCounts.same} matching · ${statusCounts.changed} changed · ${statusCounts.missing} present in one environment only.`, emptyOk: N.length === 0 };
+ return { nodes: N, edges: E, groups: [], cols: C, status: `${environments.length} environments detected. Each row compares delivery, runtime and configuration evidence without mixing their diagrams.`, emptyOk: environments.length === 0 };
}
sceneConfiguration() {
@@ -2425,8 +2493,10 @@ handler -> work -> data/events -> external effects.
- Explicit empty state when no jobs are detected.
-7. **Delivery & Runtime**
- - Deterministic columns: CI/CD -> build -> deploy -> network -> configuration.
- - Mandatory segmented environment control: Development, Staging, Production.
- - Add Compare mode for Staging vs Production, highlighting missing, added, and changed components.
+7. **Operations**
+ - Keep Deployment, Runtime, Environments, and Configuration as separate navigation items.
+ - Deployment columns: workflow -> CI/CD jobs -> build stages -> image artifact -> deployment target.
+ - Runtime columns: public entry -> routing -> workloads -> containers -> configuration.
+ - Deployment and Runtime use the same segmented environment control: Development, Staging, Production.
+ - Environments is a comparison view: one row per detected environment with separate delivery, runtime, and configuration summaries.
- Show workflows/jobs, Docker stages/images, Compose services, Kubernetes workloads/containers, replicas, resources, probes, services, ingress, ConfigMaps, and Secret names.
- Never show environment or Secret values. Use “names only, values never stored” where needed.
- - If an environment has no configuration, show that clearly while retaining shared CI/build stages.
+ - If an environment has no configuration, show that clearly while retaining shared CI/build stages where relevant.
8. **Configuration contract**
- Environment variable names grouped by environment and owning component.
diff --git a/package-lock.json b/package-lock.json
index 3bfc1af..3870c06 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@dthreads/atlas",
- "version": "0.3.0",
+ "version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@dthreads/atlas",
- "version": "0.3.0",
+ "version": "0.4.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0",
diff --git a/package.json b/package.json
index a320944..7c2c67d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@dthreads/atlas",
- "version": "0.3.0",
+ "version": "0.4.0",
"description": "Architecture intelligence for NestJS codebases.",
"keywords": [
"nestjs",
diff --git a/src/version.ts b/src/version.ts
index f096549..edad07e 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const ATLAS_VERSION = "0.3.0";
+export const ATLAS_VERSION = "0.4.0";
diff --git a/tests/project.test.mjs b/tests/project.test.mjs
index 66504cf..7ecc090 100644
--- a/tests/project.test.mjs
+++ b/tests/project.test.mjs
@@ -299,12 +299,14 @@ test("covers the complete NestJS MVP architecture surface", async () => {
assert.doesNotMatch(viewerHtml, /mod\.metrics\.routes/);
assert.match(viewerHtml, /No data structures detected/);
assert.match(viewerHtml, /Scheduled jobs/);
- assert.match(viewerHtml, /Delivery & Runtime/);
+ assert.match(viewerHtml, /Deployment/);
+ assert.match(viewerHtml, /Runtime Topology/);
+ assert.match(viewerHtml, /Environment Comparison/);
assert.match(viewerHtml, /Configuration Contract/);
assert.match(viewerHtml, /Database Schema/);
assert.match(viewerHtml, /COMPLETE CROSS-SCHEMA ERD/);
assert.match(viewerHtml, /ClickHouse Architecture/);
- assert.match(viewerHtml, /Staging vs production/);
+ assert.match(viewerHtml, /Each row compares delivery, runtime and configuration evidence/);
assert.match(viewerHtml, /every detected table/);
assert.match(viewerHtml, /context stays visible/);
assert.match(viewerHtml, /choose one section to inspect/);
@@ -329,7 +331,9 @@ test("covers the complete NestJS MVP architecture surface", async () => {
assert.match(viewerHtml, /SERVICES & USE CASES/);
assert.match(viewerHtml, /No methods are expanded here/);
assert.match(viewerHtml, /sceneScheduleOverview/);
- assert.match(viewerHtml, /sceneDelivery/);
+ assert.match(viewerHtml, /sceneDeployment/);
+ assert.match(viewerHtml, /sceneRuntime/);
+ assert.match(viewerHtml, /sceneEnvironments/);
assert.match(viewerHtml, /sceneConfiguration/);
assert.doesNotMatch(viewerHtml, /<(?:svg|g|rect|foreignObject|text|line|path)\b[^>]*\s(?:viewBox|x|y|width|height|x1|y1|x2|y2|d|transform|opacity)="\{\{/i);
assert.doesNotMatch(viewerHtml, /shopcore|deleteUserItems|ItemsService/);
diff --git a/tests/viewer-interactions.test.mjs b/tests/viewer-interactions.test.mjs
index c005532..8a12a16 100644
--- a/tests/viewer-interactions.test.mjs
+++ b/tests/viewer-interactions.test.mjs
@@ -151,6 +151,82 @@ test("module dependencies and internals are separate views", async () => {
assert.match(viewer.scene().status, /One-hop context/);
});
+test("module landscape renders directional import relationships", async () => {
+ const viewer = await createViewer();
+ const modules = viewer.state.D.nodes.filter((node) => node.type === "module").slice(0, 2);
+ assert.equal(modules.length, 2, "fixture must contain at least two modules");
+ const domain = {
+ id: "test-boundary",
+ name: "Test boundary",
+ desc: "Test module boundary",
+ counts: "2 modules",
+ modules: modules.map((node) => node.id),
+ allModules: modules.map((node) => node.id),
+ };
+ viewer.state.D.domains = [domain];
+ viewer.state.D.edges.push({
+ from: modules[0].id,
+ to: modules[1].id,
+ verb: "imports",
+ relation: "imports",
+ kind: "sync",
+ confidence: 1,
+ source: "ast",
+ });
+
+ const scene = viewer.sceneModuleGrid(domain.id);
+ const relationship = scene.edges.find((edge) => edge.from === modules[0].id && edge.to === modules[1].id);
+ assert.ok(relationship);
+ assert.equal(relationship.color, "#7452a8");
+ assert.match(scene.status, /directional import relationships/);
+});
+
+test("operations navigation separates deployment, runtime and environments", async () => {
+ const viewer = await createViewer();
+ let values = viewer.renderVals();
+ const labels = values.navItems.filter((item) => item.isItem).map((item) => item.label);
+ assert.ok(labels.includes("Deployment"));
+ assert.ok(labels.includes("Runtime"));
+ assert.ok(labels.includes("Environments"));
+ assert.ok(!labels.includes("Delivery & Runtime"));
+
+ viewer.state = { ...viewer.state, mode: "deployment", deliveryEnv: "production", sel: null };
+ const deployment = viewer.scene();
+ assert.deepEqual(deployment.cols.map((column) => column.label), ["WORKFLOW", "CI / CD JOBS", "BUILD", "ARTIFACT", "DEPLOY"]);
+ assert.ok(deployment.nodes.every((node) => !["container", "ingress", "config_map", "secret"].includes(viewer.node(node.id)?.type)));
+
+ viewer.state = { ...viewer.state, mode: "runtime", deliveryEnv: "production", sel: null };
+ const runtime = viewer.scene();
+ assert.deepEqual(runtime.cols.map((column) => column.label), ["PUBLIC ENTRY", "ROUTING", "WORKLOADS", "CONTAINERS", "CONFIGURATION"]);
+ assert.ok(runtime.nodes.every((node) => !["workflow", "pipeline_job", "build_stage"].includes(viewer.node(node.id)?.type)));
+
+ viewer.state = { ...viewer.state, mode: "environments", sel: null };
+ const environments = viewer.scene();
+ assert.deepEqual(environments.cols.map((column) => column.label), ["ENVIRONMENT", "DELIVERY", "RUNTIME", "CONFIGURATION"]);
+ assert.ok(environments.nodes.some((node) => viewer.node(node.id)?.type === "environment"));
+ assert.ok(environments.edges.length > 0);
+});
+
+test("edge grammar distinguishes structure, delivery, configuration and async flow", async () => {
+ const viewer = await createViewer();
+ const a = { x: 0, y: 0, w: 100, h: 40 };
+ const b = { x: 200, y: 0, w: 100, h: 40 };
+ const edge = (relation, kind = "sync") => viewer.linkEdge(a, b, { from: "service:UsersService", to: "table:users", verb: relation, relation, kind }, false, false, true);
+
+ const imports = edge("imports");
+ assert.equal(imports.color, "#7452a8");
+ assert.equal(imports.moving, false);
+ const deploys = edge("deploys");
+ assert.equal(deploys.color, "#c56a22");
+ assert.equal(deploys.moving, true);
+ const configures = edge("configures");
+ assert.equal(configures.color, "#8b5ca8");
+ assert.equal(configures.moving, false);
+ const publishes = edge("publishes_to", "async");
+ assert.equal(publishes.color, "#0f7895");
+ assert.equal(publishes.moving, true);
+});
+
test("HTTP flow explicitly passes through its controller and drills into context", async () => {
const viewer = await createViewer();
const candidate = Object.entries(viewer.state.D.flows).map(([id, flow]) => {