Skip to content

feat: Multiple Relationships in views - implements #663#2939

Merged
davydkov merged 4 commits into
mainfrom
feature/multiple-relationships-663
May 18, 2026
Merged

feat: Multiple Relationships in views - implements #663#2939
davydkov merged 4 commits into
mainfrom
feature/multiple-relationships-663

Conversation

@galuszkak

Copy link
Copy Markdown
Collaborator

Reopen in this repository - per @davydkov ask. Thanks for adding me as collaborator 🙌 - huge honor for me!

This PR is attempt to implement #663 per initial discussion. I did put it also some part in specification - if for any reason someone wants some relationships always expanded in the views.

Assisted-by: opencode-go:deepseek-v4-pro
Reviewed-by: openai:gpt-5.5

Checklist

  • I've thoroughly read the latest contribution guidelines.
  • I've rebased my branch onto main before creating this PR.
  • I've added tests to cover my changes (if applicable).
  • I've verified pnpm typecheck and pnpm test.
  • I've added changesets (you can use /changeset-generator SKILL).
  • My change requires documentation updates.
  • I've updated the documentation accordingly (or will do in follow-up PR).

@changeset-bot

changeset-bot Bot commented May 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0913e90

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 22 packages
Name Type
@likec4/core Minor
@likec4/language-server Minor
@likec4/playground Minor
@likec4/config Minor
@likec4/diagram Minor
@likec4/generators Minor
@likec4/language-services Minor
@likec4/layouts Minor
@likec4/leanix-bridge Minor
@likec4/spa Minor
likec4 Minor
@likec4/mcp Minor
@likec4/react Minor
@likec4/vite-plugin Minor
@likec4/vscode-preview Minor
likec4-vscode Minor
@likec4/lsp Minor
@likec4/docs-astro Minor
@likec4/style-preset Minor
@likec4/styles Minor
@likec4/log Minor
@likec4/tsconfig Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { Builder } from '../../builder'
import { TestHelper } from '../element-view/__test__/TestHelper'
@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Implements a multiple flag to control whether relationships between the same endpoints render as merged connections or as separate edges; updates grammar/parsing, predicate construction, toComputedEdges refactor (edge splitting and bidirectional merging), hashing, builder/spec handling, tests (unit/integration/e2e), docs, and snapshots.

Changes

Multiple Relationships Expansion Feature

Layer / File(s) Summary
Changelog & Documentation
.changeset/multiple-relationships-in-views.md, apps/docs/src/content/docs/dsl/Views/predicates.mdx, apps/docs/src/content/docs/dsl/specification.mdx, apps/docs/src/content/docs/dsl/styling.mdx
Adds changeset and docs describing multiple usage in specification and per-view include rules.
Type Definitions & Contracts
packages/core/src/compute-view/utils/relationExpressionToPredicates.ts, packages/core/src/types/*.ts, packages/language-server/src/ast.ts
Exports ExpandableRelation/ExpandableConnection and ShouldExpandPredicate; documents/adds multiple?: boolean JSDoc on spec and expression types and view rule styles.
Grammar & Parser
packages/language-server/src/like-c4.langium, packages/language-server/src/model/parser/SpecificationParser.ts, packages/language-server/src/model/parser/FqnRefParser.ts, packages/language-server/src/model/parser/ViewsParser.ts, packages/language-server/src/lsp/CompletionProvider.spec.ts
Grammar accepts multiple; parsers map/validate multiple, detect duplicates for spec relationships, parse custom relation properties, and completion suggests multiple.
Builder & MergedSpecification
packages/core/src/builder/Builder.view-common.ts, packages/language-server/src/model/builder/MergedSpecification.ts
Builder $include uses computed expr for element detection; MergedSpecification omits multiple when emitting c4 relations.
Predicate Factory
packages/core/src/compute-view/utils/relationExpressionToPredicates.ts
Refactors relation-expression-to-predicate into a factory to allow element/deployment predicate builders and exports Expandable types.
Element View Compute
packages/core/src/compute-view/element-view/compute.ts, packages/core/src/compute-view/element-view/utils.ts
Adds buildExpandPredicate and passes optional predicate into toComputedEdges to control per-relation expansion vs merging.
Deployment View Compute
packages/core/src/compute-view/deployment-view/compute.ts, packages/core/src/compute-view/deployment-view/utils.ts
Deployment-scoped buildExpandPredicate added; computeDeploymentView passes predicate to toComputedEdges; introduces mergeBidirectionalEdges and deterministic expanded-edge id hashing.
Edge Computation Core
packages/core/src/compute-view/element-view/utils.ts, packages/core/src/compute-view/deployment-view/utils.ts
toComputedEdges refactored to accept optional shouldExpand, partition relations into expanded vs merged, emit per-relation computed edges with deterministic hashed ids, and post-process bidirectional pairs.
View Hashing & Utilities
packages/core/src/compute-view/utils/view-hash.ts, packages/core/src/compute-view/utils/applyCustomRelationProperties.ts
View hash includes new node style fields (multiple, iconColor, border, opacity) and hashes edges by explicit id; multiple destructured in applyCustomRelationProperties.
Unit & Integration Tests
packages/core/src/compute-view/**/utils.spec.ts, packages/core/src/compute-view/element-view/multiple-relationships.spec.ts, packages/language-server/src/model/__tests__/model-builder.spec.ts
Adds tests verifying merge/expand behavior, predicate scoping, metadata-driven partial expansion, parser propagation, and precedence rules.
Hash Collision & Stability Tests
packages/core/src/compute-view/utils/view-hash.spec.ts
Tests verify layout-hash stability and non-collision for merged/parallel edges.
E2E Fixtures & Tests
e2e/src/likec4/_spec.c4, e2e/src/likec4/cloud/next.c4, e2e/src/likec4/views.c4, e2e/src/likec4-model.spec.ts, e2e/src/likec4-views.spec.ts, e2e/src/likec4-model.test-d.ts, e2e/src/likec4-views.test-d.ts
C4 spec and views added for multiple-expanded, multiple-merged, and multiple-explicit, demonstrating expanded vs merged rendering; e2e tests and type assertions updated.
Builder Snapshots
packages/core/src/builder/__snapshots__/*.json5
Computed view hash snapshots updated to reflect new edge identity and hashing strategy.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • davydkov
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: implementing support for multiple relationships in views, with a direct reference to issue #663.
Description check ✅ Passed The PR description addresses the reopening context, references the implementation of issue #663, mentions specification changes, and all checklist items are marked as completed. However, it lacks detailed technical explanation of what was changed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/multiple-relationships-663

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/types/model-spec.ts (1)

68-81: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Type definition mismatch: RelationshipSpecification needs a style wrapper for consistency with buildModel output

RelationshipSpecification declares multiple, color, line, head, and tail at the top level, but buildModel.ts (line 393–396) transforms the relationship spec by extracting notation and technology separately, then spreading the rest under a style object:

relationships: mapValues(c4Specification.specs.relationships, ({ notation, technology, ...style }) => ({
  ...(notation && { notation }),
  ...(technology && { technology }),
  style,
}))

The test in model-builder.spec.ts (line 1482) confirms the expected output structure with style.multiple, not top-level multiple. This mirrors how ElementSpecification wraps its rendering fields under a style sub-object. The type definition must be updated to reflect the actual runtime structure: color, line, head, tail, and multiple should all be nested under a style property.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/types/model-spec.ts` around lines 68 - 81,
RelationshipSpecification’s shape is inconsistent with buildModel.ts output:
move rendering properties into a nested style object so the runtime structure
matches tests; update the interface RelationshipSpecification to keep
technology?: string and notation?: string at top level and add style?: { color?:
Color; line?: RelationshipLineType; head?: RelationshipArrowType; tail?:
RelationshipArrowType; multiple?: boolean } (mirroring ElementSpecification’s
style wrapper) so consumers and buildModel.ts (which spreads ({ notation,
technology, ...style }) into { notation?, technology?, style }) align.
🧹 Nitpick comments (12)
packages/core/src/compute-view/utils/relationExpressionToPredicates.ts (1)

46-48: ⚖️ Poor tradeoff

any usage in factory signature violates the "avoid any" guideline

elementPredicateBuilder: (expr: any) => (node: any) => boolean and the (expr: any) => Predicate<T> return type use any in production code. The JSDoc acknowledges this and documents the reason (incompatible expression/node types across element vs. deployment view paths), but the type parameter A on the factory is effectively unused since elementPredicateBuilder doesn't reference it — callers cannot infer A from the any-typed parameter.

If a common base expression type (analogous to ExpandableRelation for relations) can be extracted, that would allow tightening these signatures. Otherwise, consider a comment-level // eslint-disable-next-line @typescript-eslint/no-explicit-any`` or similar oxlint suppression to make the intentional deviation explicit rather than relying solely on JSDoc prose.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/utils/relationExpressionToPredicates.ts`
around lines 46 - 48, The factory signature for
createRelationExpressionToPredicates uses broad any types
(elementPredicateBuilder: (expr: any) => (node: any) => boolean and returns
(expr: any) => Predicate<T>) which prevents inferring the generic A and violates
the no-any rule; either introduce a shared base expression type (e.g.,
ExtractableRelationExpression or similar) and replace the any occurrences with
that type so A can be referenced in elementPredicateBuilder and the return type,
or if a shared type cannot be defined, add an explicit lint suppression (//
eslint-disable-next-line `@typescript-eslint/no-explicit-any`) immediately above
the elementPredicateBuilder parameter and the function return signature and
include a short comment explaining why any is intentional; update the function
signature of createRelationExpressionToPredicates and elementPredicateBuilder
accordingly so callers can infer generics or the intentional exemption is
documented.
packages/core/src/compute-view/utils/view-hash.ts (1)

33-48: 💤 Low value

id is redundant in both the map key and the spread value

mapToObj((e) => [e.id, { ...e }]) uses e.id as the key while { ...e } also contains id in the value, so the hash receives id twice. This is harmless (the hash remains deterministic), but the spread value duplicates data that's already captured by the key.

♻️ Optional cleanup
-      mapToObj((e) => [e.id, { ...e }]),
+      mapToObj(({ id, ...rest }) => [id, rest]),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/utils/view-hash.ts` around lines 33 - 48, The
edges mapping duplicates the edge id by using e.id as the map key and also
including id inside the spread value in mapToObj; update the transformation so
the mapped value does not include id (e.g., build the value object without the
id field) when creating the [e.id, value] pair for mapToObj, leaving the rest of
the properties produced by the earlier map (flattenMarkdownOrString, technology,
dir, head, tail, line) unchanged; modify the code around view.edges / map(...) /
mapToObj(...) to omit id from the value.
packages/core/src/compute-view/element-view/utils.ts (2)

27-32: 💤 Low value

Stale commented-out code can be removed.

Lines 28-29 (// const modelRelations = [], // const deploymentRelations = []) are dead scaffolding from a prior iteration. Drop them to keep the function focused.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/element-view/utils.ts` around lines 27 - 32,
Remove the stale commented-out scaffolding inside the connections.flatMap
callback: delete the two commented lines "// const modelRelations = []" and "//
const deploymentRelations = []" so the code focuses on the real variables
(connections.flatMap, conn, relations, conn.relations) without leftover dead
comments.

40-115: 💤 Low value

directRelsList is computed eagerly even when the expansion branch returns early.

Line 40 always computes directRelsList, but it is only used at line 115 (the non-expanded fallback). When shouldExpand && relations.length > 1 and there are expanded relations, the function returns at line 101 without ever using directRelsList. Move the computation just above its first use to avoid the unnecessary filter per connection.

-    const directRelsList = filter(relations, r => r.source.id === source && r.target.id === target)
-
     if (shouldExpand && relations.length > 1) {
       ...
     }
@@
-      only(directRelsList)?.$relationship,
+      only(filter(relations, r => r.source.id === source && r.target.id === target))?.$relationship,
     )

Same kind of helper extraction suggested for the deployment-view sibling would also dedupe the destructuring of title/color/line/head/...props repeated three times here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/element-view/utils.ts` around lines 40 - 115,
The eager computation of directRelsList causes an unnecessary filter; move the
directRelsList = filter(relations, ...) expression out of the top and compute it
just before it's used in the non-expanded fallback (right before the
mergePropsFromRelationships call that uses only(directRelsList)?.$relationship).
Also extract the repeated destructuring/prop-merge logic (the
title/color/line/head/...props block and the construction of ComputedEdge
objects) into a small helper (e.g., mergePropsFromRelationshipsAndBuildEdge or
formatComputedEdge) and reuse it from the expanded mapping, the merged push
block, and the fallback to remove duplication and clarify where shouldExpand,
relations, mergePropsFromRelationships, and conn.id are used.
packages/core/src/compute-view/deployment-view/utils.ts (3)

282-307: 💤 Low value

only(filter(merged, …)) will silently drop the hint when several merged relations are direct.

only returns undefined when the filter yields more than one result, so as soon as two of the "merged" relations are direct (source.id === source && target.id === target), mergedSource becomes undefined and mergePropsFromRelationships is called without a hint. That's likely fine, but it's worth a brief comment explaining why an undefined hint is preferable to picking the first one — otherwise a future reader will be tempted to "fix" it with first(...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/utils.ts` around lines 282 -
307, The call only(filter(merged, ...)) can return undefined when multiple
merged relations match, which intentionally avoids selecting an arbitrary
"first" relation as the hint for mergePropsFromRelationships; add a brief
explanatory comment above the mergedSource assignment (referencing only, filter,
mergedSource, mergePropsFromRelationships and merged) stating that undefined is
preferred over picking the first match because a hint from one direct relation
would be misleading when multiple direct relations exist, so we deliberately
pass undefined to let mergePropsFromRelationships compute without a biased hint.

233-336: ⚡ Quick win

Address SonarCloud cognitive-complexity warning (45 vs 30 allowed).

Static analysis flagged this function. The expanded-vs-merged branch shares the mergePropsFromRelationships + defaults destructuring with the bottom non-expanded branch, and the inner "if merged.length > 0" block repeats it again. Extracting a small buildEdgeFromRelations(conn, source, target, defaults, relations, hint?) helper that returns a ComputedEdge<A> would collapse three near-identical blocks into one and bring complexity well under the threshold. As a side effect it also removes the two as ComputedEdge<A> casts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/utils.ts` around lines 233 -
336, The toComputedEdges function is over-complex due to repeated logic for
merging relationship props; extract a helper (e.g., buildEdgeFromRelations) that
accepts (conn, source, target, defaults, relations, optionalHintRelationship)
and returns a ComputedEdge<A> by calling mergePropsFromRelationships, applying
defaults, building id/parent/label/relations/color/line/head and other props;
replace the three near-identical code blocks in toComputedEdges (the expanded
loop body, the merged.length > 0 block, and the final non-expanded branch) with
calls to buildEdgeFromRelations, remove the redundant "as ComputedEdge<A>"
casts, and ensure hashEdgeId usage is preserved for expanded single-relation
edges while the helper produces the standard conn.id for merged/multi relations.

204-231: 💤 Low value

mergeBidirectionalEdges mutates input edge objects in place.

edge.dir = 'both', edge.head = …, edge.color = …, edge.line = … mutate the ComputedEdge objects that came from nonExpandedEdges, which were just constructed from mergePropsFromRelationships results and from exact({...}). While today's caller passes a freshly built array, returning a new array via filter(...) while mutating the surviving elements is a mixed pattern that's easy to misuse later (e.g. if a future caller passes shared edges). Consider building new edge objects via spread instead of mutating, and dropping as ComputedEdge<A> once typing is tightened.

Sketch
-      if (edge.source === other.target && edge.target === other.source && edge.label === other.label) {
-        edge.dir = 'both'
-        const head = edge.head ?? other.head
-        if (head) {
-          if (!edge.head) edge.head = head
-          if (!edge.tail) edge.tail = head
-        }
-        if (other.color) {
-          if (!edge.color) edge.color = other.color
-        }
-        if (other.line) {
-          if (!edge.line) edge.line = other.line
-        }
-        toRemove.add(j)
-        break
-      }
+      if (edge.source === other.target && edge.target === other.source && edge.label === other.label) {
+        const head = edge.head ?? other.head
+        edges[i] = {
+          ...edge,
+          dir: 'both',
+          head: edge.head ?? head,
+          tail: edge.tail ?? head,
+          color: edge.color ?? other.color,
+          line: edge.line ?? other.line,
+        }
+        toRemove.add(j)
+        break
+      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/utils.ts` around lines 204 -
231, mergeBidirectionalEdges currently mutates incoming ComputedEdge objects
(setting edge.dir, head, color, line) which is unsafe; instead, when you need to
modify an edge (e.g., when matching a reverse edge) create a new edge object via
a shallow copy (use {...edge, dir: 'both', head: computedHead, color: edge.color
?? other.color, line: edge.line ?? other.line}) and place that new object into
the result list, leaving the original edges untouched; ensure to build the final
returned array from these new objects (and unmodified originals) rather than
filtering the mutated input, and remove any unnecessary type assertions like "as
ComputedEdge<A>" once types are tightened.
packages/core/src/compute-view/element-view/compute.ts (1)

209-216: 💤 Low value

Iterate relSpecs entries directly to avoid the keyof+as any indirection.

Object.keys(relSpecs) followed by relSpecs[kind as keyof typeof relSpecs] as any is convoluted. Object.entries (or for…in if the order matters) yields the value without the second cast, and the runtime fallback style ?? spec can stay. Also worth confirming model.specification.relationships is guaranteed defined here — defensive ?? {} would avoid a crash if a model is constructed without a specification block.

-  const multipleKinds = new Set<string>()
-  const relSpecs = model.specification.relationships
-  for (const kind of Object.keys(relSpecs)) {
-    const spec = relSpecs[kind as keyof typeof relSpecs] as any
-    // The spec may have `multiple` nested under `style` (buildModel.ts) or flat (builder)
-    const style = spec?.style ?? spec
-    if (style?.multiple) {
-      multipleKinds.add(kind)
-    }
-  }
+  const multipleKinds = new Set<string>()
+  for (const [kind, spec] of Object.entries(model.specification.relationships ?? {})) {
+    // The spec may have `multiple` nested under `style` (buildModel.ts) or flat (builder)
+    const style = (spec as { style?: { multiple?: boolean }; multiple?: boolean })?.style ?? spec
+    if ((style as { multiple?: boolean })?.multiple) {
+      multipleKinds.add(kind)
+    }
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/element-view/compute.ts` around lines 209 -
216, The loop over relSpecs uses Object.keys and a type-cast to access values;
replace it by iterating relSpecs directly (e.g. Object.entries(relSpecs) or
for...in) so you can get the spec value without relSpecs[kind as keyof typeof
relSpecs] as any, keep the existing style = spec?.style ?? spec logic and push
kind into multipleKinds when style?.multiple is truthy, and make relSpecs itself
defensive by defaulting model.specification.relationships ?? {} (or similar)
before the loop to avoid crashes when a model has no specification block; update
references to relSpecs, multipleKinds, and the loop in compute.ts accordingly.
packages/core/src/compute-view/element-view/multiple-relationships.spec.ts (2)

316-333: ⚡ Quick win

Add coverage for the reverse ordering of multiple: true / multiple: false.

The test asserts false overrides a preceding true, but doesn't test the reverse — include('a -> b', { with: { multiple: false } }) followed by include('a -> b', { with: { multiple: true } }). Given the implementation in compute.ts always lets false win regardless of position, that ordering would currently fail "last rule wins" semantics. Adding the reverse case will pin down the intended precedence and prevent regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/element-view/multiple-relationships.spec.ts`
around lines 316 - 333, Add a test that covers the reverse ordering of include
rules so we verify precedence between multiple: false and multiple: true;
specifically add a new spec mirroring the existing one but with
TestHelper.$rules called in reverse order (first t.$include('a -> b', { with: {
multiple: false } }) then t.$include('a -> b', { with: { multiple: true } }))
and assert the expected behavior (whether false should still override or last
rule should win) to lock down intended semantics for computeView and the include
handling in compute.ts; reference the existing test in
multiple-relationships.spec.ts and the use of TestHelper.$include/computeView
when adding this new case.

110-124: 💤 Low value

Clean up debug-style narrative comments before merge.

Lines 111-119 read like a working scratchpad ("Wait - there's 1 sync relation…", "Actually: relations = [alpha, beta, gamma]"). Once the test is correct, the rationale can be a single short comment or removed entirely so future readers aren't confused about whether the reasoning is still authoritative.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/element-view/multiple-relationships.spec.ts`
around lines 110 - 124, Remove the debug-style running-narrative comments
between the assertions around expect(view.edges).toHaveLength(3) (the block that
starts with "2 expanded async edges..." and the "Actually: relations = [alpha,
beta, gamma]" lines); replace them with a single concise comment (or remove
entirely) that states the intent (e.g. "two expanded async edges and one merged
sync edge expected") so the test around view.edges, expanded and merged remains
clear; locate this in the multiple-relationships.spec.ts test that asserts
expect(expanded).toHaveLength(2) and expect(merged).toHaveLength(1) and update
the comments there.
packages/core/src/compute-view/deployment-view/compute.ts (2)

74-93: 🏗️ Heavy lift

resolveEndpoint does structural inspection on any — fragile and untyped.

The endpoint shape detection ('tags' in ep && 'kind' in ep && 'metadata' in ep, then a fallback that reaches into ep.instance/ep.element) is essentially a runtime type guess. If the deployment model adds another endpoint variant (e.g. a future "service" wrapper that has tags but a different kind semantics), the predicate will silently produce wrong results.

Recommendation: define an explicit EndpointForPredicate type and a typed resolveEndpoint(ep: DeploymentEndpoint): EndpointForPredicate function in deployment-view/utils.ts, exhaustively switching on the endpoint variants (using nonexhaustive like the surrounding code). This also removes the four as any casts on the call to pred(...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/compute.ts` around lines 74 -
93, The current resolveEndpoint implementation in compute.ts does fragile
runtime shape checks on any; replace this by defining a concrete
EndpointForPredicate type and a typed resolveEndpoint(ep: DeploymentEndpoint):
EndpointForPredicate in deployment-view/utils.ts, implement resolveEndpoint with
an exhaustive switch over known endpoint variants (use the project nonexhaustive
pattern for future-proofing, and handle NestedElementOfDeployedInstanceModel and
other wrappers explicitly), export and import that typed function into
compute.ts to remove the ad-hoc casts and the four "as any" uses when calling
pred(...) so pred receives a correctly-typed EndpointForPredicate instead of
any.

24-29: ⚖️ Poor tradeoff

as any bridge between FqnExpr and ModelFqnExpr hides a real type contract gap.

The comment acknowledges the issue ("the as any bridge is needed because…"). A safer alternative is to make createRelationExpressionToPredicates generic over the FQN-expression flavor (or accept a narrowed Predicate<{ id: string; … }>), so deployment and element pipelines instantiate it with their own typed predicate factory. The current "runtime-safe" claim relies on the two builder shapes staying convergent, with no compiler check.

As per coding guidelines: "Avoid using any, casts with as."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/compute.ts` around lines 24 -
29, The current use of "as any" to pass deploymentExpressionToPredicate into
createRelationExpressionToPredicates hides a real type mismatch between FqnExpr
and ModelFqnExpr; change createRelationExpressionToPredicates to be generic over
the expression/builder flavor (e.g. add a type parameter like <Expr> or <Expr
extends FqnExpr> or accept a builder type returning Predicate<{id: string}>),
update its signature so it accepts the exact factory shape used by
deploymentExpressionToPredicate (not ModelFqnExpr-locked types), then remove the
cast and instantiate createRelationExpressionToPredicates with the proper type
so deploymentRelationExprToPredicate is typed safely; update any callers of
createRelationExpressionToPredicates to supply the matching type parameter or
builder shape (referenced symbols: createRelationExpressionToPredicates,
deploymentExpressionToPredicate, ModelFqnExpr, FqnExpr,
deploymentRelationExprToPredicate).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/compute-view/deployment-view/compute.ts`:
- Around line 31-140: The current buildExpandPredicate in
compute-view/deployment-view/compute.ts is a near-duplicate of
element-view/compute.ts#buildExpandPredicate; extract the shared logic into a
new helper (e.g. buildMultipleExpansionPredicate) under compute-view/utils and
have both buildExpandPredicate implementations delegate to it. The helper should
accept the model, rules, a relation-expr->predicate function
(deploymentRelationExprToPredicate for this file), an optional rule-normalizer
(flattenGroupRules used by the element view), and a resolveEndpoint function
(deployment-specific here) and implement the multipleRules/multipleFalseRules
partitioning, multipleKinds collection, and the final predicate precedence
logic; then replace the body of the existing buildExpandPredicate to call this
helper with the appropriate args so the duplicate code is removed.

In `@packages/core/src/compute-view/element-view/compute.ts`:
- Around line 195-275: The buildExpandPredicate function is using wide any casts
for model/spec and every predicate call (see buildExpandPredicate,
ExpandableRelation, LikeC4Model, relationExpressionToPredicates), which violates
the TS-first guideline; fix it by (1) defining and exporting a concrete
PredicateInput type (or RelationshipModel shape) in
relationExpressionToPredicates.ts and updating relationExpressionToPredicates to
accept that typed input so callers no longer need as any casts, and (2)
extracting the shared logic into a single helper (e.g.
buildMultipleExpansionRules) used by both element- and deployment-view compute
modules, parameterized by the predicate factory and an endpoint resolver; keep
the two callers' differences (flattenGroupRules usage and resolveEndpoint step)
as parameters to the helper and replace all explicit as any casts in
buildExpandPredicate with the new typed input.
- Around line 199-205: Compute the flattened rules once instead of calling
flattenGroupRules(ModelRelationExpr.isCustom) twice: replace the duplicated
flatMap usages by assigning const flattened =
rules.flatMap(flattenGroupRules(ModelRelationExpr.isCustom)) and derive
multipleRules and multipleFalseRules from that single flattened array; then
decide and implement consistent precedence (either keep the current "multiple:
false wins" behavior by checking multipleFalseRules first, or switch to
last-rule-wins to match applyCustomRelationProperties by scanning flattened in
order and using the last matching rule to set multiple), and add a unit test
covering the reverse ordering case (multiple: true after multiple: false) to
validate the chosen semantics.

In `@packages/core/src/compute-view/utils/relationExpressionToPredicates.ts`:
- Around line 15-36: Update the ExpandableRelation JSDoc to remove the
misleading {`@link` ShouldExpandPredicate} reference and replace it with an
accurate description of where ExpandableRelation is consumed (e.g., used when
deciding whether to expand a connection into multiple edges by functions that
operate on relations with source/target metadata), or simply state it represents
the minimal relation shape used by expansion logic; ensure the comment no longer
claims it is "Used by ShouldExpandPredicate" since ShouldExpandPredicate is
typed as (rel: any) => boolean and cannot accept ExpandableRelation directly.

---

Outside diff comments:
In `@packages/core/src/types/model-spec.ts`:
- Around line 68-81: RelationshipSpecification’s shape is inconsistent with
buildModel.ts output: move rendering properties into a nested style object so
the runtime structure matches tests; update the interface
RelationshipSpecification to keep technology?: string and notation?: string at
top level and add style?: { color?: Color; line?: RelationshipLineType; head?:
RelationshipArrowType; tail?: RelationshipArrowType; multiple?: boolean }
(mirroring ElementSpecification’s style wrapper) so consumers and buildModel.ts
(which spreads ({ notation, technology, ...style }) into { notation?,
technology?, style }) align.

---

Nitpick comments:
In `@packages/core/src/compute-view/deployment-view/compute.ts`:
- Around line 74-93: The current resolveEndpoint implementation in compute.ts
does fragile runtime shape checks on any; replace this by defining a concrete
EndpointForPredicate type and a typed resolveEndpoint(ep: DeploymentEndpoint):
EndpointForPredicate in deployment-view/utils.ts, implement resolveEndpoint with
an exhaustive switch over known endpoint variants (use the project nonexhaustive
pattern for future-proofing, and handle NestedElementOfDeployedInstanceModel and
other wrappers explicitly), export and import that typed function into
compute.ts to remove the ad-hoc casts and the four "as any" uses when calling
pred(...) so pred receives a correctly-typed EndpointForPredicate instead of
any.
- Around line 24-29: The current use of "as any" to pass
deploymentExpressionToPredicate into createRelationExpressionToPredicates hides
a real type mismatch between FqnExpr and ModelFqnExpr; change
createRelationExpressionToPredicates to be generic over the expression/builder
flavor (e.g. add a type parameter like <Expr> or <Expr extends FqnExpr> or
accept a builder type returning Predicate<{id: string}>), update its signature
so it accepts the exact factory shape used by deploymentExpressionToPredicate
(not ModelFqnExpr-locked types), then remove the cast and instantiate
createRelationExpressionToPredicates with the proper type so
deploymentRelationExprToPredicate is typed safely; update any callers of
createRelationExpressionToPredicates to supply the matching type parameter or
builder shape (referenced symbols: createRelationExpressionToPredicates,
deploymentExpressionToPredicate, ModelFqnExpr, FqnExpr,
deploymentRelationExprToPredicate).

In `@packages/core/src/compute-view/deployment-view/utils.ts`:
- Around line 282-307: The call only(filter(merged, ...)) can return undefined
when multiple merged relations match, which intentionally avoids selecting an
arbitrary "first" relation as the hint for mergePropsFromRelationships; add a
brief explanatory comment above the mergedSource assignment (referencing only,
filter, mergedSource, mergePropsFromRelationships and merged) stating that
undefined is preferred over picking the first match because a hint from one
direct relation would be misleading when multiple direct relations exist, so we
deliberately pass undefined to let mergePropsFromRelationships compute without a
biased hint.
- Around line 233-336: The toComputedEdges function is over-complex due to
repeated logic for merging relationship props; extract a helper (e.g.,
buildEdgeFromRelations) that accepts (conn, source, target, defaults, relations,
optionalHintRelationship) and returns a ComputedEdge<A> by calling
mergePropsFromRelationships, applying defaults, building
id/parent/label/relations/color/line/head and other props; replace the three
near-identical code blocks in toComputedEdges (the expanded loop body, the
merged.length > 0 block, and the final non-expanded branch) with calls to
buildEdgeFromRelations, remove the redundant "as ComputedEdge<A>" casts, and
ensure hashEdgeId usage is preserved for expanded single-relation edges while
the helper produces the standard conn.id for merged/multi relations.
- Around line 204-231: mergeBidirectionalEdges currently mutates incoming
ComputedEdge objects (setting edge.dir, head, color, line) which is unsafe;
instead, when you need to modify an edge (e.g., when matching a reverse edge)
create a new edge object via a shallow copy (use {...edge, dir: 'both', head:
computedHead, color: edge.color ?? other.color, line: edge.line ?? other.line})
and place that new object into the result list, leaving the original edges
untouched; ensure to build the final returned array from these new objects (and
unmodified originals) rather than filtering the mutated input, and remove any
unnecessary type assertions like "as ComputedEdge<A>" once types are tightened.

In `@packages/core/src/compute-view/element-view/compute.ts`:
- Around line 209-216: The loop over relSpecs uses Object.keys and a type-cast
to access values; replace it by iterating relSpecs directly (e.g.
Object.entries(relSpecs) or for...in) so you can get the spec value without
relSpecs[kind as keyof typeof relSpecs] as any, keep the existing style =
spec?.style ?? spec logic and push kind into multipleKinds when style?.multiple
is truthy, and make relSpecs itself defensive by defaulting
model.specification.relationships ?? {} (or similar) before the loop to avoid
crashes when a model has no specification block; update references to relSpecs,
multipleKinds, and the loop in compute.ts accordingly.

In `@packages/core/src/compute-view/element-view/multiple-relationships.spec.ts`:
- Around line 316-333: Add a test that covers the reverse ordering of include
rules so we verify precedence between multiple: false and multiple: true;
specifically add a new spec mirroring the existing one but with
TestHelper.$rules called in reverse order (first t.$include('a -> b', { with: {
multiple: false } }) then t.$include('a -> b', { with: { multiple: true } }))
and assert the expected behavior (whether false should still override or last
rule should win) to lock down intended semantics for computeView and the include
handling in compute.ts; reference the existing test in
multiple-relationships.spec.ts and the use of TestHelper.$include/computeView
when adding this new case.
- Around line 110-124: Remove the debug-style running-narrative comments between
the assertions around expect(view.edges).toHaveLength(3) (the block that starts
with "2 expanded async edges..." and the "Actually: relations = [alpha, beta,
gamma]" lines); replace them with a single concise comment (or remove entirely)
that states the intent (e.g. "two expanded async edges and one merged sync edge
expected") so the test around view.edges, expanded and merged remains clear;
locate this in the multiple-relationships.spec.ts test that asserts
expect(expanded).toHaveLength(2) and expect(merged).toHaveLength(1) and update
the comments there.

In `@packages/core/src/compute-view/element-view/utils.ts`:
- Around line 27-32: Remove the stale commented-out scaffolding inside the
connections.flatMap callback: delete the two commented lines "// const
modelRelations = []" and "// const deploymentRelations = []" so the code focuses
on the real variables (connections.flatMap, conn, relations, conn.relations)
without leftover dead comments.
- Around line 40-115: The eager computation of directRelsList causes an
unnecessary filter; move the directRelsList = filter(relations, ...) expression
out of the top and compute it just before it's used in the non-expanded fallback
(right before the mergePropsFromRelationships call that uses
only(directRelsList)?.$relationship). Also extract the repeated
destructuring/prop-merge logic (the title/color/line/head/...props block and the
construction of ComputedEdge objects) into a small helper (e.g.,
mergePropsFromRelationshipsAndBuildEdge or formatComputedEdge) and reuse it from
the expanded mapping, the merged push block, and the fallback to remove
duplication and clarify where shouldExpand, relations,
mergePropsFromRelationships, and conn.id are used.

In `@packages/core/src/compute-view/utils/relationExpressionToPredicates.ts`:
- Around line 46-48: The factory signature for
createRelationExpressionToPredicates uses broad any types
(elementPredicateBuilder: (expr: any) => (node: any) => boolean and returns
(expr: any) => Predicate<T>) which prevents inferring the generic A and violates
the no-any rule; either introduce a shared base expression type (e.g.,
ExtractableRelationExpression or similar) and replace the any occurrences with
that type so A can be referenced in elementPredicateBuilder and the return type,
or if a shared type cannot be defined, add an explicit lint suppression (//
eslint-disable-next-line `@typescript-eslint/no-explicit-any`) immediately above
the elementPredicateBuilder parameter and the function return signature and
include a short comment explaining why any is intentional; update the function
signature of createRelationExpressionToPredicates and elementPredicateBuilder
accordingly so callers can infer generics or the intentional exemption is
documented.

In `@packages/core/src/compute-view/utils/view-hash.ts`:
- Around line 33-48: The edges mapping duplicates the edge id by using e.id as
the map key and also including id inside the spread value in mapToObj; update
the transformation so the mapped value does not include id (e.g., build the
value object without the id field) when creating the [e.id, value] pair for
mapToObj, leaving the rest of the properties produced by the earlier map
(flattenMarkdownOrString, technology, dir, head, tail, line) unchanged; modify
the code around view.edges / map(...) / mapToObj(...) to omit id from the value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1d3c5f94-a7d5-42f9-8b90-935a5e9b4150

📥 Commits

Reviewing files that changed from the base of the PR and between d22498e and 11415a5.

⛔ Files ignored due to path filters (5)
  • packages/core/src/compute-view/element-view/__test__/__snapshots__/legacy.spec.ts.snap is excluded by !**/*.snap
  • packages/generators/src/model/__snapshots__/likec4.computed-model-with-core.snap is excluded by !**/*.snap
  • packages/generators/src/model/__snapshots__/likec4.computed-model.snap is excluded by !**/*.snap
  • packages/language-server/src/model/__tests__/__snapshots__/model-builder.spec.ts.snap is excluded by !**/*.snap
  • packages/layouts/src/graphviz/wasm/__snapshots__/GraphvizWasmAdapter.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (38)
  • .changeset/multiple-relationships-in-views.md
  • apps/docs/src/content/docs/dsl/Views/predicates.mdx
  • apps/docs/src/content/docs/dsl/specification.mdx
  • apps/docs/src/content/docs/dsl/styling.mdx
  • e2e/src/likec4-model.spec.ts
  • e2e/src/likec4-model.test-d.ts
  • e2e/src/likec4-views.spec.ts
  • e2e/src/likec4-views.test-d.ts
  • e2e/src/likec4/_spec.c4
  • e2e/src/likec4/cloud/next.c4
  • e2e/src/likec4/views.c4
  • packages/core/src/builder/Builder.view-common.ts
  • packages/core/src/builder/__snapshots__/Builder-style2.compute-model.json5
  • packages/core/src/builder/__snapshots__/Builder-style2.general.json5
  • packages/core/src/compute-view/deployment-view/compute.ts
  • packages/core/src/compute-view/deployment-view/utils.spec.ts
  • packages/core/src/compute-view/deployment-view/utils.ts
  • packages/core/src/compute-view/element-view/compute.ts
  • packages/core/src/compute-view/element-view/multiple-relationships.spec.ts
  • packages/core/src/compute-view/element-view/utils.spec.ts
  • packages/core/src/compute-view/element-view/utils.ts
  • packages/core/src/compute-view/utils/applyCustomRelationProperties.ts
  • packages/core/src/compute-view/utils/relationExpressionToPredicates.ts
  • packages/core/src/compute-view/utils/view-hash.spec.ts
  • packages/core/src/compute-view/utils/view-hash.ts
  • packages/core/src/types/expression-model.ts
  • packages/core/src/types/expression.ts
  • packages/core/src/types/model-spec.ts
  • packages/core/src/types/view-common.ts
  • packages/core/src/types/view-parsed.element.ts
  • packages/language-server/src/ast.ts
  • packages/language-server/src/like-c4.langium
  • packages/language-server/src/lsp/CompletionProvider.spec.ts
  • packages/language-server/src/model/__tests__/model-builder.spec.ts
  • packages/language-server/src/model/builder/MergedSpecification.ts
  • packages/language-server/src/model/parser/FqnRefParser.ts
  • packages/language-server/src/model/parser/SpecificationParser.ts
  • packages/language-server/src/model/parser/ViewsParser.ts

Comment on lines +31 to +140
function buildExpandPredicate<A extends AnyAux>(
model: LikeC4Model<any>,
rules: DeploymentViewRule<A>[],
): ((rel: ExpandableRelation) => boolean) | undefined {
const multipleRules: RelationExpr.Custom<A>[] = []
const multipleFalseRules: RelationExpr.Custom<A>[] = []

for (const rule of rules) {
if (isViewRulePredicate(rule) && 'include' in rule) {
for (const expr of rule.include) {
if (RelationExpr.isCustom(expr)) {
if (expr.customRelation.multiple === true) {
multipleRules.push(expr)
} else if (expr.customRelation.multiple === false) {
multipleFalseRules.push(expr)
}
}
}
}
}

const multipleKinds = new Set<string>()
const relSpecs = model.specification.relationships
for (const kind of Object.keys(relSpecs)) {
const spec = relSpecs[kind as keyof typeof relSpecs] as any
// The spec may have `multiple` nested under `style` (buildModel.ts) or flat (builder)
const style = spec?.style ?? spec
if (style?.multiple) {
multipleKinds.add(kind)
}
}

if (multipleRules.length === 0 && multipleFalseRules.length === 0 && multipleKinds.size === 0) {
return undefined
}

const rulePredicates = multipleRules.map(r => ({
pred: deploymentRelationExprToPredicate(r.customRelation.expr),
}))
const falseRulePredicates = multipleFalseRules.map(r => ({
pred: deploymentRelationExprToPredicate(r.customRelation.expr),
}))

return (rel: ExpandableRelation) => {
// Resolve source/target properties for deployment relation endpoints.
// NestedElementOfDeployedInstanceModel lacks tags/kind/metadata,
// so reach through to the instance's element.
const resolveEndpoint = (ep: any) => {
if (ep && typeof ep === 'object') {
if ('tags' in ep && 'kind' in ep && 'metadata' in ep) {
return ep
}
if (ep.instance && ep.element) {
return {
id: ep.id,
tags: ep.instance.tags,
kind: ep.element.kind,
metadata: ep.instance.metadata,
}
}
}
return ep ?? {}
}

if (falseRulePredicates.length > 0) {
const source = resolveEndpoint(rel.source)
const target = resolveEndpoint(rel.target)
for (const { pred } of falseRulePredicates) {
if (
pred({
source: source as any,
target: target as any,
kind: rel.kind as any,
tags: rel.tags as any,
metadata: rel.metadata as any,
})
) {
return false
}
}
}

if (multipleKinds.size > 0) {
const kind = rel.kind
if (kind && multipleKinds.has(kind)) {
return true
}
}

if (rulePredicates.length > 0) {
const source = resolveEndpoint(rel.source)
const target = resolveEndpoint(rel.target)
for (const { pred } of rulePredicates) {
if (
pred({
source: source as any,
target: target as any,
kind: rel.kind as any,
tags: rel.tags as any,
metadata: rel.metadata as any,
})
) {
return true
}
}
}

return false
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Significant duplication with element-view/compute.ts#buildExpandPredicate.

Apart from flattenGroupRules (missing here) and resolveEndpoint (deployment-specific), the two functions are line-for-line identical: same multipleRules/multipleFalseRules partitioning, same multipleKinds derivation, same precedence loop, same return semantics. Extracting a shared buildMultipleExpansionPredicate({ rules, model, exprToPred, normalizeRules, resolveEndpoint }) helper into compute-view/utils/ would prevent the two copies from drifting (which has already started — see the flattenGroupRules divergence).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/compute.ts` around lines 31 -
140, The current buildExpandPredicate in compute-view/deployment-view/compute.ts
is a near-duplicate of element-view/compute.ts#buildExpandPredicate; extract the
shared logic into a new helper (e.g. buildMultipleExpansionPredicate) under
compute-view/utils and have both buildExpandPredicate implementations delegate
to it. The helper should accept the model, rules, a relation-expr->predicate
function (deploymentRelationExprToPredicate for this file), an optional
rule-normalizer (flattenGroupRules used by the element view), and a
resolveEndpoint function (deployment-specific here) and implement the
multipleRules/multipleFalseRules partitioning, multipleKinds collection, and the
final predicate precedence logic; then replace the body of the existing
buildExpandPredicate to call this helper with the appropriate args so the
duplicate code is removed.

Comment on lines +195 to +275
function buildExpandPredicate<A extends AnyAux>(
model: LikeC4Model<any>,
rules: ElementViewRule<A>[],
): ((rel: ExpandableRelation) => boolean) | undefined {
const multipleRules = rules
.flatMap(flattenGroupRules(ModelRelationExpr.isCustom))
.filter(r => r.customRelation.multiple === true)

const multipleFalseRules = rules
.flatMap(flattenGroupRules(ModelRelationExpr.isCustom))
.filter(r => r.customRelation.multiple === false)

const multipleKinds = new Set<string>()
const relSpecs = model.specification.relationships
for (const kind of Object.keys(relSpecs)) {
const spec = relSpecs[kind as keyof typeof relSpecs] as any
// The spec may have `multiple` nested under `style` (buildModel.ts) or flat (builder)
const style = spec?.style ?? spec
if (style?.multiple) {
multipleKinds.add(kind)
}
}

if (multipleRules.length === 0 && multipleFalseRules.length === 0 && multipleKinds.size === 0) {
return undefined
}

const rulePredicates = multipleRules.map(r => ({
pred: relationExpressionToPredicates(r.customRelation.expr),
}))
const falseRulePredicates = multipleFalseRules.map(r => ({
pred: relationExpressionToPredicates(r.customRelation.expr),
}))

return (rel: ExpandableRelation) => {
if (falseRulePredicates.length > 0) {
const source = rel.source
const target = rel.target
for (const { pred } of falseRulePredicates) {
if (
pred({
source: source as any,
target: target as any,
kind: rel.kind as any,
tags: rel.tags as any,
metadata: rel.metadata as any,
})
) {
return false
}
}
}

if (multipleKinds.size > 0) {
const kind = rel.kind
if (kind && multipleKinds.has(kind)) {
return true
}
}

if (rulePredicates.length > 0) {
const source = rel.source
const target = rel.target
for (const { pred } of rulePredicates) {
if (
pred({
source: source as any,
target: target as any,
kind: rel.kind as any,
tags: rel.tags as any,
metadata: rel.metadata as any,
})
) {
return true
}
}
}

return false
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Heavy as any usage in production code; consider typed predicate input.

Inside buildExpandPredicate, model: LikeC4Model<any>, the spec is widened with as any, and every property passed to pred(...) is cast with as any (lines 210, 236-240, 261-265). The same shape is duplicated almost verbatim in packages/core/src/compute-view/deployment-view/compute.ts. This contradicts the repo's "TypeScript-first; avoid any/as" guidance and makes refactors of relationExpressionToPredicates's contract silently unsafe.

Recommendations:

  • Type the predicate input (PredicateInput) explicitly in relationExpressionToPredicates.ts and accept RelationshipModel (or a narrowed structural type) directly so call sites don't need to as any per property.
  • Extract a single buildExpandPredicate (or a buildMultipleExpansionRules helper) shared by both element- and deployment-view computes, parameterized by the predicate factory and the endpoint resolver. The two copies currently differ only in (a) whether group rules are flattened (see separate comment) and (b) the resolveEndpoint step in the deployment variant.

As per coding guidelines: "Avoid using any, casts with as" and "Agent types and interfaces should be defined separately and reused across the codebase".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/element-view/compute.ts` around lines 195 -
275, The buildExpandPredicate function is using wide any casts for model/spec
and every predicate call (see buildExpandPredicate, ExpandableRelation,
LikeC4Model, relationExpressionToPredicates), which violates the TS-first
guideline; fix it by (1) defining and exporting a concrete PredicateInput type
(or RelationshipModel shape) in relationExpressionToPredicates.ts and updating
relationExpressionToPredicates to accept that typed input so callers no longer
need as any casts, and (2) extracting the shared logic into a single helper
(e.g. buildMultipleExpansionRules) used by both element- and deployment-view
compute modules, parameterized by the predicate factory and an endpoint
resolver; keep the two callers' differences (flattenGroupRules usage and
resolveEndpoint step) as parameters to the helper and replace all explicit as
any casts in buildExpandPredicate with the new typed input.

Comment thread packages/core/src/compute-view/element-view/compute.ts
Comment thread packages/core/src/compute-view/utils/relationExpressionToPredicates.ts Outdated
@galuszkak galuszkak force-pushed the feature/multiple-relationships-663 branch from 11415a5 to 852ae33 Compare May 8, 2026 11:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/core/src/compute-view/element-view/multiple-relationships.spec.ts (1)

82-124: ⚡ Quick win

Strengthen the “not other kinds” assertion with at least two non-matching relations.

This case currently has only one sync relation, so it passes whether sync is merged or (incorrectly) expanded. Add a second sync relation and assert a single merged sync edge (label: '[...]', relations.length === 2) to truly validate non-matching behavior.

Suggested test adjustment
       const t = TestHelper.from(builder.model(({ rel }, m) =>
         m(
           rel('a', 'b', { title: 'alpha', kind: 'async' }),
           rel('a', 'b', { title: 'beta', kind: 'async' }),
           rel('a', 'b', { title: 'gamma', kind: 'sync' }),
+          rel('a', 'b', { title: 'delta', kind: 'sync' }),
         )
       ))
@@
-      expect(view.edges).toHaveLength(3)
-      const expanded = view.edges.filter(e => e.label !== 'gamma')
-      const merged = view.edges.filter(e => e.label === 'gamma')
+      expect(view.edges).toHaveLength(3)
+      const expanded = view.edges.filter(e => ['alpha', 'beta'].includes(e.label ?? ''))
+      const merged = view.edges.filter(e => e.label === '[...]')
       expect(expanded).toHaveLength(2)
       expect(merged).toHaveLength(1)
+      expect(merged[0]!.relations).toHaveLength(2)

As per coding guidelines: "Aim to cover new features with relevant tests; keep test names descriptive".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/element-view/multiple-relationships.spec.ts`
around lines 82 - 124, Add a second non-matching "sync" relation to the
TestHelper.from model so the test actually verifies that non-matching kinds are
merged rather than expanded; update the expectations after computing the view
(using computeView and inspecting view.edges) to assert there is still a single
merged sync edge (filter by the merged edge label, e.g. label === '[...]' or the
expected merged label) and that this merged edge's relations.length === 2, while
the two async relations remain expanded (expect expanded.length === 2 and
merged.length === 1).
packages/core/src/compute-view/deployment-view/utils.ts (1)

204-231: ⚡ Quick win

Cognitive complexity exceeds project threshold; consider extracting helpers.

SonarCloud flags this function at complexity 45 (limit 30). The nested loop combined with the chained guarded assignments for head/tail/color/line is the main contributor. Splitting the “find opposite edge” and “merge properties” concerns into small helpers would bring complexity down without behavior changes.

♻️ Sketch of the refactor
-function mergeBidirectionalEdges<A extends AnyAux>(edges: ComputedEdge<A>[]): ComputedEdge<A>[] {
-  const toRemove = new Set<number>()
-  for (let i = 0; i < edges.length; i++) {
-    if (toRemove.has(i)) continue
-    const edge = edges[i]!
-    for (let j = i + 1; j < edges.length; j++) {
-      if (toRemove.has(j)) continue
-      const other = edges[j]!
-      if (edge.source === other.target && edge.target === other.source && edge.label === other.label) {
-        edge.dir = 'both'
-        const head = edge.head ?? other.head
-        if (head) {
-          if (!edge.head) edge.head = head
-          if (!edge.tail) edge.tail = head
-        }
-        if (other.color) {
-          if (!edge.color) edge.color = other.color
-        }
-        if (other.line) {
-          if (!edge.line) edge.line = other.line
-        }
-        toRemove.add(j)
-        break
-      }
-    }
-  }
-  return toRemove.size > 0 ? edges.filter((_, i) => !toRemove.has(i)) : edges
-}
+const isOpposite = <A extends AnyAux>(a: ComputedEdge<A>, b: ComputedEdge<A>): boolean =>
+  a.source === b.target && a.target === b.source && a.label === b.label
+
+function mergeIntoBidirectional<A extends AnyAux>(edge: ComputedEdge<A>, other: ComputedEdge<A>): void {
+  edge.dir = 'both'
+  const head = edge.head ?? other.head
+  if (head) {
+    edge.head ??= head
+    edge.tail ??= head
+  }
+  edge.color ??= other.color
+  edge.line ??= other.line
+}
+
+function mergeBidirectionalEdges<A extends AnyAux>(edges: ComputedEdge<A>[]): ComputedEdge<A>[] {
+  const toRemove = new Set<number>()
+  for (let i = 0; i < edges.length; i++) {
+    if (toRemove.has(i)) continue
+    const j = edges.findIndex((other, k) => k > i && !toRemove.has(k) && isOpposite(edges[i]!, other))
+    if (j < 0) continue
+    mergeIntoBidirectional(edges[i]!, edges[j]!)
+    toRemove.add(j)
+  }
+  return toRemove.size > 0 ? edges.filter((_, i) => !toRemove.has(i)) : edges
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/utils.ts` around lines 204 -
231, Refactor mergeBidirectionalEdges to reduce cognitive complexity by
extracting the inner lookup and property-merge logic into two small helpers:
create a function findOppositeEdge(edges, startIndex) that returns the index of
the matching opposite edge (checks source/target/label and skips indices in
toRemove), and create mergeEdgeProperties(targetEdge, sourceEdge) that sets dir
= 'both' and consolidates head/tail/color/line using the existing precedence
rules (use head from either edge, assign head to tail if needed, and only set
color/line if missing). Replace the nested loop body in mergeBidirectionalEdges
with a call to findOppositeEdge and, when it returns an index, call
mergeEdgeProperties(edges[i], edges[j]) and mark j in toRemove; preserve use of
toRemove and the final filter behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/compute-view/deployment-view/utils.ts`:
- Around line 268-280: Replace the explicit type assertions using "as
ComputedEdge<A>" in the expandedEdges.push calls with the project's exact(...)
helper to preserve precise typing; locate the two push sites that create edge
objects (the one using id: hashEdgeId(source, target, rel.id), parent:
conn.boundary?.id as scalar.NodeId ?? null, source, target, label: title ??
null, relations: [rel.id], color, line, head, ...props and the similar block
around lines flagged 295-307) and wrap the object passed to expandedEdges.push
with exact({...}) instead of casting, ensuring any uses of conn.boundary?.id
keep the same null logic but without the as cast so the exact call produces a
ComputedEdge<A>-typed value.
- Around line 282-307: The current only(filter(...)) logic that computes
mergedSource is wrong because it mixes deployment IDs with model-level relation
IDs and silently returns undefined when multiple matches exist; remove the
primary lookup entirely to match the default branch: stop computing mergedSource
(remove the only(filter(...)) expression and the mergedSource variable) and call
mergePropsFromRelationships with only merged.map(r => r.$relationship) (i.e.,
pass no primary) in the nonExpandedEdges block where merged and
mergePropsFromRelationships are used so the merged path behaves consistently
with the default path.

---

Nitpick comments:
In `@packages/core/src/compute-view/deployment-view/utils.ts`:
- Around line 204-231: Refactor mergeBidirectionalEdges to reduce cognitive
complexity by extracting the inner lookup and property-merge logic into two
small helpers: create a function findOppositeEdge(edges, startIndex) that
returns the index of the matching opposite edge (checks source/target/label and
skips indices in toRemove), and create mergeEdgeProperties(targetEdge,
sourceEdge) that sets dir = 'both' and consolidates head/tail/color/line using
the existing precedence rules (use head from either edge, assign head to tail if
needed, and only set color/line if missing). Replace the nested loop body in
mergeBidirectionalEdges with a call to findOppositeEdge and, when it returns an
index, call mergeEdgeProperties(edges[i], edges[j]) and mark j in toRemove;
preserve use of toRemove and the final filter behavior.

In `@packages/core/src/compute-view/element-view/multiple-relationships.spec.ts`:
- Around line 82-124: Add a second non-matching "sync" relation to the
TestHelper.from model so the test actually verifies that non-matching kinds are
merged rather than expanded; update the expectations after computing the view
(using computeView and inspecting view.edges) to assert there is still a single
merged sync edge (filter by the merged edge label, e.g. label === '[...]' or the
expected merged label) and that this merged edge's relations.length === 2, while
the two async relations remain expanded (expect expanded.length === 2 and
merged.length === 1).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d8911d0e-f6b7-46ef-afd7-fbca46932dcc

📥 Commits

Reviewing files that changed from the base of the PR and between 11415a5 and 852ae33.

⛔ Files ignored due to path filters (5)
  • packages/core/src/compute-view/element-view/__test__/__snapshots__/legacy.spec.ts.snap is excluded by !**/*.snap
  • packages/generators/src/model/__snapshots__/likec4.computed-model-with-core.snap is excluded by !**/*.snap
  • packages/generators/src/model/__snapshots__/likec4.computed-model.snap is excluded by !**/*.snap
  • packages/language-server/src/model/__tests__/__snapshots__/model-builder.spec.ts.snap is excluded by !**/*.snap
  • packages/layouts/src/graphviz/wasm/__snapshots__/GraphvizWasmAdapter.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (38)
  • .changeset/multiple-relationships-in-views.md
  • apps/docs/src/content/docs/dsl/Views/predicates.mdx
  • apps/docs/src/content/docs/dsl/specification.mdx
  • apps/docs/src/content/docs/dsl/styling.mdx
  • e2e/src/likec4-model.spec.ts
  • e2e/src/likec4-model.test-d.ts
  • e2e/src/likec4-views.spec.ts
  • e2e/src/likec4-views.test-d.ts
  • e2e/src/likec4/_spec.c4
  • e2e/src/likec4/cloud/next.c4
  • e2e/src/likec4/views.c4
  • packages/core/src/builder/Builder.view-common.ts
  • packages/core/src/builder/__snapshots__/Builder-style2.compute-model.json5
  • packages/core/src/builder/__snapshots__/Builder-style2.general.json5
  • packages/core/src/compute-view/deployment-view/compute.ts
  • packages/core/src/compute-view/deployment-view/utils.spec.ts
  • packages/core/src/compute-view/deployment-view/utils.ts
  • packages/core/src/compute-view/element-view/compute.ts
  • packages/core/src/compute-view/element-view/multiple-relationships.spec.ts
  • packages/core/src/compute-view/element-view/utils.spec.ts
  • packages/core/src/compute-view/element-view/utils.ts
  • packages/core/src/compute-view/utils/applyCustomRelationProperties.ts
  • packages/core/src/compute-view/utils/relationExpressionToPredicates.ts
  • packages/core/src/compute-view/utils/view-hash.spec.ts
  • packages/core/src/compute-view/utils/view-hash.ts
  • packages/core/src/types/expression-model.ts
  • packages/core/src/types/expression.ts
  • packages/core/src/types/model-spec.ts
  • packages/core/src/types/view-common.ts
  • packages/core/src/types/view-parsed.element.ts
  • packages/language-server/src/ast.ts
  • packages/language-server/src/like-c4.langium
  • packages/language-server/src/lsp/CompletionProvider.spec.ts
  • packages/language-server/src/model/__tests__/model-builder.spec.ts
  • packages/language-server/src/model/builder/MergedSpecification.ts
  • packages/language-server/src/model/parser/FqnRefParser.ts
  • packages/language-server/src/model/parser/SpecificationParser.ts
  • packages/language-server/src/model/parser/ViewsParser.ts
✅ Files skipped from review due to trivial changes (15)
  • packages/core/src/builder/snapshots/Builder-style2.general.json5
  • packages/core/src/builder/snapshots/Builder-style2.compute-model.json5
  • packages/core/src/types/view-parsed.element.ts
  • packages/core/src/types/expression.ts
  • apps/docs/src/content/docs/dsl/specification.mdx
  • e2e/src/likec4/_spec.c4
  • e2e/src/likec4/cloud/next.c4
  • packages/core/src/types/view-common.ts
  • apps/docs/src/content/docs/dsl/Views/predicates.mdx
  • apps/docs/src/content/docs/dsl/styling.mdx
  • e2e/src/likec4-views.test-d.ts
  • e2e/src/likec4/views.c4
  • .changeset/multiple-relationships-in-views.md
  • packages/core/src/compute-view/utils/applyCustomRelationProperties.ts
  • packages/core/src/types/expression-model.ts
🚧 Files skipped from review as they are similar to previous changes (19)
  • packages/language-server/src/ast.ts
  • packages/language-server/src/lsp/CompletionProvider.spec.ts
  • packages/language-server/src/model/builder/MergedSpecification.ts
  • packages/language-server/src/model/parser/FqnRefParser.ts
  • packages/core/src/builder/Builder.view-common.ts
  • packages/core/src/compute-view/deployment-view/utils.spec.ts
  • packages/language-server/src/model/tests/model-builder.spec.ts
  • e2e/src/likec4-views.spec.ts
  • packages/core/src/compute-view/element-view/compute.ts
  • packages/language-server/src/model/parser/ViewsParser.ts
  • packages/core/src/compute-view/utils/view-hash.ts
  • e2e/src/likec4-model.spec.ts
  • packages/language-server/src/model/parser/SpecificationParser.ts
  • packages/core/src/compute-view/deployment-view/compute.ts
  • packages/core/src/compute-view/element-view/utils.ts
  • e2e/src/likec4-model.test-d.ts
  • packages/core/src/compute-view/utils/view-hash.spec.ts
  • packages/core/src/compute-view/utils/relationExpressionToPredicates.ts
  • packages/core/src/types/model-spec.ts

Comment on lines +268 to +280
expandedEdges.push({
id: hashEdgeId(source, target, rel.id),
parent: conn.boundary?.id as scalar.NodeId ?? null,
source,
target,
label: title ?? null,
relations: [rel.id],
color,
line,
head,
...props,
} as ComputedEdge<A>)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Replace as ComputedEdge<A> casts with exact({...}) for consistency.

The default branch at lines 320–331 builds the edge with exact({...}), which gives the same typing without an explicit assertion. The two new push sites use as ComputedEdge<A> instead; this is also flagged by the project guidelines.

♻️ Proposed change
-          expandedEdges.push({
+          expandedEdges.push(exact({
             id: hashEdgeId(source, target, rel.id),
             parent: conn.boundary?.id as scalar.NodeId ?? null,
             source,
             target,
             label: title ?? null,
             relations: [rel.id],
             color,
             line,
             head,
             ...props,
-          } as ComputedEdge<A>)
+          }))
-          nonExpandedEdges.push({
+          nonExpandedEdges.push(exact({
             id: conn.id,
             parent: conn.boundary?.id as scalar.NodeId ?? null,
             source,
             target,
             label: title ?? null,
             relations: merged.map((r) => r.id),
             color,
             line,
             head,
             ...props,
-          } as ComputedEdge<A>)
+          }))

As per coding guidelines, "Avoid using any, casts with as".

Also applies to: 295-307

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/utils.ts` around lines 268 -
280, Replace the explicit type assertions using "as ComputedEdge<A>" in the
expandedEdges.push calls with the project's exact(...) helper to preserve
precise typing; locate the two push sites that create edge objects (the one
using id: hashEdgeId(source, target, rel.id), parent: conn.boundary?.id as
scalar.NodeId ?? null, source, target, label: title ?? null, relations:
[rel.id], color, line, head, ...props and the similar block around lines flagged
295-307) and wrap the object passed to expandedEdges.push with exact({...})
instead of casting, ensuring any uses of conn.boundary?.id keep the same null
logic but without the as cast so the exact call produces a ComputedEdge<A>-typed
value.

Comment on lines +282 to +307
if (merged.length > 0) {
const mergedSource = only(filter(merged, r => r.source.id === source && r.target.id === target))
const {
title,
color = defaults.relationship.color,
line = defaults.relationship.line,
head = defaults.relationship.arrow,
...props
} = mergePropsFromRelationships(
merged.map(r => r.$relationship),
mergedSource?.$relationship,
)

nonExpandedEdges.push({
id: conn.id,
parent: conn.boundary?.id as scalar.NodeId ?? null,
source,
target,
label: title ?? null,
relations: merged.map((r) => r.id),
color,
line,
head,
...props,
} as ComputedEdge<A>)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify how mergePropsFromRelationships uses its second (primary) parameter and
# whether callers conventionally pass it.
fd merge-props-from-relationships --type f
ast-grep --pattern $'export function mergePropsFromRelationships($$$) { $$$ }'
rg -nP --type=ts -C2 '\bmergePropsFromRelationships\s*\('

Repository: likec4/likec4

Length of output: 16560


🏁 Script executed:

cat -n packages/core/src/compute-view/utils/merge-props-from-relationships.ts | head -50

Repository: likec4/likec4

Length of output: 1845


🏁 Script executed:

cat -n packages/core/src/compute-view/deployment-view/utils.ts | sed -n '250,330p'

Repository: likec4/likec4

Length of output: 2956


🏁 Script executed:

rg -A 5 'type.*Relation' packages/core/src/compute-view/deployment-view/utils.ts | head -30

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Look at the relation types and structure in deployment-view/utils.ts
rg -B 20 'partition\(r => shouldExpand' packages/core/src/compute-view/deployment-view/utils.ts | head -50

Repository: likec4/likec4

Length of output: 738


🏁 Script executed:

# Check the types for relations in this file
rg -n 'relations.*:' packages/core/src/compute-view/deployment-view/utils.ts | head -20

Repository: likec4/likec4

Length of output: 195


🏁 Script executed:

# Look for conn.relations structure and how relations are populated
rg -B 10 -A 5 'conn\.relations' packages/core/src/compute-view/deployment-view/utils.ts | head -80

Repository: likec4/likec4

Length of output: 600


🏁 Script executed:

# Find type definitions for relation.source.id and relation.target.id
rg -n 'relations\.model.*:' packages/core/src/compute-view/deployment-view/utils.ts
rg -n 'relations\.deployment.*:' packages/core/src/compute-view/deployment-view/utils.ts

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Look at DeploymentConnectionModel type definition
ast-grep --pattern $'type DeploymentConnectionModel<A extends AnyAux> = { $$$ }'

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Check the element-view version at line 84 for comparison
cat -n packages/core/src/compute-view/element-view/utils.ts | sed -n '75,95p'

Repository: likec4/likec4

Length of output: 971


🏁 Script executed:

# Find where relations.model and relations.deployment are defined/typed
rg -n 'model.*:.*\[\]' packages/core/src/compute-view/deployment-view/utils.ts | head -10

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Look at DeploymentConnectionModel structure more directly
fd -t f '\.ts$' packages/core/src -x grep -l 'DeploymentConnectionModel'

Repository: likec4/likec4

Length of output: 860


🏁 Script executed:

# Get the type definition from the types file
rg -A 15 'type DeploymentConnectionModel' packages/core/src/types

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Check the actual relation object structure - what is r.source and r.target
rg -B 5 -A 10 'source\.id.*target\.id' packages/core/src/compute-view/deployment-view/utils.ts | head -50

Repository: likec4/likec4

Length of output: 606


🏁 Script executed:

# Find and read the DeploymentConnectionModel type definition
cat packages/core/src/model/connection/deployment/DeploymentConnectionModel.ts | head -100

Repository: likec4/likec4

Length of output: 2972


🏁 Script executed:

# Check what type relations.model and relations.deployment are
rg -A 5 'relations\s*:' packages/core/src/model/connection/deployment/DeploymentConnectionModel.ts

Repository: likec4/likec4

Length of output: 207


🏁 Script executed:

# Check RelationshipsAccum type definition
rg -A 20 'type RelationshipsAccum\|class RelationshipsAccum' packages/core/src/model/DeploymentElementModel.ts | head -50

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Check what type DeploymentRelationModel is and what its source/target look like
rg -A 15 'class DeploymentRelationModel\|type DeploymentRelationModel' packages/core/src/model/DeploymentElementModel.ts | head -50

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Search more broadly for RelationshipsAccum
rg 'RelationshipsAccum' packages/core/src -A 10 | head -80

Repository: likec4/likec4

Length of output: 7036


🏁 Script executed:

# Look at what's imported in DeploymentElementModel
head -50 packages/core/src/model/DeploymentElementModel.ts

Repository: likec4/likec4

Length of output: 2056


🏁 Script executed:

# Check RelationshipModel and its source/target properties
rg -A 20 'class RelationshipModel\|interface RelationshipModel' packages/core/src/model/RelationModel.ts | head -60

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Check DeploymentRelationModel
rg -A 20 'class DeploymentRelationModel' packages/core/src/model/DeploymentElementModel.ts | head -60

Repository: likec4/likec4

Length of output: 841


🏁 Script executed:

# Understand the ID types - what are source.id and target.id in each
rg -B 5 -A 15 'source.*id.*target' packages/core/src/model/RelationModel.ts | head -80

Repository: likec4/likec4

Length of output: 988


🏁 Script executed:

# Check DeploymentRelationEndpoint type
rg -A 10 'type DeploymentRelationEndpoint\|interface DeploymentRelationEndpoint' packages/core/src/model/DeploymentElementModel.ts

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Look at element-view utils for context and see if it has the same pattern
cat -n packages/core/src/compute-view/element-view/utils.ts | sed -n '50,120p'

Repository: likec4/likec4

Length of output: 2726


🏁 Script executed:

# Check the only() function behavior from remeda
rg -B 5 -A 10 "export.*only" node_modules/remeda/dist/index.d.ts 2>/dev/null | head -30

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Look at element-view more closely - understand source/target type
cat -n packages/core/src/compute-view/element-view/utils.ts | sed -n '1,80p'

Repository: likec4/likec4

Length of output: 3419


🏁 Script executed:

# Check if there's a comment or issue reference about this behavior
rg -B 5 -A 5 '1423\|primary.*relation\|mergedSource' packages/core/src/compute-view/

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Understand the full context - check if conn.relations are processed differently
cat -n packages/core/src/compute-view/deployment-view/utils.ts | sed -n '200,250p'

Repository: likec4/likec4

Length of output: 2194


🏁 Script executed:

# Check if model relations are ever used/relevant in deployment view
rg 'conn\.relations\.model' packages/core/src/compute-view/deployment-view/utils.ts

Repository: likec4/likec4

Length of output: 88


🏁 Script executed:

# Verify the two types of source/target - check FqnRef usage in deployment model
rg -B 3 'deploymentRef' packages/core/src/model/DeploymentModel.ts | head -30

Repository: likec4/likec4

Length of output: 97


🏁 Script executed:

# Get the full deploymentRef function
rg -A 20 'public deploymentRef' packages/core/src/model/DeploymentModel.ts

Repository: likec4/likec4

Length of output: 764


🏁 Script executed:

# Check RelationshipModel source/target type - what's the FQN?
rg -B 10 'this\.source = model\.element' packages/core/src/model/RelationModel.ts | head -30

Repository: likec4/likec4

Length of output: 409


🏁 Script executed:

# Look at the types of source and target in relation objects
rg 'source.*ElementModel\|target.*ElementModel' packages/core/src/model/RelationModel.ts

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Find the actual types used - specifically what are ElementModel and DeploymentElementModel IDs
rg 'readonly id.*:.*FqnRef\|readonly id.*:.*NodeId\|readonly id.*:.*DeploymentFqn' packages/core/src/model/*.ts | head -20

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Check what ElementModel.id actually is
rg -A 5 'class ElementModel' packages/core/src/model/ElementModel.ts | head -20

Repository: likec4/likec4

Length of output: 232


🏁 Script executed:

# Verify conn.source.id type in deployment context
rg -B 5 'conn\.source\.id.*scalar\.NodeId' packages/core/src/compute-view/deployment-view/utils.ts

Repository: likec4/likec4

Length of output: 253


🏁 Script executed:

# Check the full mergePropsFromRelationships implementation to see how it uses the second parameter
cat -n packages/core/src/compute-view/utils/merge-props-from-relationships.ts | sed -n '50,150p'

Repository: likec4/likec4

Length of output: 3829


🏁 Script executed:

# Count actual usages to see the pattern - with vs without primary parameter
rg 'mergePropsFromRelationships\(' packages/core/src/compute-view -A 1 | grep -E '(mergePropsFromRelationships|$relationship)' | head -30

Repository: likec4/likec4

Length of output: 3863


🏁 Script executed:

# Verify the exact types being used - check the ID type of DeploymentRelationModel.source/target
rg -A 30 'class DeploymentRelationModel' packages/core/src/model/DeploymentElementModel.ts

Repository: likec4/likec4

Length of output: 1074


🏁 Script executed:

# Check if there's any type conversion happening in the filter
cat -n packages/core/src/compute-view/deployment-view/utils.ts | sed -n '280,295p'

Repository: likec4/likec4

Length of output: 699


🏁 Script executed:

# Look at test coverage for this specific scenario (merged + expanded in deployment view)
fd '\.spec\.ts$' packages/core/src/compute-view/deployment-view -x grep -l 'merged\|expand' | head -5

Repository: likec4/likec4

Length of output: 440


🏁 Script executed:

# Check if there are tests for the merged+expanded scenario in deployment view
cat packages/core/src/compute-view/deployment-view/utils.spec.ts | head -100

Repository: likec4/likec4

Length of output: 2829


🏁 Script executed:

# Look for any existing issues or TODOs related to this
rg 'TODO\|FIXME\|BUG' packages/core/src/compute-view/deployment-view/utils.ts

Repository: likec4/likec4

Length of output: 39


🏁 Script executed:

# Verify: in the merged array, are there actually model relations being passed?
cat -n packages/core/src/compute-view/deployment-view/utils.ts | sed -n '240,285p'

Repository: likec4/likec4

Length of output: 1817


only(filter(...)) lookup for the merged "primary" has two issues: type mismatch when mixing relations and silent loss when multiple matches exist.

The filter at line 283 compares r.source.id and r.target.id against deployment node IDs (source and target). When merged contains model-level relations (from conn.relations.model), their source/target IDs are model FQNs, not deployment FQNs, so the equality check fails and mergedSource is always undefined for them. Additionally, only() returns undefined when there are 0 or more than one matches, so when multiple merged relations point in the same direction, the primary silently drops. This creates inconsistency: the default path at line 318 calls mergePropsFromRelationships without a primary, but the expanded+merged path passes one inconsistently.

Either remove the primary altogether (consistent with the default branch) or filter by the relation's own endpoints rather than comparing against deployment node IDs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/utils.ts` around lines 282 -
307, The current only(filter(...)) logic that computes mergedSource is wrong
because it mixes deployment IDs with model-level relation IDs and silently
returns undefined when multiple matches exist; remove the primary lookup
entirely to match the default branch: stop computing mergedSource (remove the
only(filter(...)) expression and the mergedSource variable) and call
mergePropsFromRelationships with only merged.map(r => r.$relationship) (i.e.,
pass no primary) in the nonExpandedEdges block where merged and
mergePropsFromRelationships are used so the merged path behaves consistently
with the default path.

@galuszkak

Copy link
Copy Markdown
Collaborator Author

Something is wrong in this screenshots... let me look into this.

@galuszkak

Copy link
Copy Markdown
Collaborator Author

Actually they look OK - I just forgot what was in e2e examples.

@galuszkak

Copy link
Copy Markdown
Collaborator Author

But I've added one more e2e tests and it looks like it's not properly expanding in some cases - let me send one more fix...

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/core/src/compute-view/deployment-view/utils.ts (1)

204-231: ⚡ Quick win

mergeBidirectionalEdges mutates its inputs and trips SonarCloud complexity.

Two concerns to address together:

  1. The function mutates the edge objects it receives (edge.dir, edge.head, edge.tail, edge.color, edge.line). The signature suggests a pure transform; in-place mutation is fine while the caller is the only owner, but it leaks a side effect through the public-ish API surface and makes future reuse risky.
  2. SonarCloud reports cognitive complexity of 45 here (limit 30). The nested for/for plus the in-loop merge bookkeeping is the main contributor.

Extracting an applyBidirectionalMerge(edge, other) helper that returns a fresh merged edge (and dropping the toRemove Set in favor of a simple result list keyed by source|target|label) addresses both at once.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/utils.ts` around lines 204 -
231, mergeBidirectionalEdges currently mutates input edge objects and is overly
complex; refactor it to produce new edge objects and simplify control flow by
extracting a helper applyBidirectionalMerge(edge, other) that returns a fresh
merged ComputedEdge (combining dir='both', head/tail/color/line with preference
rules) instead of modifying either input, and replace the nested for/Set removal
pattern with a single-pass accumulation keyed by a unique key (e.g.,
`${source}|${target}|${label}` or its normalized bidirectional equivalent) that
either inserts the original edge or replaces it with the result of
applyBidirectionalMerge when a matching reverse edge is found; ensure the final
function returns a new array of edges without mutating any input objects and
that applyBidirectionalMerge encapsulates the merge precedence logic for
head/tail/color/line.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/compute-view/deployment-view/utils.ts`:
- Around line 213-224: When merging bidirectional edges in utils.ts (symbols:
edge, other, head, tail, color, line), preserve side-specific properties instead
of copying one side over both: set edge.dir='both', assign edge.head = edge.head
?? other.tail ?? other.head and edge.tail = other.head ?? edge.tail ??
other.tail so the reversed edge's head becomes this merged tail (and reversed
tail can become head if present); for styling, prefer preserving side-specific
values by setting edge.colorHead/edge.colorTail (or if only color/line fields
exist, set edge.color = edge.color ?? other.color but also capture other-side
styling into separate fields or merge only when missing) — update the merge
logic around the head/tail assignments and color/line handling to map other.head
-> edge.tail and other.tail -> edge.head, and only fall back to existing values
when the counterpart is absent.

---

Nitpick comments:
In `@packages/core/src/compute-view/deployment-view/utils.ts`:
- Around line 204-231: mergeBidirectionalEdges currently mutates input edge
objects and is overly complex; refactor it to produce new edge objects and
simplify control flow by extracting a helper applyBidirectionalMerge(edge,
other) that returns a fresh merged ComputedEdge (combining dir='both',
head/tail/color/line with preference rules) instead of modifying either input,
and replace the nested for/Set removal pattern with a single-pass accumulation
keyed by a unique key (e.g., `${source}|${target}|${label}` or its normalized
bidirectional equivalent) that either inserts the original edge or replaces it
with the result of applyBidirectionalMerge when a matching reverse edge is
found; ensure the final function returns a new array of edges without mutating
any input objects and that applyBidirectionalMerge encapsulates the merge
precedence logic for head/tail/color/line.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dd4e1deb-c3a2-41c1-8b0c-8dbf7b335592

📥 Commits

Reviewing files that changed from the base of the PR and between 852ae33 and ab8fefb.

⛔ Files ignored due to path filters (6)
  • e2e/tests/__screenshots__/chromium-linux/e2e-backend.png is excluded by !**/*.png
  • e2e/tests/__screenshots__/chromium-linux/e2e-cloud-next.png is excluded by !**/*.png
  • e2e/tests/__screenshots__/chromium-linux/e2e-cloud.png is excluded by !**/*.png
  • e2e/tests/__screenshots__/chromium-linux/e2e-deploy-1.png is excluded by !**/*.png
  • e2e/tests/__screenshots__/chromium-linux/e2e-multiple-expanded.png is excluded by !**/*.png
  • e2e/tests/__screenshots__/chromium-linux/e2e-multiple-merged.png is excluded by !**/*.png
📒 Files selected for processing (11)
  • e2e/src/likec4-model.spec.ts
  • e2e/src/likec4-model.test-d.ts
  • e2e/src/likec4-views.spec.ts
  • e2e/src/likec4-views.test-d.ts
  • e2e/src/likec4/views.c4
  • packages/core/src/compute-view/deployment-view/compute.ts
  • packages/core/src/compute-view/deployment-view/utils.ts
  • packages/core/src/compute-view/element-view/compute.ts
  • packages/core/src/compute-view/element-view/multiple-relationships.spec.ts
  • packages/core/src/compute-view/element-view/utils.ts
  • packages/core/src/compute-view/utils/relationExpressionToPredicates.ts
✅ Files skipped from review due to trivial changes (1)
  • e2e/src/likec4-views.test-d.ts
🚧 Files skipped from review as they are similar to previous changes (8)
  • packages/core/src/compute-view/utils/relationExpressionToPredicates.ts
  • e2e/src/likec4-views.spec.ts
  • e2e/src/likec4-model.spec.ts
  • packages/core/src/compute-view/element-view/multiple-relationships.spec.ts
  • packages/core/src/compute-view/element-view/compute.ts
  • e2e/src/likec4-model.test-d.ts
  • packages/core/src/compute-view/element-view/utils.ts
  • packages/core/src/compute-view/deployment-view/compute.ts

Comment on lines +213 to +224
edge.dir = 'both'
const head = edge.head ?? other.head
if (head) {
if (!edge.head) edge.head = head
if (!edge.tail) edge.tail = head
}
if (other.color) {
if (!edge.color) edge.color = other.color
}
if (other.line) {
if (!edge.line) edge.line = other.line
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bidirectional arrow merge drops other.head/other.tail.

When merging A→B with B→A, the natural mapping is head = edge.head (arrow at B) and tail = other.head (arrow at A, which is the merged edge's tail). The current logic computes head = edge.head ?? other.head and then assigns the same value to both edge.head and edge.tail. As a result:

  • If edge.head is set, other.head is ignored entirely and edge.tail becomes a copy of edge.head (asymmetric arrows are flattened).
  • other.tail is never consulted.

Same concern applies, less critically, to color/line: only edge's values win when both are set, so other's side-specific styling is lost. Consider mapping the reversed edge's head onto tail and (if present) its tail onto head.

♻️ Suggested mapping
-      if (edge.source === other.target && edge.target === other.source && edge.label === other.label) {
-        edge.dir = 'both'
-        const head = edge.head ?? other.head
-        if (head) {
-          if (!edge.head) edge.head = head
-          if (!edge.tail) edge.tail = head
-        }
-        if (other.color) {
-          if (!edge.color) edge.color = other.color
-        }
-        if (other.line) {
-          if (!edge.line) edge.line = other.line
-        }
-        toRemove.add(j)
-        break
-      }
+      if (edge.source === other.target && edge.target === other.source && edge.label === other.label) {
+        edge.dir = 'both'
+        // other goes the opposite way, so its head sits at the merged edge's tail end
+        edge.head ??= other.tail ?? other.head
+        edge.tail ??= other.head
+        edge.color ??= other.color
+        edge.line ??= other.line
+        toRemove.add(j)
+        break
+      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/utils.ts` around lines 213 -
224, When merging bidirectional edges in utils.ts (symbols: edge, other, head,
tail, color, line), preserve side-specific properties instead of copying one
side over both: set edge.dir='both', assign edge.head = edge.head ?? other.tail
?? other.head and edge.tail = other.head ?? edge.tail ?? other.tail so the
reversed edge's head becomes this merged tail (and reversed tail can become head
if present); for styling, prefer preserving side-specific values by setting
edge.colorHead/edge.colorTail (or if only color/line fields exist, set
edge.color = edge.color ?? other.color but also capture other-side styling into
separate fields or merge only when missing) — update the merge logic around the
head/tail assignments and color/line handling to map other.head -> edge.tail and
other.tail -> edge.head, and only fall back to existing values when the
counterpart is absent.

galuszkak and others added 4 commits May 8, 2026 20:30
Assisted-by: opencode-go:deepseek-v4-pro
Reviewed-by: openai:gpt-5.5
Co-authored-by: davydkov <824903+davydkov@users.noreply.github.com>
Co-authored-by: galuszkak <759579+galuszkak@users.noreply.github.com>
@galuszkak galuszkak force-pushed the feature/multiple-relationships-663 branch from 9e5b8da to 0913e90 Compare May 8, 2026 18:30

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
packages/core/src/compute-view/deployment-view/utils.ts (3)

268-280: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

as ComputedEdge<A> cast still present (applies again at 295-307).

The default branch at lines 320-331 already builds the edge via exact({...}) without an explicit assertion. The two new push sites continue to use as ComputedEdge<A>, conflicting with both the existing local pattern and the project guideline to avoid as casts. Per coding guidelines: "Avoid using any, casts with as".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/utils.ts` around lines 268 -
280, The pushes that create edges use an explicit cast "as ComputedEdge<A>" (see
the expandedEdges.push that builds id via hashEdgeId and the other new push
site), which violates the guideline to avoid "as" casts; replace those casts by
constructing the edge through the project's type-safe helper (the same pattern
used by the existing default branch) — call exact({...}) with the same
properties (id, parent, source, target, label, relations, color, line, head,
...props) instead of using "as ComputedEdge<A>", so TypeScript will
infer/validate the correct ComputedEdge<A> type without an "as" assertion.

213-224: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bidirectional merge still flattens asymmetric arrows and ignores other.tail.

The previously raised concern still applies to the current code: head = edge.head ?? other.head is then assigned to both edge.head and edge.tail, which copies one side over both ends. other.tail is never consulted, and side-specific color/line from other are also lost when edge already has values set.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/utils.ts` around lines 213 -
224, The merge currently sets head = edge.head ?? other.head and then assigns
that value to both edge.head and edge.tail, which flattens asymmetric arrows and
ignores other.tail; change the logic to set each side independently: set
edge.head = edge.head ?? other.head and edge.tail = edge.tail ?? other.tail (do
not assign one computed variable to both ends), and when merging styling only
assign edge.color or edge.line from other if the corresponding side is missing
(i.e., only set edge.color/edge.line when undefined, and if your model supports
per-side styling prefer other.head/other.tail variants), so that edge.head,
edge.tail, edge.color and edge.line are merged side-by-side rather than
overwritten with a single value.

282-293: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

only(filter(...)) for mergedSource still inconsistent with the default branch.

Same concern as before: this filter compares relation source.id/target.id against deployment node IDs, so model-level relations in merged never match. Even when types align, only() returns undefined for >1 matches, silently dropping the primary. Meanwhile the default branch (line 318) calls mergePropsFromRelationships with no primary, leaving the two paths inconsistent. Either drop the mergedSource argument here for symmetry with the default branch, or filter against the relation's own endpoints rather than deployment node IDs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/utils.ts` around lines 282 -
293, The mergedSource lookup is using only(filter(...)) which mismatches the
default branch and can return undefined for >1 matches; update the call site so
both branches are consistent by removing the primary relationship argument
(mergedSource) from the mergePropsFromRelationships invocation in this block
(i.e., call mergePropsFromRelationships(merged.map(r => r.$relationship)) ), or
alternatively change the filter logic to compare against the relationship
endpoints instead of deployment node IDs if you intend to keep a primary;
reference merged, mergedSource, filter, only, and mergePropsFromRelationships to
locate and apply the change.
🧹 Nitpick comments (1)
packages/core/src/compute-view/deployment-view/utils.ts (1)

233-337: ⚡ Quick win

Consider extracting helpers to reduce toComputedEdges complexity.

SonarCloud flags cognitive complexity of 45 (allowed 30) in this region. Extracting the per-branch edge construction (e.g., buildExpandedEdge(rel, conn, defaults), buildMergedEdge(merged, conn, defaults), buildSingleEdge(relations, conn, defaults)) would also let you delete the duplicated destructuring/mergePropsFromRelationships/exact({...}) blocks shared between the three push sites and naturally resolve the duplicate as ComputedEdge<A> casts noted above.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/compute-view/deployment-view/utils.ts` around lines 233 -
337, toComputedEdges is too complex and duplicates logic for building edges;
extract three helper functions (e.g., buildExpandedEdge(rel, conn, defaults),
buildMergedEdge(merged, conn, defaults) and buildSingleEdge(relations, conn,
defaults)) that encapsulate the destructuring/mergePropsFromRelationships calls
and construction of the ComputedEdge (including id via hashEdgeId or conn.id,
parent, source, target, label, relations, color, line, head and remaining props)
so the main loop only decides which helper to call and pushes its result(s);
ensure helpers return properly typed ComputedEdge<A> (avoiding repeated `as
ComputedEdge<A>`), reuse only(...) and mergeBidirectionalEdges as before, and
keep mergePropsFromRelationships usage inside the helpers to remove duplication
and reduce cognitive complexity.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@packages/core/src/compute-view/deployment-view/utils.ts`:
- Around line 268-280: The pushes that create edges use an explicit cast "as
ComputedEdge<A>" (see the expandedEdges.push that builds id via hashEdgeId and
the other new push site), which violates the guideline to avoid "as" casts;
replace those casts by constructing the edge through the project's type-safe
helper (the same pattern used by the existing default branch) — call
exact({...}) with the same properties (id, parent, source, target, label,
relations, color, line, head, ...props) instead of using "as ComputedEdge<A>",
so TypeScript will infer/validate the correct ComputedEdge<A> type without an
"as" assertion.
- Around line 213-224: The merge currently sets head = edge.head ?? other.head
and then assigns that value to both edge.head and edge.tail, which flattens
asymmetric arrows and ignores other.tail; change the logic to set each side
independently: set edge.head = edge.head ?? other.head and edge.tail = edge.tail
?? other.tail (do not assign one computed variable to both ends), and when
merging styling only assign edge.color or edge.line from other if the
corresponding side is missing (i.e., only set edge.color/edge.line when
undefined, and if your model supports per-side styling prefer
other.head/other.tail variants), so that edge.head, edge.tail, edge.color and
edge.line are merged side-by-side rather than overwritten with a single value.
- Around line 282-293: The mergedSource lookup is using only(filter(...)) which
mismatches the default branch and can return undefined for >1 matches; update
the call site so both branches are consistent by removing the primary
relationship argument (mergedSource) from the mergePropsFromRelationships
invocation in this block (i.e., call mergePropsFromRelationships(merged.map(r =>
r.$relationship)) ), or alternatively change the filter logic to compare against
the relationship endpoints instead of deployment node IDs if you intend to keep
a primary; reference merged, mergedSource, filter, only, and
mergePropsFromRelationships to locate and apply the change.

---

Nitpick comments:
In `@packages/core/src/compute-view/deployment-view/utils.ts`:
- Around line 233-337: toComputedEdges is too complex and duplicates logic for
building edges; extract three helper functions (e.g., buildExpandedEdge(rel,
conn, defaults), buildMergedEdge(merged, conn, defaults) and
buildSingleEdge(relations, conn, defaults)) that encapsulate the
destructuring/mergePropsFromRelationships calls and construction of the
ComputedEdge (including id via hashEdgeId or conn.id, parent, source, target,
label, relations, color, line, head and remaining props) so the main loop only
decides which helper to call and pushes its result(s); ensure helpers return
properly typed ComputedEdge<A> (avoiding repeated `as ComputedEdge<A>`), reuse
only(...) and mergeBidirectionalEdges as before, and keep
mergePropsFromRelationships usage inside the helpers to remove duplication and
reduce cognitive complexity.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e936570f-862f-4424-91db-0286426df31f

📥 Commits

Reviewing files that changed from the base of the PR and between ab8fefb and 0913e90.

⛔ Files ignored due to path filters (12)
  • e2e/tests/__screenshots__/chromium-linux/e2e-backend.png is excluded by !**/*.png
  • e2e/tests/__screenshots__/chromium-linux/e2e-cloud-next.png is excluded by !**/*.png
  • e2e/tests/__screenshots__/chromium-linux/e2e-cloud.png is excluded by !**/*.png
  • e2e/tests/__screenshots__/chromium-linux/e2e-deploy-1.png is excluded by !**/*.png
  • e2e/tests/__screenshots__/chromium-linux/e2e-multiple-expanded.png is excluded by !**/*.png
  • e2e/tests/__screenshots__/chromium-linux/e2e-multiple-explicit.png is excluded by !**/*.png
  • e2e/tests/__screenshots__/chromium-linux/e2e-multiple-merged.png is excluded by !**/*.png
  • packages/core/src/compute-view/element-view/__test__/__snapshots__/legacy.spec.ts.snap is excluded by !**/*.snap
  • packages/generators/src/model/__snapshots__/likec4.computed-model-with-core.snap is excluded by !**/*.snap
  • packages/generators/src/model/__snapshots__/likec4.computed-model.snap is excluded by !**/*.snap
  • packages/language-server/src/model/__tests__/__snapshots__/model-builder.spec.ts.snap is excluded by !**/*.snap
  • packages/layouts/src/graphviz/wasm/__snapshots__/GraphvizWasmAdapter.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (38)
  • .changeset/multiple-relationships-in-views.md
  • apps/docs/src/content/docs/dsl/Views/predicates.mdx
  • apps/docs/src/content/docs/dsl/specification.mdx
  • apps/docs/src/content/docs/dsl/styling.mdx
  • e2e/src/likec4-model.spec.ts
  • e2e/src/likec4-model.test-d.ts
  • e2e/src/likec4-views.spec.ts
  • e2e/src/likec4-views.test-d.ts
  • e2e/src/likec4/_spec.c4
  • e2e/src/likec4/cloud/next.c4
  • e2e/src/likec4/views.c4
  • packages/core/src/builder/Builder.view-common.ts
  • packages/core/src/builder/__snapshots__/Builder-style2.compute-model.json5
  • packages/core/src/builder/__snapshots__/Builder-style2.general.json5
  • packages/core/src/compute-view/deployment-view/compute.ts
  • packages/core/src/compute-view/deployment-view/utils.spec.ts
  • packages/core/src/compute-view/deployment-view/utils.ts
  • packages/core/src/compute-view/element-view/compute.ts
  • packages/core/src/compute-view/element-view/multiple-relationships.spec.ts
  • packages/core/src/compute-view/element-view/utils.spec.ts
  • packages/core/src/compute-view/element-view/utils.ts
  • packages/core/src/compute-view/utils/applyCustomRelationProperties.ts
  • packages/core/src/compute-view/utils/relationExpressionToPredicates.ts
  • packages/core/src/compute-view/utils/view-hash.spec.ts
  • packages/core/src/compute-view/utils/view-hash.ts
  • packages/core/src/types/expression-model.ts
  • packages/core/src/types/expression.ts
  • packages/core/src/types/model-spec.ts
  • packages/core/src/types/view-common.ts
  • packages/core/src/types/view-parsed.element.ts
  • packages/language-server/src/ast.ts
  • packages/language-server/src/like-c4.langium
  • packages/language-server/src/lsp/CompletionProvider.spec.ts
  • packages/language-server/src/model/__tests__/model-builder.spec.ts
  • packages/language-server/src/model/builder/MergedSpecification.ts
  • packages/language-server/src/model/parser/FqnRefParser.ts
  • packages/language-server/src/model/parser/SpecificationParser.ts
  • packages/language-server/src/model/parser/ViewsParser.ts
✅ Files skipped from review due to trivial changes (12)
  • .changeset/multiple-relationships-in-views.md
  • packages/core/src/builder/snapshots/Builder-style2.general.json5
  • apps/docs/src/content/docs/dsl/specification.mdx
  • apps/docs/src/content/docs/dsl/Views/predicates.mdx
  • packages/core/src/types/view-parsed.element.ts
  • packages/core/src/types/view-common.ts
  • apps/docs/src/content/docs/dsl/styling.mdx
  • packages/core/src/compute-view/utils/applyCustomRelationProperties.ts
  • e2e/src/likec4/_spec.c4
  • packages/core/src/builder/snapshots/Builder-style2.compute-model.json5
  • packages/language-server/src/lsp/CompletionProvider.spec.ts
  • packages/core/src/types/expression-model.ts
🚧 Files skipped from review as they are similar to previous changes (25)
  • packages/language-server/src/model/parser/ViewsParser.ts
  • packages/language-server/src/ast.ts
  • packages/core/src/builder/Builder.view-common.ts
  • e2e/src/likec4-views.test-d.ts
  • e2e/src/likec4-model.test-d.ts
  • e2e/src/likec4-views.spec.ts
  • packages/language-server/src/model/parser/FqnRefParser.ts
  • packages/language-server/src/model/tests/model-builder.spec.ts
  • e2e/src/likec4/views.c4
  • packages/core/src/types/model-spec.ts
  • packages/language-server/src/like-c4.langium
  • e2e/src/likec4-model.spec.ts
  • packages/core/src/compute-view/utils/relationExpressionToPredicates.ts
  • packages/core/src/compute-view/element-view/compute.ts
  • packages/language-server/src/model/builder/MergedSpecification.ts
  • packages/core/src/compute-view/utils/view-hash.ts
  • packages/core/src/types/expression.ts
  • packages/core/src/compute-view/deployment-view/compute.ts
  • packages/core/src/compute-view/element-view/utils.spec.ts
  • packages/core/src/compute-view/element-view/multiple-relationships.spec.ts
  • packages/core/src/compute-view/utils/view-hash.spec.ts
  • packages/core/src/compute-view/deployment-view/utils.spec.ts
  • e2e/src/likec4/cloud/next.c4
  • packages/language-server/src/model/parser/SpecificationParser.ts
  • packages/core/src/compute-view/element-view/utils.ts

@davydkov davydkov enabled auto-merge (squash) May 18, 2026 06:59
@davydkov davydkov merged commit 311b93d into main May 18, 2026
18 checks passed
@davydkov davydkov deleted the feature/multiple-relationships-663 branch May 18, 2026 07:10
@likec4-ci likec4-ci Bot mentioned this pull request May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants