Skip to content

fix(composition): change how interface field selections are validated…#3027

Open
Aenimus wants to merge 8 commits into
mainfrom
david/eng-9823-change-provides-interface-field-validation
Open

fix(composition): change how interface field selections are validated…#3027
Aenimus wants to merge 8 commits into
mainfrom
david/eng-9823-change-provides-interface-field-validation

Conversation

@Aenimus

@Aenimus Aenimus commented Jun 28, 2026

Copy link
Copy Markdown
Member

… in a @provides fieldset

Summary by CodeRabbit

  • New Features

    • Added clearer validation support for @provides on interface-related field sets.
    • Introduced a unified execution result type for easier handling of success and failure outcomes.
  • Bug Fixes

    • Improved error reporting when a provided field path includes an interface selection without the needed external context.
    • Refined @provides checks so interface implementation cases are handled more accurately.

Checklist

Open Source AI Manifesto

This project follows the principles of the Open Source AI Manifesto. Please ensure your contribution aligns with its principles.

@Aenimus Aenimus requested a review from a team as a code owner June 28, 2026 00:01
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d184a315-992e-493e-b186-3708957c91b4

📥 Commits

Reviewing files that changed from the base of the PR and between 40eee0e and b140213.

📒 Files selected for processing (2)
  • composition/src/v1/normalization/types/params.ts
  • composition/src/v1/normalization/types/results.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • composition/src/v1/normalization/types/results.ts
  • composition/src/v1/normalization/types/params.ts

Walkthrough

Adds a new directlyProvidedInterfaceFieldError composition error with its parameter type, extends normalization parameter types with a selection field and a new IsAnyImplementationFieldExternalParams type, and updates NormalizationFactory to detect and validate interface fields lacking @external ancestors during @provides/@requires conditional field-set validation. Adds an ExecutionResult type alias and updates related tests.

Changes

Interface field provides validation

Layer / File(s) Summary
Error definition and parameter type
composition/src/errors/errors.ts, composition/src/errors/types/params.ts
New directlyProvidedInterfaceFieldError function and its DirectlyProvidedInterfaceFieldErrorParams type are added, describing directive field sets with interface selections lacking @external ancestors.
Normalization param type extensions
composition/src/v1/normalization/types/params.ts
HandleNonExternalConditionalFieldParams gains a selection field; new IsAnyImplementationFieldExternalParams type is added.
Normalization factory interface handling
composition/src/v1/normalization/normalization-factory.ts
New handleConditionalImplementationField method scans interface implementations for @external fields; integrated into validateConditionalFieldSet and #handleNonExternalConditionalField to raise the new error and track conditional state.
Supporting type imports and results type
composition/src/v1/normalization/types/results.ts, composition/src/types/results.ts
Import of ValidProvidesParentData simplified; new ExecutionResult union type added.
Test coverage for interface provides errors
composition/tests/v1/directives/provides.test.ts
Federation @provides tests updated for union/interface scenarios, asserting the new error and revised warning expectations.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

  • wundergraph/cosmo#2764: Both PRs modify normalization-factory.ts's interface implementation field handling/tracking logic.
  • wundergraph/cosmo#3026: Both PRs modify validateConditionalFieldSet in the same file, one for interfaces and one for unions.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: composition validation for interface field selections in @provides fieldsets.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@codecov

codecov Bot commented Jun 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 35.95%. Comparing base (e242571) to head (b140213).

Additional details and impacted files
@@             Coverage Diff             @@
##             main    #3027       +/-   ##
===========================================
- Coverage   65.50%   35.95%   -29.55%     
===========================================
  Files         335      808      +473     
  Lines       48753   108084    +59331     
  Branches     5427     6697     +1270     
===========================================
+ Hits        31935    38864     +6929     
- Misses      16792    68877    +52085     
- Partials       26      343      +317     

see 473 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Base automatically changed from david/eng-5628-support-provides-on-union to main July 2, 2026 23:50

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
composition/src/v1/normalization/normalization-factory.ts (1)

1898-1905: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stale naming from the method rename.

The comment and surrounding style still read as if this is a boolean-only predicate (isAnyImplementationFieldExternal), matching the still-unrenamed IsAnyImplementationFieldExternalParams type in types/params.ts. Since the method now also has the side effect of populating conditionalFieldDataByCoords, consider aligning the comment/type name with handleConditionalImplementationField.

🤖 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 `@composition/src/v1/normalization/normalization-factory.ts` around lines 1898
- 1905, The naming here is stale and still suggests a pure boolean predicate
even though handleConditionalImplementationField now also updates
conditionalFieldDataByCoords. Update the nearby comment and the
IsAnyImplementationFieldExternalParams type name in types/params.ts to match the
renamed behavior, and make sure any references to the old
isAnyImplementationFieldExternal wording are aligned with
handleConditionalImplementationField.
composition/src/v1/normalization/types/params.ts (1)

61-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Type name no longer matches its consumer.

IsAnyImplementationFieldExternalParams reads like a boolean predicate name, but it now backs NormalizationFactory#handleConditionalImplementationField (see normalization-factory.ts), which has side effects (records conditional providedBy data) beyond just testing externality. Consider renaming to something like HandleConditionalImplementationFieldParams for consistency with the renamed method.

🤖 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 `@composition/src/v1/normalization/types/params.ts` around lines 61 - 67, The
parameter type name is outdated and no longer matches its consumer:
IsAnyImplementationFieldExternalParams is used by
NormalizationFactory#handleConditionalImplementationField, which does more than
check externality. Rename the type to something aligned with the new method
name, update the corresponding import/usage sites in normalization-factory.ts,
and keep the naming consistent across the related normalization params
definitions.
🤖 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 `@composition/src/v1/normalization/normalization-factory.ts`:
- Around line 1899-1943: The conditional field handling in
handleConditionalImplementationField still adds providedBy entries for
implementation fields marked `@external` even when they are already
unconditionally provided. Update the logic in this method to skip those fields
by checking isUnconditionallyProvided before pushing to
conditionalFieldDataByCoords, so interface fields do not get a spurious
conditional/external ancestor.

In `@composition/tests/v1/directives/provides.test.ts`:
- Around line 1791-2586: Remove the duplicated federation test block that starts
with the Union “provides” cases in provides.test.ts and keep only one copy of
each test. The copied tests with names like “that provides on a field that
returns a Union is valid `#1/`#2” and the related Union error cases are stale
leftovers and should be deleted, since the original versions already exist and
correctly assert the warning behavior. Preserve the newer Interface-related
tests that follow, and verify the remaining tests still reference the same
helpers such as federateSubgraphsSuccess, federateSubgraphsFailure, and
subgraphValidationError.

---

Nitpick comments:
In `@composition/src/v1/normalization/normalization-factory.ts`:
- Around line 1898-1905: The naming here is stale and still suggests a pure
boolean predicate even though handleConditionalImplementationField now also
updates conditionalFieldDataByCoords. Update the nearby comment and the
IsAnyImplementationFieldExternalParams type name in types/params.ts to match the
renamed behavior, and make sure any references to the old
isAnyImplementationFieldExternal wording are aligned with
handleConditionalImplementationField.

In `@composition/src/v1/normalization/types/params.ts`:
- Around line 61-67: The parameter type name is outdated and no longer matches
its consumer: IsAnyImplementationFieldExternalParams is used by
NormalizationFactory#handleConditionalImplementationField, which does more than
check externality. Rename the type to something aligned with the new method
name, update the corresponding import/usage sites in normalization-factory.ts,
and keep the naming consistent across the related normalization params
definitions.
🪄 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: c7fe6dfa-bf4b-48f7-9f6a-ad04ce9bbb81

📥 Commits

Reviewing files that changed from the base of the PR and between e242571 and 40eee0e.

📒 Files selected for processing (8)
  • composition/src/errors/errors.ts
  • composition/src/errors/types/params.ts
  • composition/src/types/results.ts
  • composition/src/v1/normalization/normalization-factory.ts
  • composition/src/v1/normalization/types/params.ts
  • composition/src/v1/normalization/types/results.ts
  • composition/src/v1/normalization/types/types.ts
  • composition/tests/v1/directives/provides.test.ts

Comment on lines +1899 to +1943
handleConditionalImplementationField({
fieldCoordsPath,
fieldName,
fieldPath,
interfaceTypeName,
isProvides,
}: IsAnyImplementationFieldExternalParams): boolean {
const implementationTypeNames = this.concreteTypeNamesByAbstractTypeName.get(interfaceTypeName);
if (!implementationTypeNames) {
return false;
}

let hasExternalField = false;
for (const typeName of implementationTypeNames) {
const data = this.parentDefinitionDataByTypeName.get(typeName);
if (!isObjectDefinitionData(data)) {
continue;
}

const fieldData = data.fieldDataByName.get(fieldName);
if (!fieldData) {
continue;
}

if (fieldData.directivesByName.has(EXTERNAL)) {
if (!isProvides) {
return true;
}

getValueOrDefault(
this.conditionalFieldDataByCoords,
`${typeName}.${fieldName}`,
newConditionalFieldData,
).providedBy.push(
newFieldSetConditionData({
fieldCoordinatesPath: [...fieldCoordsPath],
fieldPath: [...fieldPath],
}),
);
hasExternalField = true;
}
}

return hasExternalField;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether externalFieldDataBySubgraphName / isUnconditionallyProvided is consulted anywhere
# in handleConditionalImplementationField or its call sites for interface-implementation fields.
rg -n -A5 -B5 'isUnconditionallyProvided' composition/src/v1/normalization/normalization-factory.ts

Repository: wundergraph/cosmo

Length of output: 3457


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the function body and its callers to see how `isUnconditionallyProvided`
# is threaded through the normalization flow.
sed -n '1880,1955p' composition/src/v1/normalization/normalization-factory.ts
printf '\n---\n'
sed -n '2010,2085p' composition/src/v1/normalization/normalization-factory.ts
printf '\n---\n'
sed -n '3995,4085p' composition/src/v1/normalization/normalization-factory.ts
printf '\n---\n'
rg -n -A4 -B4 'handleConditionalImplementationField\(' composition/src/v1/normalization/normalization-factory.ts

Repository: wundergraph/cosmo

Length of output: 11854


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the second call site and the conditional-field data structures
# to see whether unconditionally-provided fields are excluded elsewhere.
sed -n '2115,2155p' composition/src/v1/normalization/normalization-factory.ts
printf '\n---\n'
rg -n -A4 -B4 'newConditionalFieldData|providedBy|conditionalFieldDataByCoords' composition/src/v1/normalization/normalization-factory.ts

Repository: wundergraph/cosmo

Length of output: 4599


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the data model for external field metadata and all places where
# `isUnconditionallyProvided` is read or written.
rg -n -A3 -B3 'isUnconditionallyProvided|externalFieldDataBySubgraphName|newExternalFieldData' composition/src/v1
printf '\n---\n'
sed -n '1,220p' composition/src/v1/schema-building/utils.ts

Repository: wundergraph/cosmo

Length of output: 14712


Skip isUnconditionallyProvided here too. handleConditionalImplementationField still records providedBy for implementation fields that are @external but already unconditionally provided, which can leave a spurious conditional/external ancestor on interface fields.

🤖 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 `@composition/src/v1/normalization/normalization-factory.ts` around lines 1899
- 1943, The conditional field handling in handleConditionalImplementationField
still adds providedBy entries for implementation fields marked `@external` even
when they are already unconditionally provided. Update the logic in this method
to skip those fields by checking isUnconditionallyProvided before pushing to
conditionalFieldDataByCoords, so interface fields do not get a spurious
conditional/external ancestor.

Comment on lines +1791 to +2586
test('that provides on a field that returns a Union is valid #1', () => {
const subgraphA = createSubgraph(
'a',
`
type Query {
union: Union @provides(fields: "... on Entity { name }")
}

type Entity @key(fields: "id") {
id: ID!
name: String! @external @shareable
}

union Union = Entity
`,
);
const subgraphB = createSubgraph(
'b',
`
type Entity @key(fields: "id") {
id: ID!
name: String! @shareable
}
`,
);
const { warnings } = federateSubgraphsSuccess([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE);
expect(warnings).toHaveLength(0);
});

test('that provides on a field that returns a Union is valid #2', () => {
const subgraphA = createSubgraph(
'a',
`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable", "@external"]
)

type Query {
media: [Media] @shareable
}

union Media = Book | Movie

type Book @key(fields: "id") {
id: ID!
}

type Movie @key(fields: "id") {
id: ID!
}
`,
);
const subgraphB = createSubgraph(
'b',
`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable", "@provides", "@external"]
)

type Query {
media: [Media] @shareable @provides(fields: "... on Book { title }")
}

union Media = Book | Movie

type Book @key(fields: "id") {
id: ID!
title: String @external
}

type Movie @key(fields: "id") {
id: ID!
}
`,
);
const subgraphC = createSubgraph(
'c',
`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable"]
)

type Book @key(fields: "id") {
id: ID!
title: String @shareable
}

type Movie @key(fields: "id") {
id: ID!
title: String @shareable
}
`,
);
const { subgraphConfigBySubgraphName, warnings } = federateSubgraphsSuccess(
[subgraphA, subgraphB, subgraphC],
ROUTER_COMPATIBILITY_VERSION_ONE,
);
expect(warnings).toHaveLength(0);
});

test('that an error is returned if a non-external field is provided through a Union type fragment', () => {
const subgraphA = createSubgraph(
'a',
`
type Query {
union: Union @provides(fields: "... on Entity { name }")
}

type Entity @key(fields: "id") {
id: ID!
name: String! @shareable
}

union Union = Entity
`,
);
const subgraphB = createSubgraph(
'b',
`
type Entity @key(fields: "id") {
id: ID!
name: String! @shareable
}
`,
);
const { errors, warnings } = federateSubgraphsFailure([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE);
expect(errors).toHaveLength(1);
expect(errors).toStrictEqual([
subgraphValidationError(subgraphA.name, [
nonExternalConditionalFieldError({
directiveCoords: 'Query.union',
fieldSet: `... on Entity { name }`,
directiveName: PROVIDES,
subgraphName: subgraphA.name,
targetCoords: 'Entity.name',
}),
]),
]);
expect(warnings).toHaveLength(0);
});

test('that an error is returned if a Union fieldset defines an unknown type fragment', () => {
const subgraphA = createSubgraph(
'a',
`
type Query {
union: Union @provides(fields: "... on Unknown { name }")
}

type Entity @key(fields: "id") {
id: ID!
name: String! @shareable
}

union Union = Entity
`,
);
const subgraphB = createSubgraph(
'b',
`
type Entity @key(fields: "id") {
id: ID!
name: String! @shareable
}
`,
);
const { errors, warnings } = federateSubgraphsFailure([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE);
expect(errors).toHaveLength(1);
expect(errors).toStrictEqual([
subgraphValidationError(subgraphA.name, [
invalidProvidesOrRequiresDirectivesError(PROVIDES, [
` On field "Query.union":\n -` +
unknownInlineFragmentTypeConditionErrorMessage(
`... on Unknown { name }`,
['Query.union'],
UNION,
'Unknown',
),
]),
]),
]);
expect(warnings).toHaveLength(0);
});

test('that an error is returned if a Union fieldset defines a non-member type fragment', () => {
const subgraphA = createSubgraph(
'a',
`
type Query {
union: Union @provides(fields: "... on EntityB { name }")
}

type EntityA @key(fields: "id") {
id: ID!
name: String! @shareable
}

type EntityB @key(fields: "id") {
id: ID!
}

union Union = EntityA
`,
);
const subgraphB = createSubgraph(
'b',
`
type EntityA @key(fields: "id") {
id: ID!
name: String! @shareable
}
`,
);
const { errors, warnings } = federateSubgraphsFailure([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE);
expect(errors).toHaveLength(1);
expect(errors).toStrictEqual([
subgraphValidationError(subgraphA.name, [
invalidProvidesOrRequiresDirectivesError(PROVIDES, [
` On field "Query.union":\n -` +
invalidInlineFragmentTypeConditionErrorMessage(
`... on EntityB { name }`,
['Query.union'],
'EntityB',
UNION,
UNION,
OBJECT,
),
]),
]),
]);
expect(warnings).toHaveLength(0);
});

test('that an error is returned if a Union fieldset defines a non-member type fragment (in the current subgraph)', () => {
const subgraphA = createSubgraph(
'a',
`
type Query {
union: Union @provides(fields: "... on EntityB { name }")
}

type EntityA @key(fields: "id") {
id: ID!
name: String! @shareable
}

type EntityB @key(fields: "id") {
id: ID!
}

union Union = EntityA
`,
);
const subgraphB = createSubgraph(
'b',
`
type EntityA @key(fields: "id") {
id: ID!
name: String! @shareable
}

type EntityB @key(fields: "id") {
id: ID!
}

union Union = EntityA | EntityB
`,
);
const { errors, warnings } = federateSubgraphsFailure([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE);
expect(errors).toHaveLength(1);
expect(errors).toStrictEqual([
subgraphValidationError(subgraphA.name, [
invalidProvidesOrRequiresDirectivesError(PROVIDES, [
` On field "Query.union":\n -` +
invalidInlineFragmentTypeConditionErrorMessage(
`... on EntityB { name }`,
['Query.union'],
'EntityB',
UNION,
UNION,
OBJECT,
),
]),
]),
]);
expect(warnings).toHaveLength(0);
});

test('that an error is returned if a field is directly provided on an Interface', () => {
const subgraphA = createSubgraph(
'a',
`
type Query {
interface: Interface! @provides(fields: "name")
}

interface Interface {
name: String!
}

type Object implements Interface {
name: String!
}
`,
);
const { errors, warnings } = federateSubgraphsFailure([subgraphA], ROUTER_COMPATIBILITY_VERSION_ONE);
expect(errors).toHaveLength(1);
expect(errors).toStrictEqual([
subgraphValidationError(subgraphA.name, [
directlyProvidedInterfaceFieldError({
directiveCoords: `Query.interface`,
directiveName: PROVIDES,
fieldSet: 'name',
selection: 'interface { name }',
subgraphName: subgraphA.name,
targetCoords: 'Interface.name',
}),
]),
]);
expect(warnings).toHaveLength(0);
});

test('that an error is returned if a field is directly provided on a nested Interface (no external ancestor)', () => {
const subgraphA = createSubgraph(
'a',
`
type Query {
object: Object! @provides(fields: "interface { name }")
}

interface Interface {
name: String!
}

type Object implements Interface {
interface: Interface!
name: String!
}
`,
);
const { errors, warnings } = federateSubgraphsFailure([subgraphA], ROUTER_COMPATIBILITY_VERSION_ONE);
expect(errors).toHaveLength(1);
expect(errors).toStrictEqual([
subgraphValidationError(subgraphA.name, [
directlyProvidedInterfaceFieldError({
directiveCoords: `Query.object`,
directiveName: PROVIDES,
fieldSet: 'interface { name }',
selection: 'interface { name }',
subgraphName: subgraphA.name,
targetCoords: 'Interface.name',
}),
]),
]);
expect(warnings).toHaveLength(0);
});

test('that an error is returned if a field is directly provided on a nested Interface (with external ancestor)', () => {
const subgraphA = createSubgraph(
'a',
`
type Query {
objectA: ObjectA! @provides(fields: "interface { name }")
}

interface Interface {
name: String!
}

type ObjectA @shareable {
interface: Interface! @external
name: String!
}

type ObjectB implements Interface @shareable {
name: String!
}
`,
);
const subgraphB = createSubgraph(
'b',
`
interface Interface {
name: String!
}

type ObjectA @shareable {
interface: Interface!
}

type ObjectB implements Interface @shareable {
name: String!
}
`,
);
const { warnings } = federateSubgraphsSuccess([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE);
expect(warnings).toHaveLength(0);
});

test('that provides on an Interface field returning an Interface can be valid (all implementations provide an @external ancestor)', () => {
const subgraphA = createSubgraph(
'a',
`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable", "@external", "@provides"]
)

type Query {
media: Media @shareable
book: Book @provides(fields: "animals { ... on Dog { name } }")
}

interface Media {
id: ID!
}

interface Animal {
id: ID!
}

type Book implements Media @key(fields: "id") {
id: ID!
animals: [Animal] @shareable
}

type Dog implements Animal @key(fields: "id") {
id: ID! @external
name: String @external
}

type Cat implements Animal @key(fields: "id") {
id: ID! @external
}
`,
);
const subgraphB = createSubgraph(
'b',
`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable", "@provides", "@external"]
)

type Query {
media: Media @shareable @provides(fields: "animals { id name }")
}

interface Media {
id: ID!
animals: [Animal]
}

interface Animal {
id: ID!
name: String
}

type Book implements Media {
id: ID! @shareable
animals: [Animal] @external
}

type Dog implements Animal {
id: ID! @external
name: String @external
}

type Cat implements Animal {
id: ID! @external
name: String @external
}
`,
);
const subgraphC = createSubgraph(
'c',
`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable"]
)

interface Media {
id: ID!
animals: [Animal]
}

interface Animal {
id: ID!
name: String
}

type Book implements Media @key(fields: "id") {
id: ID!
animals: [Animal] @shareable
}

type Dog implements Animal @key(fields: "id") {
id: ID!
name: String @shareable
age: Int
}

type Cat implements Animal @key(fields: "id") {
id: ID!
name: String @shareable
age: Int
}
`,
);
const { subgraphConfigBySubgraphName, warnings } = federateSubgraphsSuccess(
[subgraphA, subgraphB, subgraphC],
ROUTER_COMPATIBILITY_VERSION_ONE,
);
expect(warnings).toHaveLength(0);
});

test('that provides on an Interface field returning an Interface can be valid (only one implementations provides an @external ancestor)', () => {
const subgraphA = createSubgraph(
'a',
`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable", "@external", "@provides"]
)

type Query {
media: Media @shareable
book: Book @provides(fields: "animals { ... on Dog { name } }")
}

interface Media {
id: ID!
}

interface Animal {
id: ID!
}

type Book implements Media @key(fields: "id") {
id: ID!
animals: [Animal] @shareable
}

type Dog implements Animal @key(fields: "id") {
id: ID! @external
name: String @external
}

type Cat implements Animal @key(fields: "id") {
id: ID! @external
}
`,
);
const subgraphB = createSubgraph(
'b',
`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable", "@provides", "@external"]
)

type Query {
media: Media @shareable @provides(fields: "animals { id name }")
}

interface Media {
id: ID!
animals: [Animal]
}

interface Animal {
id: ID!
name: String
}

type Book implements Media @shareable {
id: ID!
animals: [Animal]
}

type Video implements Media {
id: ID! @shareable
animals: [Animal] @external
}

type Dog implements Animal {
id: ID! @external
name: String @external
}

type Cat implements Animal {
id: ID! @external
name: String @external
}
`,
);
const subgraphC = createSubgraph(
'c',
`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable"]
)

interface Media {
id: ID!
animals: [Animal]
}

interface Animal {
id: ID!
name: String
}

type Book implements Media @key(fields: "id") {
id: ID!
animals: [Animal] @shareable
}

type Video implements Media @key(fields: "id") {
id: ID!
animals: [Animal] @shareable
}

type Dog implements Animal @key(fields: "id") {
id: ID!
name: String @shareable
age: Int
}

type Cat implements Animal @key(fields: "id") {
id: ID!
name: String @shareable
age: Int
}
`,
);
const { warnings } = federateSubgraphsSuccess(
[subgraphA, subgraphB, subgraphC],
ROUTER_COMPATIBILITY_VERSION_ONE,
);
expect(warnings).toHaveLength(0);
});

test('that an error is returned if no there no implementation type provides an external ancestor for an Interface field', () => {
const subgraphA = createSubgraph(
'a',
`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable", "@external", "@provides"]
)

type Query {
media: Media @shareable
book: Book @provides(fields: "animals { ... on Dog { name } }")
}

interface Media {
id: ID!
}

interface Animal {
id: ID!
}

type Book implements Media @key(fields: "id") {
id: ID!
animals: [Animal] @shareable
}

type Dog implements Animal @key(fields: "id") {
id: ID! @external
name: String @external
}

type Cat implements Animal @key(fields: "id") {
id: ID! @external
}
`,
);
const subgraphB = createSubgraph(
'b',
`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable", "@provides", "@external"]
)

type Query {
media: Media @shareable @provides(fields: "animals { id name }")
}

interface Media {
id: ID!
animals: [Animal]
}

interface Animal {
id: ID!
name: String
}

type Book implements Media {
id: ID! @shareable
animals: [Animal] @shareable
}

type Dog implements Animal {
id: ID! @external
name: String @external
}

type Cat implements Animal {
id: ID! @external
name: String @external
}
`,
);
const subgraphC = createSubgraph(
'c',
`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable"]
)

interface Media {
id: ID!
animals: [Animal]
}

interface Animal {
id: ID!
name: String
}

type Book implements Media @key(fields: "id") {
id: ID!
animals: [Animal] @shareable
}

type Dog implements Animal @key(fields: "id") {
id: ID!
name: String @shareable
age: Int
}

type Cat implements Animal @key(fields: "id") {
id: ID!
name: String @shareable
age: Int
}
`,
);
const { errors, warnings } = federateSubgraphsFailure(
[subgraphA, subgraphB, subgraphC],
ROUTER_COMPATIBILITY_VERSION_ONE,
);
expect(errors).toHaveLength(1);
expect(errors).toStrictEqual([
subgraphValidationError(subgraphB.name, [
directlyProvidedInterfaceFieldError({
directiveCoords: 'Query.media',
directiveName: PROVIDES,
fieldSet: 'animals { id name }',
selection: 'animals { id }',
subgraphName: subgraphB.name,
targetCoords: 'Animal.id',
}),
directlyProvidedInterfaceFieldError({
directiveCoords: 'Query.media',
directiveName: PROVIDES,
fieldSet: 'animals { id name }',
selection: 'animals { name }',
subgraphName: subgraphB.name,
targetCoords: 'Animal.name',
}),
]),
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Duplicate test block breaks the build — leftover pre-replacement tests were not removed.

This block duplicates tests already present at lines 1247-1789 (same test names: "that provides on a field that returns a Union is valid #1/#2", the Union fieldset error tests, etc.), but with incorrect warning-count assertions. The originals correctly expect 1 warning (providesOnUnionWarning is still emitted by unchanged code), while these new copies wrongly expect 0. This exactly matches the CI failures reported in the pipeline logs at lines 1817, 1894, 1935, 1978, 2027, and 2082 ("expected warnings to have length 0 but got 1").

Per the PR's own change summary, this range was meant to replace the federation tests starting with "provides on a field that returns a Union is valid #1" — the corresponding old block (lines 1247-1789) needs to be deleted so only one (correct) copy of each test remains. The genuinely new interface coverage (from "that an error is returned if a field is directly provided on an Interface" onward, ~line 2085) should be kept.

🐛 Suggested fix direction
- (delete lines 1247-1789, the superseded original versions of:
-   "that provides on a field that returns a Union is valid `#1`"
-   "that provides on a field that returns a Union is valid `#2`"
-   "that an error is returned if a non-external field is provided through a Union type fragment"
-   "that an error is returned if a Union fields et defines an unknown type fragment"
-   "that an error is returned if a Union field set defines a non-member type fragment"
-   "that an error is returned if a Union field set defines a non-member type fragment (in the current subgraph)")
+ (keep only the single, correctly-asserted copy of each test — currently the copies at
+  lines 1791-2083 have wrong `warnings` length expectations and should either be removed
+  in favor of the originals, or have their assertions corrected to `toHaveLength(1)`)
🧰 Tools
🪛 GitHub Actions: Composition CI / 0_build_test.txt

[error] 1817-1817: AssertionError in @provides directive tests (Federation tests) 'that provides on a field that returns a Union is valid #1': expected warnings toHaveLength(0) but got 1.


[error] 1894-1894: AssertionError in @provides directive tests (Federation tests) 'that provides on a field that returns a Union is valid #2': expected warnings toHaveLength(0) but got 1.


[error] 1935-1935: AssertionError in @provides directive tests (Federation tests) 'that an error is returned if a non-external field is provided through a Union type fragment': expected warnings toHaveLength(0) but got 1.


[error] 1978-1978: AssertionError in @provides directive tests (Federation tests) 'that an error is returned if a Union fieldset defines an unknown type fragment': expected warnings toHaveLength(0) but got 1.


[error] 2027-2027: AssertionError in @provides directive tests (Federation tests) 'that an error is returned if a Union fieldset defines a non-member type fragment': expected warnings toHaveLength(0) but got 1.


[error] 2082-2082: AssertionError in @provides directive tests (Federation tests) 'that an error is returned if a Union fieldset defines a non-member type fragment (in the current subgraph)': expected warnings toHaveLength(0) but got 1.

🪛 GitHub Actions: Composition CI / build_test

[error] 1817-1817: Vitest assertion failed in test '@provides directive tests > Federation tests > that provides on a field that returns a Union is valid #1': expected warnings to have length 0 but got 1.


[error] 1894-1894: Vitest assertion failed in test '@provides directive tests > Federation tests > that provides on a field that returns a Union is valid #2': expected warnings to have length 0 but got 1.


[error] 1935-1935: Vitest assertion failed in test '@provides directive tests > Federation tests > that an error is returned if a non-external field is provided through a Union type fragment': expected warnings to have length 0 but got 1.


[error] 1978-1978: Vitest assertion failed in test '@provides directive tests > Federation tests > that an error is returned if a Union fieldset defines an unknown type fragment': expected warnings to have length 0 but got 1.


[error] 2027-2027: Vitest assertion failed in test '@provides directive tests > Federation tests > that an error is returned if a Union fieldset defines a non-member type fragment': expected warnings to have length 0 but got 1.


[error] 2082-2082: Vitest assertion failed in test '@provides directive tests > Federation tests > that an error is returned if a Union fieldset defines a non-member type fragment (in the current subgraph)': expected warnings to have length 0 but got 1.

🪛 GitHub Check: build_test

[failure] 2082-2082: tests/v1/directives/provides.test.ts > @provides directive tests > Federation tests > that an error is returned if a Union fieldset defines a non-member type fragment (in the current subgraph)
AssertionError: expected [ …(1) ] to have a length of +0 but got 1

  • Expected
  • Received
  • 0
  • 1

❯ tests/v1/directives/provides.test.ts:2082:24


[failure] 2027-2027: tests/v1/directives/provides.test.ts > @provides directive tests > Federation tests > that an error is returned if a Union fieldset defines a non-member type fragment
AssertionError: expected [ …(1) ] to have a length of +0 but got 1

  • Expected
  • Received
  • 0
  • 1

❯ tests/v1/directives/provides.test.ts:2027:24


[failure] 1978-1978: tests/v1/directives/provides.test.ts > @provides directive tests > Federation tests > that an error is returned if a Union fieldset defines an unknown type fragment
AssertionError: expected [ …(1) ] to have a length of +0 but got 1

  • Expected
  • Received
  • 0
  • 1

❯ tests/v1/directives/provides.test.ts:1978:24


[failure] 1935-1935: tests/v1/directives/provides.test.ts > @provides directive tests > Federation tests > that an error is returned if a non-external field is provided through a Union type fragment
AssertionError: expected [ …(1) ] to have a length of +0 but got 1

  • Expected
  • Received
  • 0
  • 1

❯ tests/v1/directives/provides.test.ts:1935:24


[failure] 1894-1894: tests/v1/directives/provides.test.ts > @provides directive tests > Federation tests > that provides on a field that returns a Union is valid #2
AssertionError: expected [ …(1) ] to have a length of +0 but got 1

  • Expected
  • Received
  • 0
  • 1

❯ tests/v1/directives/provides.test.ts:1894:24


[failure] 1817-1817: tests/v1/directives/provides.test.ts > @provides directive tests > Federation tests > that provides on a field that returns a Union is valid #1
AssertionError: expected [ …(1) ] to have a length of +0 but got 1

  • Expected
  • Received
  • 0
  • 1

❯ tests/v1/directives/provides.test.ts:1817:24

🤖 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 `@composition/tests/v1/directives/provides.test.ts` around lines 1791 - 2586,
Remove the duplicated federation test block that starts with the Union
“provides” cases in provides.test.ts and keep only one copy of each test. The
copied tests with names like “that provides on a field that returns a Union is
valid `#1/`#2” and the related Union error cases are stale leftovers and should be
deleted, since the original versions already exist and correctly assert the
warning behavior. Preserve the newer Interface-related tests that follow, and
verify the remaining tests still reference the same helpers such as
federateSubgraphsSuccess, federateSubgraphsFailure, and subgraphValidationError.

Sources: Linters/SAST tools, Pipeline failures

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant