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

Feature: Submission instrumentation #151

Draft
wants to merge 30 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4858461
Add prototype for full project instrumentation
Hapstyx Nov 15, 2024
3e3e7b1
Add more documentation
Hapstyx Nov 17, 2024
9656943
Refactoring
Hapstyx Nov 17, 2024
ce09349
Add configuration options
Hapstyx Nov 17, 2024
6e0a0fe
Add more missing documentation
Hapstyx Nov 17, 2024
a3f4cbf
Fixes and refactoring
Hapstyx Nov 18, 2024
c545b48
Implement constructor substitution
Hapstyx Nov 18, 2024
cd102da
Record stack trace in invocation
Hapstyx Nov 19, 2024
35a7d24
Implement method call replacement
Hapstyx Nov 19, 2024
ebfbde1
Refactoring
Hapstyx Nov 19, 2024
c8c2e90
Refactor method calling instructions
Hapstyx Nov 20, 2024
390c102
Fix method signature for getOriginalFieldHeaders and getOrignalMethod…
Hapstyx Nov 21, 2024
f5ba418
Filter synthetic fields and methods from original headers
Hapstyx Nov 21, 2024
b256ed4
Fix NPE in SolutionClassNode
Hapstyx Nov 21, 2024
8185233
Fix bug where submission class was loaded from wrong class loader
Hapstyx Nov 23, 2024
3fe4a44
Use submission-info.json for finding submission classes
Hapstyx Nov 26, 2024
23b5c45
Fixes and refactoring
Hapstyx Nov 26, 2024
3ad6919
Make framework more robust for incompatible fields and methods (stati…
Hapstyx Nov 26, 2024
9bc6a86
Simplify header replication
Hapstyx Nov 27, 2024
98111aa
Allow defining alternative names for solution class matching
Hapstyx Nov 28, 2024
d2d1a89
Refactoring, documentation, etc.
Hapstyx Nov 29, 2024
44e2e74
Create separate exception for irrecoverable header mismatches
Hapstyx Nov 30, 2024
d5bcfa6
Refactor SimilarityMatcher and prevent duplicates
Hapstyx Nov 30, 2024
09aec1e
Allow targeting specific methods for disabling logging, substitution …
Hapstyx Nov 30, 2024
a645cfe
Allow calling the original method from substituted methods
Hapstyx Nov 30, 2024
71721e2
Current state
Hapstyx Dec 3, 2024
527fa06
Refactor method visitors
Hapstyx Dec 3, 2024
c61bf01
Add mechanism for injecting missing classes
Hapstyx Dec 3, 2024
860b077
Remove locals from injected methods since they're static
Hapstyx Dec 3, 2024
2d74300
Refactoring and enable injecting missing classes into Jagr
Hapstyx Dec 3, 2024
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
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ jagr = "org.sourcegrade:jagr-launcher:0.10.3"
mockito = "org.mockito:mockito-core:5.14.1"
junit = "org.junit.jupiter:junit-jupiter:5.11.2"
asm = "org.ow2.asm:asm:9.7.1"
asmTree = "org.ow2.asm:asm-tree:9.7.1"
dokkaKotlinAsJavaPlugin = "org.jetbrains.dokka:kotlin-as-java-plugin:1.9.20"
dokkaBase = "org.jetbrains.dokka:dokka-base:1.9.20"
[plugins]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package org.tudalgo.algoutils.student.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Forces the annotated type or member to be mapped to the specified one.
* Mappings must be 1:1, meaning multiple annotated members (or types) may not map to the same target and
* all members and types may be targeted by at most one annotation.
*
* @author Daniel Mangold
*/
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.METHOD})
@Retention(RetentionPolicy.CLASS)
public @interface ForceSignature {

/**
* The identifier of the annotated type / member.
* The value must be as follows:
* <ul>
* <li>Types: the fully qualified name of the type (e.g., {@code java.lang.Object})</li>
* <li>Fields: the name / identifier of the field</li>
* <li>Constructors: Always {@code <init>}, regardless of the class</li>
* <li>Methods: the name / identifier of the method</li>
* </ul>
*
* @return the type / member identifier
*/
String identifier();

/**
* The method descriptor as specified by
* <a href="https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-4.html#jvms-4.3">Chapter 4.3</a>
* of the Java Virtual Machine Specification.
* If a value is set, it takes precedence over {@link #returnType()} and {@link #parameterTypes()}.
*
* <p>
* Note: Setting this value has no effect for types or fields.
* </p>
*
* @return the method's descriptor
*/
String descriptor() default "";

/**
* The class object specifying the method's return type.
* If a value is set, it will be overwritten if {@link #descriptor()} is also set.
* Default is no return type (void).
*
* <p>
* Note: Setting this value has no effect for types or fields.
* </p>
*
* @return the method's return type
*/
Class<?> returnType() default void.class;

/**
* An array of class objects specifying the method's parameter types.
* The classes need to be given in the same order as they are declared by the targeted method.
* If a value is set, it will be overwritten if {@link #descriptor()} is also set.
* Default is no parameters.
*
* <p>
* Note: Setting this value has no effect for types or fields.
* </p>
*
* @return the method's parameter types
*/
Class<?>[] parameterTypes() default {};
}
1 change: 1 addition & 0 deletions tutor/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies {
api(libs.jagr)
api(libs.mockito)
api(libs.asm)
api(libs.asmTree)
testImplementation(libs.junit)
dokkaPlugin(libs.dokkaKotlinAsJavaPlugin)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
package org.tudalgo.algoutils.transform;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.sourcegrade.jagr.api.testing.extension.JagrExecutionCondition;
import org.tudalgo.algoutils.student.annotation.ForceSignature;
import org.tudalgo.algoutils.transform.classes.MissingClassVisitor;
import org.tudalgo.algoutils.transform.classes.SolutionClassNode;
import org.tudalgo.algoutils.transform.classes.SubmissionClassInfo;
import org.tudalgo.algoutils.transform.classes.SubmissionClassVisitor;
import org.tudalgo.algoutils.transform.util.MethodHeader;
import org.tudalgo.algoutils.transform.util.TransformationContext;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Type;
import org.sourcegrade.jagr.api.testing.ClassTransformer;
import org.tudalgo.algoutils.tutor.general.SubmissionInfo;

import java.lang.reflect.Executable;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

/**
* A class transformer that allows logging, substitution and delegation of method invocations.
* This transformer uses two source sets: solution classes and submission classes.
* <br><br>
* <b>Solution classes</b>
* <p>
* Solution classes are compiled java classes located in the {@code resources/classes/} directory.
* Their class structure must match the one defined in the exercise sheet but they may define additional
* members such as fields and methods.
* Additional types (classes, interfaces, enums, etc.) may also be defined.
* <br>
* The directory structure must match the one in the output / build directory.
* Due to limitations of {@link Class#getResourceAsStream(String)} the compiled classes cannot have
* the {@code .class} file extension, so {@code .bin} is used instead.
* For example, a class {@code MyClass} with an inner class {@code Inner} in package {@code my.package}
* would be compiled to {@code my/package/MyClass.class} and {@code my/package/MyClass$Inner.class}.
* In the solution classes directory they would be located at {@code resources/classes/my/package/MyClass.bin}
* and {@code resources/classes/my/package/MyClass$Inner.bin}, respectively.
* </p>
*
* <b>Submission classes</b>
* <p>
* Submission classes are the original java classes in the main module.
* They are compiled externally and processed one by one using {@link #transform(ClassReader, ClassWriter)}.
* In case classes or members are misnamed, this transformer will attempt to map them to the closest
* matching solution class / member.
* If both the direct and similarity matching approach fail, the intended target can be explicitly
* specified using the {@link ForceSignature} annotation.
* </p>
* <br><br>
* Implementation details:
* <ul>
* <li>
* Unless otherwise specified, this transformer and its tools use the internal name as specified by
* {@link Type#getInternalName()} when referring to class names.
* </li>
* <li>
* The term "descriptor" refers to the bytecode-level representation of types, such as
* field types, method return types or method parameter types as specified by
* <a href="https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-4.html#jvms-4.3">Chapter 4.3</a>
* of the Java Virtual Machine Specification.
* </li>
* </ul>
*
* @see SubmissionClassVisitor
* @see SubmissionExecutionHandler
* @author Daniel Mangold
*/
@SuppressWarnings("unused")
public class SolutionMergingClassTransformer implements ClassTransformer {

/**
* An object providing context throughout the transformation processing chain.
*/
private final TransformationContext transformationContext;
private boolean readSubmissionClasses = false;

/**
* Constructs a new {@link SolutionMergingClassTransformer} instance.
*
* @param projectPrefix the root package containing all submission classes, usually the sheet number
* @param availableSolutionClasses the list of solution class names (fully qualified) to use
*/
public SolutionMergingClassTransformer(String projectPrefix, String... availableSolutionClasses) {
this(Arrays.stream(availableSolutionClasses).reduce(new Builder(projectPrefix),
Builder::addSolutionClass,
(builder, builder2) -> builder));
}

/**
* Constructs a new {@link SolutionMergingClassTransformer} instance with config settings from
* the given builder.
*
* @param builder the builder object
*/
@SuppressWarnings("unchecked")
private SolutionMergingClassTransformer(Builder builder) {
Map<String, SolutionClassNode> solutionClasses = new HashMap<>();
Map<String, SubmissionClassInfo> submissionClasses = new ConcurrentHashMap<>();
this.transformationContext = new TransformationContext(Collections.unmodifiableMap(builder.configuration),
solutionClasses,
submissionClasses);
((Map<String, List<String>>) builder.configuration.get(Config.SOLUTION_CLASSES)).keySet()
.forEach(className -> solutionClasses.put(className, transformationContext.readSolutionClass(className)));
}

@Override
public String getName() {
return SolutionMergingClassTransformer.class.getSimpleName();
}

@Override
public int getWriterFlags() {
return ClassWriter.COMPUTE_MAXS;
}

@Override
public void transform(ClassReader reader, ClassWriter writer) {
if (!new JagrExecutionCondition().evaluateExecutionCondition(null).isDisabled()) { // if Jagr is present
try {
Method getClassLoader = ClassWriter.class.getDeclaredMethod("getClassLoader");
getClassLoader.setAccessible(true);
ClassLoader submissionClassLoader = (ClassLoader) getClassLoader.invoke(writer);
transformationContext.setSubmissionClassLoader(submissionClassLoader);

if (!readSubmissionClasses) {
Set<String> submissionClassNames = new ObjectMapper()
.readValue(submissionClassLoader.getResourceAsStream("submission-info.json"), SubmissionInfo.class)
.sourceSets()
.stream()
.flatMap(sourceSet -> sourceSet.files().get("java").stream())
.map(submissionClassName -> submissionClassName.replaceAll("\\.java$", ""))
.collect(Collectors.toSet());
transformationContext.setSubmissionClassNames(submissionClassNames);
transformationContext.computeClassesSimilarity();

readSubmissionClasses = true;
}
} catch (Exception e) {
throw new RuntimeException(e);
}
} else {
// TODO: fix this for regular JUnit run
transformationContext.setSubmissionClassLoader(null);
}
reader.accept(new SubmissionClassVisitor(writer, transformationContext, reader.getClassName()), 0);
}

@Override
public Map<String, byte[]> injectClasses() {
Set<String> visitedClasses = transformationContext.getVisitedClasses();
Map<String, byte[]> missingClasses = new HashMap<>();

transformationContext.getSolutionClasses()
.entrySet()
.stream()
.filter(entry -> !visitedClasses.contains(entry.getKey()))
.forEach(entry -> {
ClassWriter writer = new ClassWriter(0);
entry.getValue().accept(new MissingClassVisitor(writer, transformationContext, entry.getValue()));
missingClasses.put(entry.getKey().replace('/', '.'), writer.toByteArray());
});

return missingClasses;
}

/**
* (Internal) Configuration keys
*/
public enum Config {
PROJECT_PREFIX(null),
SOLUTION_CLASSES(new HashMap<>()),
SIMILARITY(0.90),
METHOD_REPLACEMENTS(new HashMap<MethodHeader, MethodHeader>());

private final Object defaultValue;

Config(Object defaultValue) {
this.defaultValue = defaultValue;
}
}

/**
* Builder for {@link SolutionMergingClassTransformer}.
*/
public static class Builder {

private final Map<Config, Object> configuration = new EnumMap<>(Config.class);

/**
* Constructs a new {@link Builder}.
*
* @param projectPrefix the root package containing all submission classes, usually the sheet number
*/
public Builder(String projectPrefix) {
for (Config config : Config.values()) {
configuration.put(config, config.defaultValue);
}
configuration.put(Config.PROJECT_PREFIX, projectPrefix);
}

@SuppressWarnings("unchecked")
public Builder addSolutionClass(String solutionClassName, String... altNames) {
((Map<String, List<String>>) configuration.get(Config.SOLUTION_CLASSES)).put(
solutionClassName.replace('.', '/'),
Arrays.stream(altNames).map(s -> s.replace('.', '/')).toList()
);
return this;
}

/**
* Sets the threshold for matching submission classes to solution classes via similarity matching.
*
* @param similarity the new similarity threshold
* @return the builder object
*/
public Builder setSimilarity(double similarity) {
configuration.put(Config.SIMILARITY, similarity);
return this;
}

/**
* Replaces all calls to the target executable with calls to the replacement executable.
* The replacement executable must be accessible from the calling class, be static and declare
* the same parameter types and return type as the target.
* If the target executable is not static, the replacement must declare an additional parameter
* at the beginning to receive the object the target was called on.<br>
* Example:<br>
* Target: {@code public boolean equals(Object)} in class {@code String} =>
* Replacement: {@code public static boolean <name>(String, Object)}
*
* @param targetExecutable the targeted method / constructor
* @param replacementExecutable the replacement method / constructor
* @return the builder object
*/
public Builder addMethodReplacement(Executable targetExecutable, Executable replacementExecutable) {
return addMethodReplacement(new MethodHeader(targetExecutable), new MethodHeader(replacementExecutable));
}

/**
* Replaces all calls to the matching the target's method header with calls to the replacement.
* The replacement must be accessible from the calling class, be static and declare
* the same parameter types and return type as the target.
* If the target is not static, the replacement must declare an additional parameter
* at the beginning to receive the object the target was called on.<br>
* Example:<br>
* Target: {@code public boolean equals(Object)} in class {@code String} =>
* Replacement: {@code public static boolean <name>(String, Object)}
*
* @param targetMethodHeader the header of the targeted method / constructor
* @param replacementMethodHeader the header of the replacement method / constructor
* @return the builder object
*/
@SuppressWarnings("unchecked")
public Builder addMethodReplacement(MethodHeader targetMethodHeader, MethodHeader replacementMethodHeader) {
if (!Modifier.isStatic(replacementMethodHeader.access())) {
throw new IllegalArgumentException("Replacement method " + replacementMethodHeader + " is not static");
}

((Map<MethodHeader, MethodHeader>) configuration.get(Config.METHOD_REPLACEMENTS))
.put(targetMethodHeader, replacementMethodHeader);
return this;
}

/**
* Constructs the transformer.
*
* @return the configured {@link SolutionMergingClassTransformer} object
*/
public SolutionMergingClassTransformer build() {
return new SolutionMergingClassTransformer(this);
}
}
}
Loading
Loading