Skip to content
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e5fa4f5
docs(phase-2): research test infrastructure domain
halotukozak Feb 18, 2026
cea1d5e
chore(02-01): add scalatest dependency and Test/fork to analyzer module
halotukozak Feb 18, 2026
16dab56
feat(02-02): rewrite AnalyzerTest for Scala 3 with ContextBase harness
halotukozak Feb 18, 2026
aa41335
test(02-03): create CanaryTest validating test infrastructure
halotukozak Feb 18, 2026
b99a01c
feat(03-02): implement FinalCaseClasses rule
halotukozak Feb 18, 2026
d7f43e6
feat(03-05): implement ImplicitParamDefaults rule with comprehensive …
halotukozak Feb 18, 2026
acf0f7c
feat(03-03): implement CatchThrowable rule with Scala 3 type equivale…
halotukozak Feb 18, 2026
3207d16
fix(03-01): fix ImportJavaUtil rule using sel.renamed.isEmpty for cor…
halotukozak Feb 19, 2026
1a0847f
feat(03-04): fix CheckBincompat definition site detection for module …
halotukozak Feb 19, 2026
dd7e112
feat(03-03): implement ThrowableObjects rule with Scala 3 subtype and…
halotukozak Feb 19, 2026
9eccf0d
feat(03-05): implement VarargsAtLeast rule with Scala 3 SeqLiteral va…
halotukozak Feb 19, 2026
df5a5fd
feat(03-05): implement NothingAsFunctionArgument rule detecting Nothi…
halotukozak Feb 19, 2026
371bef9
feat(03-01): implement BasePackage rule with recursive PackageDef val…
halotukozak Feb 19, 2026
c3eda7f
feat(03-04): implement FindUsages rule with symbol name matching and …
halotukozak Feb 19, 2026
50812c0
feat(03-02): implement FinalValueClasses rule with Scala 3 AnyVal det…
halotukozak Feb 19, 2026
11f939e
feat(04-01): implement ConstantDeclarations rule with literal constan…
halotukozak Feb 20, 2026
a19da6f
feat(04-04): implement CheckMacroPrivate rule with inline method dete…
halotukozak Feb 20, 2026
896190c
feat(04-03): implement ShowAst rule with tree.show and annotation det…
halotukozak Feb 20, 2026
41af41e
feat(04-03): implement ExplicitGenerics rule with span-based inferred…
halotukozak Feb 20, 2026
c0a16cd
feat(04-04): implement DiscardedMonixTask rule with recursive tree tr…
halotukozak Feb 20, 2026
e3d3629
feat(04-01): implement ImplicitFunctionParams rule with function type…
halotukozak Feb 20, 2026
dd6afee
feat(04-02): implement ImplicitTypes rule with span-based inferred ty…
halotukozak Feb 20, 2026
dab4fe4
feat(04-02): implement ImplicitValueClasses rule checking implicit cl…
halotukozak Feb 20, 2026
83c531d
feat(05-01): delete Any2StringAdd and silence unknown rule warnings
halotukozak Feb 20, 2026
a682c2d
feat(05-01): implement ValueEnumExhaustiveMatch rule with tests
halotukozak Feb 20, 2026
4ea6f59
feat(05-02): convert implicit def to extension method and apply scalafmt
halotukozak Feb 20, 2026
48b9898
feat: remove FinalValueClasses rule and clean up related test cases
halotukozak Feb 21, 2026
cb9e755
refactor: simplify analyzer rules and test sources
halotukozak Feb 21, 2026
56c8440
chore: remove outdated 02-RESEARCH.md for Phase 2 test infrastructure…
halotukozak Feb 21, 2026
37b688c
revert some tests, formatting
halotukozak Feb 23, 2026
6f2a86b
Update analyzer rules and tests: improve validation, add debug loggin…
halotukozak Feb 23, 2026
99b66ac
Refactor `AnalyzerRule` implementations: simplify class definitions b…
halotukozak Feb 26, 2026
f841cc2
Refactor `AnalyzerRule`: replace `transform*` methods with `verify*`,…
halotukozak Feb 26, 2026
2f07f4e
Refactor `AnalyzerRule`: replace `Seq` with `List` in `requiredSymbol…
halotukozak Feb 26, 2026
5f875fa
Refactor `AnalyzerTest`: simplify `compile` method by returning `Repo…
halotukozak Feb 26, 2026
230a7e9
Remove `CanaryTest` and `TestUtils`: clean up obsolete test cases and…
halotukozak Feb 26, 2026
536abc5
Improve code formatting, standardize type annotations, and add langua…
halotukozak Feb 26, 2026
b78d233
Refactor analyzer rules and tests: improve type handling, simplify va…
halotukozak Feb 26, 2026
22c852b
Refactor `BasePackage` analyzer: remove debug logs, simplify validati…
halotukozak Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ masterSlave/

.metals/
.vscode/
metals.sbt
metals.sbt
.planning/
Original file line number Diff line number Diff line change
@@ -1,92 +1,71 @@
package com.avsystem.commons
package analyzer

import scala.reflect.internal.util.NoPosition
import scala.tools.nsc.plugins.{Plugin, PluginComponent}
import scala.tools.nsc.{Global, Phase}
import dotty.tools.dotc.core.Contexts.Context
import dotty.tools.dotc.plugins.{PluginPhase, StandardPlugin}

final class AnalyzerPlugin(val global: Global) extends Plugin { plugin =>
class AnalyzerPlugin extends StandardPlugin {

override def init(options: List[String], error: String => Unit): Boolean = {
options.foreach { option =>
if (option.startsWith("requireJDK=")) {
val jdkVersionRegex = option.substring(option.indexOf('=') + 1)
override val description: String = "AVSystem custom Scala static analyzer"
val name: String = "AVSystemAnalyzer"
override def initialize(options: List[String])(using Context): List[PluginPhase] = {
val rules: List[AnalyzerRule] = List(
new ImportJavaUtil,
new VarargsAtLeast,
new CheckMacroPrivate,
new ExplicitGenerics,
new ValueEnumExhaustiveMatch,
new ShowAst,
new FindUsages,
new CheckBincompat,
new ThrowableObjects,
new DiscardedMonixTask,
new NothingAsFunctionArgument,
new ConstantDeclarations,
new BasePackage,
new ImplicitTypes,
new ImplicitValueClasses,
new FinalCaseClasses,
new ImplicitParamDefaults,
new CatchThrowable,
new ImplicitFunctionParams,
)
parseOptions(options, rules)
rules.filter(_.level != Level.Off)
}

private def parseOptions(options: List[String], rules: List[AnalyzerRule])(using Context): Unit = {
val byName = rules.map(r => r.name -> r).toMap
options.foreach { opt =>
if (opt.startsWith("requireJDK=")) {
val jdkVersionRegex = opt.substring(opt.indexOf('=') + 1)
val javaVersion = System.getProperty("java.version", "")
if (!javaVersion.matches(jdkVersionRegex)) {
global.reporter.error(
NoPosition,
s"This project must be compiled on JDK version that matches $jdkVersionRegex but got $javaVersion",
if (!javaVersion.matches(jdkVersionRegex))
dotty.tools.dotc.report.error(
s"This project must be compiled on JDK version matching $jdkVersionRegex but got $javaVersion",
)
}
} else {
val level = option.charAt(0) match {
case '-' => Level.Off
case '*' => Level.Info
case '+' => Level.Error
case _ => Level.Warn
val (level, nameArg) = opt.headOption match {
case Some('-') => (Level.Off, opt.drop(1))
case Some('+') => (Level.Error, opt.drop(1))
case Some('*') => (Level.Info, opt.drop(1))
case _ => (Level.Warn, opt)
}
val nameArg = if (level != Level.Warn) option.drop(1) else option
if (nameArg == "_") {
// Split on ':' to extract per-rule argument, e.g. "findUsages:com.foo.Bar"
val colonIdx = nameArg.indexOf(':')
val (ruleName, ruleArg) =
if (colonIdx >= 0) (nameArg.substring(0, colonIdx), Some(nameArg.substring(colonIdx + 1)))
else (nameArg, None)
if (ruleName == "_")
rules.foreach(_.level = level)
} else {
val (name, arg) = nameArg.split(":", 2) match {
case Array(n, a) => (n, a)
case Array(n) => (n, null)
}
rulesByName.get(name) match {
else
byName.get(ruleName) match {
case Some(rule) =>
rule.level = level
rule.argument = arg
case None =>
error(s"Unrecognized AVS analyzer rule: $name")
ruleArg.foreach(arg => rule.argument = Some(arg))
case None => dotty.tools.dotc.report.error(s"Unrecognized AVS analyzer rule: $name")
Comment thread
halotukozak marked this conversation as resolved.
}
}
}
}
true
}

private lazy val rules = List(
new ImportJavaUtil(global),
new VarargsAtLeast(global),
new CheckMacroPrivate(global),
new ExplicitGenerics(global),
new ValueEnumExhaustiveMatch(global),
new ShowAst(global),
new FindUsages(global),
new CheckBincompat(global),
new Any2StringAdd(global),
new ThrowableObjects(global),
new DiscardedMonixTask(global),
new NothingAsFunctionArgument(global),
new ConstantDeclarations(global),
new BasePackage(global),
new ImplicitValueClasses(global),
new FinalValueClasses(global),
new FinalCaseClasses(global),
new ImplicitParamDefaults(global),
new CatchThrowable(global),
new ImplicitFunctionParams(global),
)

private lazy val rulesByName = rules.map(r => (r.name, r)).toMap

val name = "AVSystemAnalyzer"
val description = "AVSystem custom Scala static analyzer"
val components: List[PluginComponent] = List(component)

private object component extends PluginComponent {
val global: plugin.global.type = plugin.global
val runsAfter: List[String] = List("typer")
override val runsBefore: List[String] = List("patmat")
val phaseName = "avsAnalyze"

import global._

def newPhase(prev: Phase): StdPhase = new StdPhase(prev) {
def apply(unit: CompilationUnit): Unit =
rules.foreach(rule => if (rule.level != Level.Off) rule.analyze(unit.asInstanceOf[rule.global.CompilationUnit]))
}
}

}
Original file line number Diff line number Diff line change
@@ -1,59 +1,55 @@
package com.avsystem.commons
package analyzer

import java.io.{PrintWriter, StringWriter}
import scala.tools.nsc.Global
import scala.tools.nsc.Reporting.WarningCategory
import scala.util.control.NonFatal

abstract class AnalyzerRule(val global: Global, val name: String, defaultLevel: Level = Level.Warn) {

import global._

var level: Level = defaultLevel
var argument: String = _

protected def classType(fullName: String): Type =
try rootMirror.staticClass(fullName).asType.toType.erasure
catch {
case _: ScalaReflectionException => NoType
}

protected def analyzeTree(fun: PartialFunction[Tree, Unit])(tree: Tree): Unit =
try fun.applyOrElse(tree, (_: Tree) => ())
catch {
case NonFatal(t) =>
val sw = new StringWriter
t.printStackTrace(new PrintWriter(sw))
reporter.error(tree.pos, s"Analyzer rule $this failed: " + sw.toString)
}

private def adjustMsg(msg: String): String = s"[AVS] $msg"

protected final def report(
pos: Position,
message: String,
category: WarningCategory = WarningCategory.Lint,
site: Symbol = NoSymbol,
level: Level = this.level,
): Unit =
level match {
case Level.Off =>
case Level.Info => reporter.echo(pos, adjustMsg(message))
case Level.Warn => currentRun.reporting.warning(pos, adjustMsg(message), category, site)
case Level.Error => reporter.error(pos, adjustMsg(message))
}

def analyze(unit: CompilationUnit): Unit

override def toString: String =
getClass.getSimpleName
import dotty.tools.dotc.ast.tpd
import dotty.tools.dotc.core.Contexts.Context
import dotty.tools.dotc.plugins.PluginPhase
import dotty.tools.dotc.typer.TyperPhase
import dotty.tools.dotc.transform.Pickler

/**
* Base trait for all AVSystem analyzer rules.
*
* Each rule is a Scala 3 [[PluginPhase]] (MiniPhase) that runs after the typer
* and before the pickler. Rules override one or more `transformX` hooks from
* [[dotty.tools.dotc.transform.MegaPhase.MiniPhase]] to inspect the typed tree and
* emit diagnostics via the [[report]] helper.
*
* Rules MUST return the original tree unchanged from every `transformX` override
* (analysis-only; no tree modification).
*/
trait AnalyzerRule extends PluginPhase {

/** Short name used in plugin options, e.g. `catchThrowable` for `-P:AVSystemAnalyzer:+catchThrowable`. */
val name: String

/** Current diagnostic level. Mutable so [[AnalyzerPlugin]] can configure it from plugin options. */
var level: Level = Level.Warn

/**
* Optional per-rule argument, e.g. the class name for `findUsages:com.foo.Bar`.
* Populated by [[AnalyzerPlugin]] when parsing `ruleName:arg` option syntax.
*/
var argument: Option[String] = None

/** Unique phase name used by the Scala 3 phase scheduler. Namespaced to avoid conflicts. */
final def phaseName: String = s"avs.$name"

/** Run after the typer phase so all symbols and types are fully resolved. */
override def runsAfter: Set[String] = Set(TyperPhase.name)

/** Run before the pickler so we operate on the original typed tree (not yet serialised to TASTy). */
override def runsBefore: Set[String] = Set(Pickler.name)

/** Emit a diagnostic at the given tree's source position, using the configured [[level]]. */
protected final def report(tree: tpd.Tree, message: String)(using Context): Unit = level match {
case Level.Off => ()
case Level.Info => dotty.tools.dotc.report.echo(s"[AVS] $message", tree.srcPos)
case Level.Warn => dotty.tools.dotc.report.warning(s"[AVS] $message", tree.srcPos)
case Level.Error => dotty.tools.dotc.report.error(s"[AVS] $message", tree.srcPos)
}
}

sealed trait Level
object Level {
case object Off extends Level
case object Info extends Level
case object Warn extends Level
case object Error extends Level
enum Level {
case Off, Info, Warn, Error
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
package com.avsystem.commons
package analyzer

import scala.annotation.tailrec
import scala.tools.nsc.Global
import dotty.tools.dotc.ast.tpd
import dotty.tools.dotc.core.Contexts.Context
import dotty.tools.dotc.core.Symbols
import dotty.tools.dotc.core.Symbols.Symbol

class BasePackage(g: Global) extends AnalyzerRule(g, "basePackage") {
import scala.annotation.tailrec

import global._
class BasePackage(using Context) extends AnalyzerRule {
private lazy val requiredBasePackage = argument.map(Symbols.requiredPackage)

object SkipImports {
@tailrec def unapply(stats: List[Tree]): Some[List[Tree]] = stats match {
case Import(_, _) :: tail => unapply(tail)
case stats => Some(stats)
}
val name: String = "basePackage"
override def transformUnit(tree: tpd.Tree)(using Context): tpd.Tree = {
requiredBasePackage.foreach(validate(tree, _))
tree
}

def analyze(unit: CompilationUnit): Unit = if (argument != null) {
val requiredBasePackage = argument

@tailrec def validate(tree: Tree): Unit = tree match {
case PackageDef(pid, _) if pid.symbol.hasPackageFlag && pid.symbol.fullName == requiredBasePackage =>
case PackageDef(_, SkipImports(List(stat))) => validate(stat)
case t => report(t.pos, s"`$requiredBasePackage` must be one of the base packages in this file")
}

validate(unit.body)
@tailrec
private def validate(tree: tpd.Tree, required: Symbol)(using Context): Unit = tree match {
case pkg: tpd.PackageDef if pkg.pid.symbol == required =>
// Found the required base package -- validation passes
()
case pkg: tpd.PackageDef =>
// Skip imports, recurse into the single remaining stat (if exactly one)
val nonImports = pkg.stats.filterNot(_.isInstanceOf[tpd.Import])
nonImports match {
case stat :: Nil => validate(stat, required)
case _ =>
report(tree, s"`$required` must be one of the base packages in this file")
}
case _ =>
report(tree, s"`$required` must be one of the base packages in this file")
}
}
Loading
Loading