feat: Multiple Relationships in views - implements #663#2939
Conversation
🦋 Changeset detectedLatest commit: 0913e90 The changes in this PR will be included in the next version bump. This PR includes changesets to release 22 packages
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' | |||
📝 WalkthroughWalkthroughImplements a ChangesMultiple Relationships Expansion Feature
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
There was a problem hiding this comment.
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 winType definition mismatch:
RelationshipSpecificationneeds astylewrapper for consistency with buildModel output
RelationshipSpecificationdeclaresmultiple,color,line,head, andtailat the top level, butbuildModel.ts(line 393–396) transforms the relationship spec by extractingnotationandtechnologyseparately, then spreading the rest under astyleobject: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 withstyle.multiple, not top-levelmultiple. This mirrors howElementSpecificationwraps its rendering fields under astylesub-object. The type definition must be updated to reflect the actual runtime structure:color,line,head,tail, andmultipleshould all be nested under astyleproperty.🤖 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
anyusage in factory signature violates the "avoidany" guideline
elementPredicateBuilder: (expr: any) => (node: any) => booleanand the(expr: any) => Predicate<T>return type useanyin production code. The JSDoc acknowledges this and documents the reason (incompatible expression/node types across element vs. deployment view paths), but the type parameterAon the factory is effectively unused sinceelementPredicateBuilderdoesn't reference it — callers cannot inferAfrom theany-typed parameter.If a common base expression type (analogous to
ExpandableRelationfor 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
idis redundant in both the map key and the spread value
mapToObj((e) => [e.id, { ...e }])usese.idas the key while{ ...e }also containsidin the value, so the hash receivesidtwice. 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 valueStale 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
directRelsListis 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). WhenshouldExpand && relations.length > 1and there are expanded relations, the function returns at line 101 without ever usingdirectRelsList. Move the computation just above its first use to avoid the unnecessaryfilterper 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/...propsrepeated 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.
onlyreturnsundefinedwhen the filter yields more than one result, so as soon as two of the "merged" relations are direct (source.id === source && target.id === target),mergedSourcebecomesundefinedandmergePropsFromRelationshipsis 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 withfirst(...).🤖 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 winAddress 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 "ifmerged.length > 0" block repeats it again. Extracting a smallbuildEdgeFromRelations(conn, source, target, defaults, relations, hint?)helper that returns aComputedEdge<A>would collapse three near-identical blocks into one and bring complexity well under the threshold. As a side effect it also removes the twoas 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
mergeBidirectionalEdgesmutates input edge objects in place.
edge.dir = 'both',edge.head = …,edge.color = …,edge.line = …mutate theComputedEdgeobjects that came fromnonExpandedEdges, which were just constructed frommergePropsFromRelationshipsresults and fromexact({...}). While today's caller passes a freshly built array, returning a new array viafilter(...)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 droppingas 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 valueIterate
relSpecsentries directly to avoid the keyof+as anyindirection.
Object.keys(relSpecs)followed byrelSpecs[kind as keyof typeof relSpecs] as anyis convoluted.Object.entries(orfor…inif the order matters) yields the value without the second cast, and the runtime fallbackstyle ?? speccan stay. Also worth confirmingmodel.specification.relationshipsis 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 winAdd coverage for the reverse ordering of
multiple: true/multiple: false.The test asserts
falseoverrides a precedingtrue, but doesn't test the reverse —include('a -> b', { with: { multiple: false } })followed byinclude('a -> b', { with: { multiple: true } }). Given the implementation incompute.tsalways letsfalsewin 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 valueClean 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
resolveEndpointdoes structural inspection onany— fragile and untyped.The endpoint shape detection (
'tags' in ep && 'kind' in ep && 'metadata' in ep, then a fallback that reaches intoep.instance/ep.element) is essentially a runtime type guess. If the deployment model adds another endpoint variant (e.g. a future "service" wrapper that hastagsbut a differentkindsemantics), the predicate will silently produce wrong results.Recommendation: define an explicit
EndpointForPredicatetype and a typedresolveEndpoint(ep: DeploymentEndpoint): EndpointForPredicatefunction indeployment-view/utils.ts, exhaustively switching on the endpoint variants (usingnonexhaustivelike the surrounding code). This also removes the fouras anycasts on the call topred(...).🤖 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 anybridge betweenFqnExprandModelFqnExprhides a real type contract gap.The comment acknowledges the issue ("the
as anybridge is needed because…"). A safer alternative is to makecreateRelationExpressionToPredicatesgeneric over the FQN-expression flavor (or accept a narrowedPredicate<{ 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 withas."🤖 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
⛔ Files ignored due to path filters (5)
packages/core/src/compute-view/element-view/__test__/__snapshots__/legacy.spec.ts.snapis excluded by!**/*.snappackages/generators/src/model/__snapshots__/likec4.computed-model-with-core.snapis excluded by!**/*.snappackages/generators/src/model/__snapshots__/likec4.computed-model.snapis excluded by!**/*.snappackages/language-server/src/model/__tests__/__snapshots__/model-builder.spec.ts.snapis excluded by!**/*.snappackages/layouts/src/graphviz/wasm/__snapshots__/GraphvizWasmAdapter.spec.ts.snapis excluded by!**/*.snap
📒 Files selected for processing (38)
.changeset/multiple-relationships-in-views.mdapps/docs/src/content/docs/dsl/Views/predicates.mdxapps/docs/src/content/docs/dsl/specification.mdxapps/docs/src/content/docs/dsl/styling.mdxe2e/src/likec4-model.spec.tse2e/src/likec4-model.test-d.tse2e/src/likec4-views.spec.tse2e/src/likec4-views.test-d.tse2e/src/likec4/_spec.c4e2e/src/likec4/cloud/next.c4e2e/src/likec4/views.c4packages/core/src/builder/Builder.view-common.tspackages/core/src/builder/__snapshots__/Builder-style2.compute-model.json5packages/core/src/builder/__snapshots__/Builder-style2.general.json5packages/core/src/compute-view/deployment-view/compute.tspackages/core/src/compute-view/deployment-view/utils.spec.tspackages/core/src/compute-view/deployment-view/utils.tspackages/core/src/compute-view/element-view/compute.tspackages/core/src/compute-view/element-view/multiple-relationships.spec.tspackages/core/src/compute-view/element-view/utils.spec.tspackages/core/src/compute-view/element-view/utils.tspackages/core/src/compute-view/utils/applyCustomRelationProperties.tspackages/core/src/compute-view/utils/relationExpressionToPredicates.tspackages/core/src/compute-view/utils/view-hash.spec.tspackages/core/src/compute-view/utils/view-hash.tspackages/core/src/types/expression-model.tspackages/core/src/types/expression.tspackages/core/src/types/model-spec.tspackages/core/src/types/view-common.tspackages/core/src/types/view-parsed.element.tspackages/language-server/src/ast.tspackages/language-server/src/like-c4.langiumpackages/language-server/src/lsp/CompletionProvider.spec.tspackages/language-server/src/model/__tests__/model-builder.spec.tspackages/language-server/src/model/builder/MergedSpecification.tspackages/language-server/src/model/parser/FqnRefParser.tspackages/language-server/src/model/parser/SpecificationParser.tspackages/language-server/src/model/parser/ViewsParser.ts
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ 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 inrelationExpressionToPredicates.tsand acceptRelationshipModel(or a narrowed structural type) directly so call sites don't need toas anyper property. - Extract a single
buildExpandPredicate(or abuildMultipleExpansionRuleshelper) 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) theresolveEndpointstep 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.
11415a5 to
852ae33
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
packages/core/src/compute-view/element-view/multiple-relationships.spec.ts (1)
82-124: ⚡ Quick winStrengthen the “not other kinds” assertion with at least two non-matching relations.
This case currently has only one
syncrelation, so it passes whethersyncis merged or (incorrectly) expanded. Add a secondsyncrelation and assert a single mergedsyncedge (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 winCognitive 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/lineis 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
⛔ Files ignored due to path filters (5)
packages/core/src/compute-view/element-view/__test__/__snapshots__/legacy.spec.ts.snapis excluded by!**/*.snappackages/generators/src/model/__snapshots__/likec4.computed-model-with-core.snapis excluded by!**/*.snappackages/generators/src/model/__snapshots__/likec4.computed-model.snapis excluded by!**/*.snappackages/language-server/src/model/__tests__/__snapshots__/model-builder.spec.ts.snapis excluded by!**/*.snappackages/layouts/src/graphviz/wasm/__snapshots__/GraphvizWasmAdapter.spec.ts.snapis excluded by!**/*.snap
📒 Files selected for processing (38)
.changeset/multiple-relationships-in-views.mdapps/docs/src/content/docs/dsl/Views/predicates.mdxapps/docs/src/content/docs/dsl/specification.mdxapps/docs/src/content/docs/dsl/styling.mdxe2e/src/likec4-model.spec.tse2e/src/likec4-model.test-d.tse2e/src/likec4-views.spec.tse2e/src/likec4-views.test-d.tse2e/src/likec4/_spec.c4e2e/src/likec4/cloud/next.c4e2e/src/likec4/views.c4packages/core/src/builder/Builder.view-common.tspackages/core/src/builder/__snapshots__/Builder-style2.compute-model.json5packages/core/src/builder/__snapshots__/Builder-style2.general.json5packages/core/src/compute-view/deployment-view/compute.tspackages/core/src/compute-view/deployment-view/utils.spec.tspackages/core/src/compute-view/deployment-view/utils.tspackages/core/src/compute-view/element-view/compute.tspackages/core/src/compute-view/element-view/multiple-relationships.spec.tspackages/core/src/compute-view/element-view/utils.spec.tspackages/core/src/compute-view/element-view/utils.tspackages/core/src/compute-view/utils/applyCustomRelationProperties.tspackages/core/src/compute-view/utils/relationExpressionToPredicates.tspackages/core/src/compute-view/utils/view-hash.spec.tspackages/core/src/compute-view/utils/view-hash.tspackages/core/src/types/expression-model.tspackages/core/src/types/expression.tspackages/core/src/types/model-spec.tspackages/core/src/types/view-common.tspackages/core/src/types/view-parsed.element.tspackages/language-server/src/ast.tspackages/language-server/src/like-c4.langiumpackages/language-server/src/lsp/CompletionProvider.spec.tspackages/language-server/src/model/__tests__/model-builder.spec.tspackages/language-server/src/model/builder/MergedSpecification.tspackages/language-server/src/model/parser/FqnRefParser.tspackages/language-server/src/model/parser/SpecificationParser.tspackages/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
| 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>) | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| 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>) | ||
| } |
There was a problem hiding this comment.
🧩 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 -50Repository: 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 -30Repository: 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 -50Repository: 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 -20Repository: 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 -80Repository: 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.tsRepository: 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 -10Repository: 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/typesRepository: 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 -50Repository: 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 -100Repository: 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.tsRepository: 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 -50Repository: 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 -50Repository: likec4/likec4
Length of output: 39
🏁 Script executed:
# Search more broadly for RelationshipsAccum
rg 'RelationshipsAccum' packages/core/src -A 10 | head -80Repository: likec4/likec4
Length of output: 7036
🏁 Script executed:
# Look at what's imported in DeploymentElementModel
head -50 packages/core/src/model/DeploymentElementModel.tsRepository: 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 -60Repository: likec4/likec4
Length of output: 39
🏁 Script executed:
# Check DeploymentRelationModel
rg -A 20 'class DeploymentRelationModel' packages/core/src/model/DeploymentElementModel.ts | head -60Repository: 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 -80Repository: likec4/likec4
Length of output: 988
🏁 Script executed:
# Check DeploymentRelationEndpoint type
rg -A 10 'type DeploymentRelationEndpoint\|interface DeploymentRelationEndpoint' packages/core/src/model/DeploymentElementModel.tsRepository: 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 -30Repository: 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.tsRepository: 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 -30Repository: likec4/likec4
Length of output: 97
🏁 Script executed:
# Get the full deploymentRef function
rg -A 20 'public deploymentRef' packages/core/src/model/DeploymentModel.tsRepository: 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 -30Repository: 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.tsRepository: 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 -20Repository: 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 -20Repository: 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.tsRepository: 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 -30Repository: 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.tsRepository: 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 -5Repository: 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 -100Repository: 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.tsRepository: 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.
|
Something is wrong in this screenshots... let me look into this. |
|
Actually they look OK - I just forgot what was in e2e examples. |
|
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... |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/core/src/compute-view/deployment-view/utils.ts (1)
204-231: ⚡ Quick win
mergeBidirectionalEdgesmutates its inputs and trips SonarCloud complexity.Two concerns to address together:
- 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.- SonarCloud reports cognitive complexity of 45 here (limit 30). The nested
for/forplus the in-loop merge bookkeeping is the main contributor.Extracting an
applyBidirectionalMerge(edge, other)helper that returns a fresh merged edge (and dropping thetoRemoveSet in favor of a simple result list keyed bysource|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
⛔ Files ignored due to path filters (6)
e2e/tests/__screenshots__/chromium-linux/e2e-backend.pngis excluded by!**/*.pnge2e/tests/__screenshots__/chromium-linux/e2e-cloud-next.pngis excluded by!**/*.pnge2e/tests/__screenshots__/chromium-linux/e2e-cloud.pngis excluded by!**/*.pnge2e/tests/__screenshots__/chromium-linux/e2e-deploy-1.pngis excluded by!**/*.pnge2e/tests/__screenshots__/chromium-linux/e2e-multiple-expanded.pngis excluded by!**/*.pnge2e/tests/__screenshots__/chromium-linux/e2e-multiple-merged.pngis excluded by!**/*.png
📒 Files selected for processing (11)
e2e/src/likec4-model.spec.tse2e/src/likec4-model.test-d.tse2e/src/likec4-views.spec.tse2e/src/likec4-views.test-d.tse2e/src/likec4/views.c4packages/core/src/compute-view/deployment-view/compute.tspackages/core/src/compute-view/deployment-view/utils.tspackages/core/src/compute-view/element-view/compute.tspackages/core/src/compute-view/element-view/multiple-relationships.spec.tspackages/core/src/compute-view/element-view/utils.tspackages/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
| 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 | ||
| } |
There was a problem hiding this comment.
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.headis set,other.headis ignored entirely andedge.tailbecomes a copy ofedge.head(asymmetric arrows are flattened). other.tailis 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.
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>
9e5b8da to
0913e90
Compare
There was a problem hiding this comment.
♻️ 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 useas ComputedEdge<A>, conflicting with both the existing local pattern and the project guideline to avoidascasts. Per coding guidelines: "Avoid usingany, casts withas".🤖 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 winBidirectional merge still flattens asymmetric arrows and ignores
other.tail.The previously raised concern still applies to the current code:
head = edge.head ?? other.headis then assigned to bothedge.headandedge.tail, which copies one side over both ends.other.tailis never consulted, and side-specificcolor/linefromotherare also lost whenedgealready 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(...))formergedSourcestill inconsistent with the default branch.Same concern as before: this filter compares relation
source.id/target.idagainst deployment node IDs, so model-level relations inmergednever match. Even when types align,only()returnsundefinedfor >1 matches, silently dropping the primary. Meanwhile the default branch (line 318) callsmergePropsFromRelationshipswith no primary, leaving the two paths inconsistent. Either drop themergedSourceargument 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 winConsider extracting helpers to reduce
toComputedEdgescomplexity.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 duplicateas 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
⛔ Files ignored due to path filters (12)
e2e/tests/__screenshots__/chromium-linux/e2e-backend.pngis excluded by!**/*.pnge2e/tests/__screenshots__/chromium-linux/e2e-cloud-next.pngis excluded by!**/*.pnge2e/tests/__screenshots__/chromium-linux/e2e-cloud.pngis excluded by!**/*.pnge2e/tests/__screenshots__/chromium-linux/e2e-deploy-1.pngis excluded by!**/*.pnge2e/tests/__screenshots__/chromium-linux/e2e-multiple-expanded.pngis excluded by!**/*.pnge2e/tests/__screenshots__/chromium-linux/e2e-multiple-explicit.pngis excluded by!**/*.pnge2e/tests/__screenshots__/chromium-linux/e2e-multiple-merged.pngis excluded by!**/*.pngpackages/core/src/compute-view/element-view/__test__/__snapshots__/legacy.spec.ts.snapis excluded by!**/*.snappackages/generators/src/model/__snapshots__/likec4.computed-model-with-core.snapis excluded by!**/*.snappackages/generators/src/model/__snapshots__/likec4.computed-model.snapis excluded by!**/*.snappackages/language-server/src/model/__tests__/__snapshots__/model-builder.spec.ts.snapis excluded by!**/*.snappackages/layouts/src/graphviz/wasm/__snapshots__/GraphvizWasmAdapter.spec.ts.snapis excluded by!**/*.snap
📒 Files selected for processing (38)
.changeset/multiple-relationships-in-views.mdapps/docs/src/content/docs/dsl/Views/predicates.mdxapps/docs/src/content/docs/dsl/specification.mdxapps/docs/src/content/docs/dsl/styling.mdxe2e/src/likec4-model.spec.tse2e/src/likec4-model.test-d.tse2e/src/likec4-views.spec.tse2e/src/likec4-views.test-d.tse2e/src/likec4/_spec.c4e2e/src/likec4/cloud/next.c4e2e/src/likec4/views.c4packages/core/src/builder/Builder.view-common.tspackages/core/src/builder/__snapshots__/Builder-style2.compute-model.json5packages/core/src/builder/__snapshots__/Builder-style2.general.json5packages/core/src/compute-view/deployment-view/compute.tspackages/core/src/compute-view/deployment-view/utils.spec.tspackages/core/src/compute-view/deployment-view/utils.tspackages/core/src/compute-view/element-view/compute.tspackages/core/src/compute-view/element-view/multiple-relationships.spec.tspackages/core/src/compute-view/element-view/utils.spec.tspackages/core/src/compute-view/element-view/utils.tspackages/core/src/compute-view/utils/applyCustomRelationProperties.tspackages/core/src/compute-view/utils/relationExpressionToPredicates.tspackages/core/src/compute-view/utils/view-hash.spec.tspackages/core/src/compute-view/utils/view-hash.tspackages/core/src/types/expression-model.tspackages/core/src/types/expression.tspackages/core/src/types/model-spec.tspackages/core/src/types/view-common.tspackages/core/src/types/view-parsed.element.tspackages/language-server/src/ast.tspackages/language-server/src/like-c4.langiumpackages/language-server/src/lsp/CompletionProvider.spec.tspackages/language-server/src/model/__tests__/model-builder.spec.tspackages/language-server/src/model/builder/MergedSpecification.tspackages/language-server/src/model/parser/FqnRefParser.tspackages/language-server/src/model/parser/SpecificationParser.tspackages/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
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
mainbefore creating this PR.pnpm typecheckandpnpm test./changeset-generatorSKILL).