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]) => {