Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect when resolve needs ModuleRef when likely cyclic references #3878

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
82 changes: 46 additions & 36 deletions main/resolve/src/mill/resolve/ResolveCore.scala
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ private object ResolveCore {
m.cls,
None,
current.segments,
Nil
Nil,
Set.empty
)

transitiveOrErr.map(transitive => self ++ transitive)
Expand All @@ -122,7 +123,8 @@ private object ResolveCore {
m.cls,
None,
current.segments,
typePattern
typePattern,
Set.empty
)

transitiveOrErr.map(transitive => self ++ transitive)
Expand Down Expand Up @@ -240,29 +242,39 @@ private object ResolveCore {
}

def resolveTransitiveChildren(
rootModule: BaseModule,
cls: Class[_],
nameOpt: Option[String],
segments: Segments,
typePattern: Seq[String]
rootModule: BaseModule,
cls: Class[_],
nameOpt: Option[String],
segments: Segments,
typePattern: Seq[String],
seenModules: Set[Class[_]],
): Either[String, Set[Resolved]] = {
val direct =
resolveDirectChildren(rootModule, cls, nameOpt, segments, typePattern)
direct.flatMap { direct =>
for {
directTraverse <-
resolveDirectChildren(rootModule, cls, nameOpt, segments, Nil)
indirect0 = directTraverse
.collect { case m: Resolved.Module =>
resolveTransitiveChildren(
rootModule,
m.cls,
nameOpt,
m.segments,
typePattern
)
val errOrDirect = resolveDirectChildren(rootModule, cls, nameOpt, segments, typePattern)
val directTraverse = resolveDirectChildren(rootModule, cls, nameOpt, segments, Nil)

val errOrModules = directTraverse.map { modules =>
modules.flatMap {
case m: Resolved.Module => Some(m)
case _ => None
}
}

if (seenModules.contains(cls)) {
Left(s"Cyclic module reference detected: ${cls.getName}, it's required to wrap it in ModuleRef. See documentation: https://mill-build.org/mill/0.12.1/fundamentals/modules.html#_abstract_modules_references")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It would be better if this link used the MILL_VERSION or I could not give the link. WDYT?

Copy link
Member

Choose a reason for hiding this comment

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

Ideally, the full link should be injected and generated in build.sc, since page refactoring happens now and then, and we could feed it to our link-checker when generating the docs.

Copy link
Member

Choose a reason for hiding this comment

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

I'd say let's just leave out the documentation link for now; we don't really have this convention in other error messages, and I feel like it would need a bit more thought to do properly (ensuring it's the right version, ensuring we don't have broken links, etc.)

} else {
val errOrIndirect0 = errOrModules match {
case Right(modules) =>
modules.flatMap { m =>
Some(resolveTransitiveChildren(rootModule, m.cls, nameOpt, m.segments, typePattern, seenModules + cls))
}
indirect <- EitherOps.sequence(indirect0).map(_.flatten)
case Left(err) => Seq(Left(err))
}

val errOrIndirect = EitherOps.sequence(errOrIndirect0).map(_.flatten)

for {
direct <- errOrDirect
indirect <- errOrIndirect
} yield direct ++ indirect
}
}
Expand Down Expand Up @@ -318,21 +330,19 @@ private object ResolveCore {
}
} else Right(Nil)

crossesOrErr.flatMap { crosses =>
val filteredCrosses = crosses.filter { c =>
for {
crosses <- crossesOrErr
filteredCrosses = crosses.filter { c =>
classMatchesTypePred(typePattern)(c.cls)
}

resolveDirectChildren0(rootModule, segments, cls, nameOpt, typePattern)
.map(
_.map {
case (Resolved.Module(s, cls), _) => Resolved.Module(segments ++ s, cls)
case (Resolved.NamedTask(s), _) => Resolved.NamedTask(segments ++ s)
case (Resolved.Command(s), _) => Resolved.Command(segments ++ s)
}
.toSet
.++(filteredCrosses)
)
direct <- resolveDirectChildren0(rootModule, segments, cls, nameOpt, typePattern)
} yield {
direct.map {
case (Resolved.Module(s, cls), _) => Resolved.Module(segments ++ s, cls)
case (Resolved.NamedTask(s), _) => Resolved.NamedTask(segments ++ s)
case (Resolved.Command(s), _) => Resolved.Command(segments ++ s)
}
.toSet ++ filteredCrosses
}
}

Expand Down
49 changes: 49 additions & 0 deletions main/resolve/test/src/mill/main/ResolveTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1110,5 +1110,54 @@ object ResolveTests extends TestSuite {
Right(Set(_.concrete.tests.inner.foo, _.concrete.tests.inner.innerer.bar))
)
}
test("cyclicModuleRefInitError") {
val check = new Checker(TestGraphs.CyclicModuleRefInitError)
test - check.checkSeq0(
Seq("__"),
isShortError(_, "Cyclic module reference detected")
)
}
test("cyclicModuleRefInitError2") {
val check = new Checker(TestGraphs.CyclicModuleRefInitError2)
test - check.checkSeq0(
Seq("__"),
isShortError(_, "Cyclic module reference detected")
)
}
test("cyclicModuleRefInitError3") {
val check = new Checker(TestGraphs.CyclicModuleRefInitError3)
test - check.checkSeq0(
Seq("__"),
isShortError(_, "Cyclic module reference detected")
)
}
test("crossedCyclicModuleRefInitError") {
val check = new Checker(TestGraphs.CrossedCyclicModuleRefInitError)
test - check.checkSeq0(
Seq("__"),
isShortError(_, "Cyclic module reference detected")
)
}
test("nonCyclicModules") {
val check = new Checker(TestGraphs.NonCyclicModules)
test - check(
"__",
Right(Set(_.foo))
)
}
test("moduleRefWithNonModuleRefChild") {
val check = new Checker(TestGraphs.ModuleRefWithNonModuleRefChild)
test - check(
"__",
Right(Set(_.foo))
)
}
test("moduleRefCycle") {
val check = new Checker(TestGraphs.ModuleRefCycle)
test - check(
"__",
Right(Set(_.foo))
)
}
Comment on lines +1155 to +1161
Copy link
Member

@lihaoyi lihaoyi Oct 31, 2024

Choose a reason for hiding this comment

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

Let's also add a few test cases for non-transitive resolves, both by normal wildcard foo.bar._ or foo.bar._.qux, as well as no-wildcard-at-all foo.bar.qux. In these cases, we should raise an error if we detect a cycle. That would keep the behavior consistent with the transitive case even if there isn't a risk of stackoverflow.

}
}
92 changes: 92 additions & 0 deletions main/test/src/mill/util/TestGraphs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -667,4 +667,96 @@ object TestGraphs {
}
}

object CyclicModuleRefInitError extends TestBaseModule {
import mill.Agg

// See issue: https://github.com/com-lihaoyi/mill/issues/3715
trait CommonModule extends TestBaseModule {
def moduleDeps: Seq[CommonModule] = Seq.empty
def a = myA
def b = myB
}

object myA extends A
trait A extends CommonModule
object myB extends B
trait B extends CommonModule {
override def moduleDeps = super.moduleDeps ++ Agg(a)
}
}

object CyclicModuleRefInitError2 extends TestBaseModule {
// The cycle is in the child
def A = CyclicModuleRefInitError
}

object CyclicModuleRefInitError3 extends TestBaseModule {
// The cycle is in directly here
object A extends Module {
def b = B
}
object B extends Module {
def a = A
}
}

object CrossedCyclicModuleRefInitError extends TestBaseModule {
object cross extends mill.Cross[Cross]("210", "211", "212")
trait Cross extends Cross.Module[String] {
def suffix = Task { crossValue }
def c2 = cross2
}

object cross2 extends mill.Cross[Cross2]("210", "211", "212")
trait Cross2 extends Cross.Module[String] {
override def millSourcePath = super.millSourcePath / crossValue
def suffix = Task { crossValue }
def c1 = cross
}
}

// The module names repeat, but it's not actually cyclic and is meant to confuse the cycle detection.
object NonCyclicModules extends TestBaseModule {
def foo = Task { "foo" }

object A extends Module {
def b = B
}
object B extends Module {
object A extends Module {
def b = B
}
def a = A

object B extends Module {
object B extends Module {}
object A extends Module {
def b = B
}
def a = A
}
}
}

// This edge case shouldn't be an error
object ModuleRefWithNonModuleRefChild extends TestBaseModule {
def foo = Task { "foo" }

def aRef = A
def a = ModuleRef(A)

object A extends TestBaseModule {}
}

object ModuleRefCycle extends TestBaseModule {
def foo = Task { "foo" }

// The cycle is in directly here
object A extends Module {
def b = ModuleRef(B)
}
object B extends Module {
def a = ModuleRef(A)
}
}
}
Loading