Cross-Language Code Copying Is Outpacing Detection Tools

The Python file landed in my inbox on a Tuesday. A TA at a mid-sized state school had spent two hours trying to explain why it felt familiar. 140 lines of Dijkstra's algorithm, clean and correct, submitted by a first-year who had never taken a Python course. Three weeks earlier a different student had turned in the same algorithm in Java. JPlag scored the pair at 4 percent. MOSS, run twice with different language flags, never put the two files in the same result set at all.

That's the shape of a problem that has quietly gotten worse since about 2023. Cross-language code copying used to require a student fluent enough in two languages to transliterate by hand, and hand transliteration leaves fingerprints. Now it's one prompt. The mechanical work is gone, the structure is preserved, and most of the tooling a department already owns is looking in the wrong direction.

What cross-language code plagiarism actually looks like

Here are the two submissions, trimmed. Java first.

public static int[] dijkstra(int[][] g, int src) {
    int n = g.length;
    int[] dist = new int[n];
    for (int i = 0; i < n; i++) dist[i] = 999999;
    dist[src] = 0;
    boolean[] used = new boolean[n];
    for (int k = 0; k < n - 1; k++) {
        int u = -1;
        for (int i = 0; i < n; i++)
            if (!used[i] && (u == -1 || dist[i] < dist[u])) u = i;
        used[u] = true;
        for (int v = 0; v < n; v++)
            if (!used[v] && g[u][v] != 0 && dist[u] + g[u][v] < dist[v])
                dist[v] = dist[u] + g[u][v];
    }
    return dist;
}

And the Python.

def dijkstra(g, src):
    n = len(g)
    dist = [999999] * n
    dist[src] = 0
    used = [False] * n
    for k in range(n - 1):
        u = -1
        for i in range(n):
            if not used[i] and (u == -1 or dist[i] < dist[u]):
                u = i
        used[u] = True
        for v in range(n):
            if not used[v] and g[u][v] != 0 and dist[u] + g[u][v] < dist[v]:
                dist[v] = dist[u] + g[u][v]
    return dist

Read those side by side and the shared choices are obvious. 999999 as a stand-in for infinity instead of Integer.MAX_VALUE or float('inf'). The u == -1 sentinel in a selection loop that could just as easily have started at index 0. The n - 1 outer bound. The g[u][v] != 0 convention for "no edge," which is a Java-shaped decision because Python developers reach for None or a sparse dict. Even the used array name survives.

None of those are bugs. They're decisions, and decisions are what a plagiarist actually copies. Tokens, unfortunately, are what most detectors compare.

Why token matchers and AST pipelines miss it

MOSS, from Schleimer, Wilkerson, and Aiken at Stanford in 2003, is still the reference design: hash overlapping k-grams of tokens for each file, winnow the set down, and count shared fingerprints. JPlag's token-stream and greedy string tiling approach, rewritten for v5 in 2020 to support 15-plus languages, works on the same assumption. Flatten the source into tokens, then look for long matching runs.

That assumption collapses the moment languages change. int u = -1; in Java tokenizes to something close to int, u, =, -, 1, ;. The Python version is u, =, -, 1 with no type and no terminator. Normalize those differences and you've already started writing a language-agnostic normalizer, which is precisely the hard part and precisely what neither tool was built to do.

Side-by-side code comparison in Codequiry showing a 91% match between two student submissions
Side-by-side comparison: Codequiry lines up matching code between two submissions, with confirmed and false-positive review labels.

It's worth being fair here. These tools do what they were designed to do, and MOSS in particular has caught more contract cheating than any other piece of software in CS education. The gap is structural, not negligent. A submission set in MOSS is bound to a single -l flag, and each run produces its own result page. There is no Java-to-Python comparison because there is no shared token alphabet to compare.

Tool Cross-language comparison of a submission pair Notes
MOSS (2003, still maintained) No. Runs are per-language via -l. Strong within-language fingerprinting. Results expire after two weeks unless you host them.
JPlag 5.x No. A submission set is one language. Better reporting than MOSS, same language boundary. Good API.
Dolos (Helder & Maertens, 2022) Partial. Per-language AST normalization, not designed for cross-language pairs. Cleanest open-source architecture of the three. Extensible if you want to build.
Codequiry Yes, across 65 approved languages. Normalizes structure before comparison and runs peer, web, and AI checks in one report.

The three ways students copy across languages now

The old path was manual. A student who knew Java took a friend's solution and retyped it in Python, keeping the logic and changing the syntax by hand. Slow, error-prone, and it usually left small tells: off-by-one habits carried across, a comment translated word for word, variable names that read like Java with underscores bolted on (dist_array, is_used).

The second path is LLM translation. Paste the Java into a chat window, ask for Python, done in eight seconds. This is now the dominant vector in the cases I've been asked to look at, and it's the one that defeats human graders most reliably, because the output is idiomatic. The model renames variables, swaps some literals, adjusts formatting. A TA reading it cold sees clean Python.

The third path is quieter. A student doesn't copy from a classmate at all. They find a well-known implementation on GitHub in C++ or C# and ask a model to port it. Public repos are full of algorithm implementations in every language, and the provenance trail runs to a source the instructor never expected anyone to consult. That's a different problem from peer collusion, and it needs a different check: web and repository matching, not just a comparison against the current cohort.

How cross-language code similarity detection works under the hood

There are three viable technical routes, and the realistic systems combine them.

The first is intermediate representation. Compile each submission down to something language-neutral and compare there. LLVM IR is the obvious target if you're working in C, C++, or Rust, and there's a solid research line going back a decade on IR-based clone detection. The problem is Python, JavaScript, and the rest of the dynamic languages, which don't compile to anything you can compare against a C++ binary without losing most of the signal.

The second is a language-agnostic structural profile. Strip identifiers, generalize literals by type, and extract a small vector per function: control-flow graph node and edge counts, loop nesting depth histogram, branch density, number of distinct operators used, comment-to-code ratio, and the multiset of surviving numeric constants. That last one is doing more work than people expect. A student who preserves 999999 has preserved something no independent implementation would produce.

The third is semantic. Embed function bodies with a code-trained model and compare vectors. This catches paraphrased logic that structural profiling misses, but the false-positive rate on short utility functions is bad, and it's hard to explain to an academic integrity board why a 0.87 cosine similarity means anything. I've watched more than one committee meeting stall on exactly that question.

Production systems tend to run structural profiling as the primary signal and use embeddings or a token-level check as a tiebreaker. That combination is also what lets a system compare across 65 languages without pretending Python and Java share a token vocabulary.

Codequiry supported programming languages page listing 65 approved languages and custom parser submission
65 supported languages, plus custom parsers for anything unusual a course or codebase throws at the engine.

What the LLM translation step changed

Here's the part that made this problem worse rather than just different. When a student hand-transliterates, the translation is lossy in ways that help you. They drop the u == -1 sentinel because it feels wrong in Python. They switch to float('inf'). They rename used to visited because that's what the tutorial said.

Ask a 2025-era model to port the same file and it often does the opposite. It keeps the sentinel, because the sentinel is in the input. It keeps 999999, because changing it would change behavior and the model is being conservative. It keeps the loop bounds and the adjacency-matrix convention. What it changes is surface: names, whitespace, type hints. The skeleton transfers intact, and the skeleton is the evidence.

The translation step removes the low-level tells and preserves the high-level ones. That's the opposite of what most detectors are tuned to notice.

It also stacks badly with AI detection run in isolation. If a student translated a classmate's Java through a model, the Python file will score high on AI generation. That's true and it's also incomplete. An AI-written-code flag tells you the file probably came from a model. It doesn't tell you the model had someone else's homework in the prompt. Running a AI code detector alone gets you a misconduct finding with the wrong explanation attached, which is a bad place to be in front of a review board.

Codequiry per-file AI analysis showing AI versus human probability for each file with written indicators
Drilling into one submission: per-file AI and human probabilities, each with the stylistic indicators behind the score.

Where detection still breaks down

Cross-language comparison has a false positive problem that within-language comparison does not, and departments should understand it before they sanction anyone.

A student who learned to program in Java and is taking their first Python course will write Java-shaped Python. Same loop structure, same sentinels, same habit of preallocating arrays. That's a legitimate transfer-learning artifact, and a structural profiler will happily flag it against another Java-native student's submission. I watched a normalizer in an early build collapse for and while loops into a single node type, which produced an 88 percent structural match between two files that shared nothing but a loop nest three levels deep. We fixed it. The lesson stuck.

The honest position is that structural cross-language scores should be treated as a lead, not a verdict. A 70 percent structural match with preserved magic constants and a matching CFG profile is worth an interview. A 70 percent structural match on two 12-line helper functions is noise. We haven't tested the semantic-embedding route past a few thousand submissions, so I'd hold off on treating cosine scores as anything but a ranking signal.

A practical playbook for instructors

Three things actually move the needle, and none of them are exotic.

Compare against a corpus, not a class. If your detector only compares this semester's Python submissions to each other, the Java file from last fall is invisible. Keep a rolling archive across semesters and languages, and run the new cohort against all of it. This is the single highest-value configuration change a department can make, and it costs nothing but disk.

Require process artifacts and use them. Commit history in a private repo, timestamps, an in-class five-minute defense of a randomly selected function. The defense is cheap to run and brutal on copied code, because the student who translated a file cannot explain why the sentinel is -1 instead of a nullable type.

Design assignments where the language matters. Ask for a Python generator that yields shortest paths lazily, or a Java implementation that uses a PriorityQueue with a custom comparator. Translated code can satisfy these requirements, but the translation has to be genuinely rewritten rather than mechanically ported, and the more rewriting happens the more the structure diverges from the source. It's not a wall. It raises the cost.

For the comparison step itself, a tool that handles the language boundary in one pass is worth the license. Codequiry normalizes across its supported language set and reports peer similarity, web and GitHub matches, and AI generation in the same source code plagiarism checker run, which is the only reason the Dijkstra case would have surfaced in a single afternoon instead of never. It isn't a replacement for judgment, and it won't tell you whether a Java-shaped Python file is misconduct or a transfer artifact. That call stays with the instructor.

But it will put the two files in the same window, which is more than MOSS was ever going to do. If your department is still running single-language comparisons and calling it coverage, the gap is wider than you think. You can see how the cross-language pass looks on your own submissions with a code plagiarism checker that was built to compare them.