# Java Harness Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.

**Goal:** Everything needed to compile student Java, run hidden JUnit suites, measure how much a curveball cost, and detect races — so that corpus content can then be authored against a fixed convention.

**Why this is the blocker:** twenty problems × (base suite + 2–3 curveball suites + concurrency tests) cannot be written until the layout, classpath and result format are fixed. This plan fixes them.

**Tech Stack:** Node 25 zero-dependency ESM, `.toolchain/jdk-21`, `junit-platform-console-standalone.jar` 1.11.4, `node --test`.

**Prerequisite already done:** `server/lib/paths.mjs`, `tools/bootstrap.mjs` (JDK 21 verified), `server/lib/db.mjs`, `server/lib/corpus.mjs`.

**Also required from the M1 plan:** `### Task 6: Java compilation and diagnostic parsing` → `server/lib/java.mjs`. Implement that first if absent; the tasks below import `compile`, `writeFiles`, `listJavaFiles` from it.

---

## Conventions this plan establishes

**No git.** End each task with `node tools/snapshot.mjs "<label>"`.
**Tests:** bare `node --test`. Never `node --test <dir>` — it fails on this Node install.

### Corpus test layout — fixed here, depended on by all content

```
corpus/<slug>/
  scaffold/                     files handed to the candidate
  tests_base/                   hidden JUnit 5 → Phase 3
    <Slug>BaseTest.java
  curveballs/
    01-<kebab-name>/
      REQUIREMENT-CHANGE.md     the text delivered mid-round
      tests/<Slug>Curveball01Test.java
      budget.json               {"diff_budget": N, "measured_against": "reference"}
  tests_concurrency/            optional
    <Slug>StressTest.java
  reference/
    src/**.java                 the reference solution
    DECISION_LOG.md
```

### Java package convention

**All corpus code is in the default (unnamed) package.** No `package` declarations anywhere — not in
scaffolds, tests, or references. Rationale: it removes an entire class of "wrong directory" failures
under a 12-minute clock, and interview graders never care about package structure. Test classes
therefore reference student types directly by simple name.

### Test naming

Test classes end in `Test`. Test methods use `@DisplayName` with a short human sentence, because the
UI shows those strings to the user as the checklist of what their code must do.

---

### Task 1: The concurrency helper class

Shipped once, added to the classpath for every concurrency run, so problem authors never re-implement
thread coordination.

**Files:**
- Create: `harness/DojoConcurrency.java`
- Create: `server/test/harness-files.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/harness-files.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { ROOT } from '../lib/paths.mjs';

const HELPER = join(ROOT, 'harness', 'DojoConcurrency.java');

test('the concurrency helper exists and is in the default package', () => {
  assert.ok(existsSync(HELPER), 'harness/DojoConcurrency.java must exist');
  const src = readFileSync(HELPER, 'utf8');
  assert.doesNotMatch(src, /^\s*package\s+/m, 'corpus Java uses the default package');
});

test('the helper exposes the coordination primitives problems rely on', () => {
  const src = readFileSync(HELPER, 'utf8');
  for (const sig of ['assertConcurrent', 'runContended', 'repeat']) {
    assert.match(src, new RegExp(`\\b${sig}\\s*\\(`), `missing ${sig}`);
  }
});
```

- [ ] **Step 2: Run it and confirm failure**

Run: `node --test server/test/harness-files.test.mjs`
Expected: FAIL — helper does not exist

- [ ] **Step 3: Implement `harness/DojoConcurrency.java`**

```java
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Coordination primitives for concurrency drills.
 *
 * The point of every method here is to make races LIKELY rather than possible.
 * Threads are started and then released simultaneously from a latch, so they
 * collide on purpose instead of drifting past each other.
 */
public final class DojoConcurrency {

    private DojoConcurrency() {}

    /**
     * Runs every task at the same instant and collects failures.
     * Throws AssertionError listing all exceptions if any thread failed,
     * or if the tasks did not finish inside the timeout (which usually means deadlock).
     */
    public static void assertConcurrent(List<Runnable> tasks, int timeoutSeconds) throws InterruptedException {
        int threads = tasks.size();
        ExecutorService pool = Executors.newFixedThreadPool(threads);
        List<Throwable> failures = Collections.synchronizedList(new ArrayList<>());
        CountDownLatch ready = new CountDownLatch(threads);
        CountDownLatch go = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(threads);

        try {
            for (Runnable task : tasks) {
                pool.submit(() -> {
                    ready.countDown();
                    try {
                        go.await();          // every thread blocks here, then all wake together
                        task.run();
                    } catch (Throwable t) {
                        failures.add(t);
                    } finally {
                        done.countDown();
                    }
                });
            }
            if (!ready.await(timeoutSeconds, TimeUnit.SECONDS)) {
                throw new AssertionError("threads did not reach the start line in time");
            }
            go.countDown();
            if (!done.await(timeoutSeconds, TimeUnit.SECONDS)) {
                throw new AssertionError("tasks did not finish in " + timeoutSeconds
                        + "s — likely a deadlock or a lock held too long");
            }
        } finally {
            pool.shutdownNow();
        }

        if (!failures.isEmpty()) {
            StringBuilder sb = new StringBuilder(failures.size() + " of " + threads + " threads failed:");
            for (Throwable t : failures) sb.append("\n  ").append(t);
            throw new AssertionError(sb.toString());
        }
    }

    /** Convenience: run the same action on `threads` threads simultaneously. */
    public static void runContended(int threads, Runnable action) throws InterruptedException {
        List<Runnable> tasks = new ArrayList<>();
        for (int i = 0; i < threads; i++) tasks.add(action);
        assertConcurrent(tasks, 20);
    }

    /**
     * Runs a scenario `times` times. Races are probabilistic: a single green run
     * proves nothing, so every stress assertion is repeated.
     */
    public static void repeat(int times, ThrowingRunnable scenario) throws Exception {
        for (int i = 0; i < times; i++) {
            try {
                scenario.run();
            } catch (Throwable t) {
                throw new AssertionError("failed on repetition " + (i + 1) + " of " + times, t);
            }
        }
    }

    /** Counts successful operations across threads, for lost-update detection. */
    public static AtomicInteger counter() {
        return new AtomicInteger();
    }

    @FunctionalInterface
    public interface ThrowingRunnable {
        void run() throws Exception;
    }
}
```

- [ ] **Step 4: Confirm the test passes and the helper compiles**

Run: `node --test server/test/harness-files.test.mjs`
Expected: PASS — 2 tests

Then prove it compiles with the real toolchain:

```bash
mkdir -p workspace/_harness_check && ./.toolchain/jdk-21/bin/javac.exe -d workspace/_harness_check harness/DojoConcurrency.java && ls workspace/_harness_check
```

Expected: `DojoConcurrency.class` and `DojoConcurrency$ThrowingRunnable.class` present, no warnings.

- [ ] **Step 5: Snapshot** — `node tools/snapshot.mjs "harness-1-concurrency-helper"`

---

### Task 2: JUnit suite runner

**Files:**
- Create: `server/lib/junit.mjs`
- Create: `server/test/junit.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/junit.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseJunitXml, summarise } from '../lib/junit.mjs';

const XML = `<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="JUnit Jupiter" tests="3" failures="1" errors="0" skipped="0" time="0.21">
  <testcase name="parks a car and issues a ticket" classname="ParkingLotBaseTest" time="0.01"/>
  <testcase name="rejects entry when full" classname="ParkingLotBaseTest" time="0.02">
    <failure message="expected: &lt;true&gt; but was: &lt;false&gt;" type="org.opentest4j.AssertionFailedError">
      stack trace here
    </failure>
  </testcase>
  <testcase name="computes a fee on exit" classname="ParkingLotBaseTest" time="0.01"/>
</testsuite>`;

test('parseJunitXml extracts one entry per testcase with pass/fail', () => {
  const r = parseJunitXml(XML);
  assert.equal(r.length, 3);
  assert.deepEqual(r[0], { name: 'parks a car and issues a ticket', className: 'ParkingLotBaseTest', passed: true, message: null });
  assert.equal(r[1].passed, false);
  assert.match(r[1].message, /expected: <true> but was: <false>/);
  assert.equal(r[2].passed, true);
});

test('parseJunitXml decodes XML entities in failure messages', () => {
  const r = parseJunitXml(XML);
  assert.doesNotMatch(r[1].message, /&lt;|&gt;|&amp;/);
});

test('parseJunitXml handles an error element as a failure', () => {
  const xml = `<testsuite tests="1"><testcase name="boom" classname="T">` +
              `<error message="NullPointerException" type="java.lang.NullPointerException"/></testcase></testsuite>`;
  const r = parseJunitXml(xml);
  assert.equal(r[0].passed, false);
  assert.match(r[0].message, /NullPointerException/);
});

test('parseJunitXml returns an empty array when no testcases ran', () => {
  assert.deepEqual(parseJunitXml('<testsuite tests="0"></testsuite>'), []);
});

test('summarise counts passes and totals and reports green only when all pass', () => {
  assert.deepEqual(summarise(parseJunitXml(XML)), { pass: 2, total: 3, green: false });
  const allPass = [{ passed: true }, { passed: true }];
  assert.deepEqual(summarise(allPass), { pass: 2, total: 2, green: true });
  assert.deepEqual(summarise([]), { pass: 0, total: 0, green: false });
});
```

Note the last assertion: **an empty suite is not green.** A run that executed zero tests must never
report success — that is how a broken classpath silently turns into a false pass.

- [ ] **Step 2: Run it and confirm failure**

Run: `node --test server/test/junit.test.mjs`
Expected: FAIL — `Cannot find module '../lib/junit.mjs'`

- [ ] **Step 3: Implement `server/lib/junit.mjs`**

```javascript
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdirSync, rmSync, existsSync, readFileSync, readdirSync, cpSync } from 'node:fs';
import { join } from 'node:path';
import { javaPath, javacPath, JDK_DIR, JUNIT_JAR, ROOT } from './paths.mjs';
import { listJavaFiles, parseDiagnostics } from './java.mjs';

const exec = promisify(execFile);
const HARNESS_DIR = join(ROOT, 'harness');

const ENTITIES = { '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'", '&amp;': '&' };

function decode(s) {
  // &amp; must be last so "&amp;lt;" does not become "<".
  return s.replace(/&(lt|gt|quot|apos);/g, (m) => ENTITIES[m]).replace(/&amp;/g, '&');
}

export function parseJunitXml(xml) {
  const out = [];
  const caseRe = /<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g;
  const attr = (s, name) => {
    const m = new RegExp(`${name}="([^"]*)"`).exec(s);
    return m ? decode(m[1]) : null;
  };

  let m;
  while ((m = caseRe.exec(xml)) !== null) {
    const attrs = m[1];
    const body = m[3] ?? '';
    const bad = /<(failure|error)\b/.exec(body);
    let message = null;
    if (bad) {
      const tag = new RegExp(`<${bad[1]}\\b([^>]*)`).exec(body);
      message = tag ? (attr(tag[1], 'message') ?? attr(tag[1], 'type') ?? 'failed') : 'failed';
    }
    out.push({
      name: attr(attrs, 'name'),
      className: attr(attrs, 'classname'),
      passed: !bad,
      message,
    });
  }
  return out;
}

export function summarise(results) {
  const total = results.length;
  const pass = results.filter((r) => r.passed).length;
  // Zero tests is never green — an empty run usually means a broken classpath.
  return { pass, total, green: total > 0 && pass === total };
}

/**
 * Compile the candidate's sources together with a hidden suite, then run it.
 *
 * Returns { compiled, diagnostics, results, summary, raw }.
 * A compile failure short-circuits: no tests are run and summary.green is false.
 */
export async function runSuite({ attemptRoot, testDirs = [], includeHarness = false }) {
  const outDir = join(attemptRoot, 'out-test');
  const reports = join(attemptRoot, 'reports');
  rmSync(outDir, { recursive: true, force: true });
  rmSync(reports, { recursive: true, force: true });
  mkdirSync(outDir, { recursive: true });

  // Hidden tests are copied into a sibling directory so the candidate never sees them
  // in their own file tree, and so a stale copy cannot linger between attempts.
  const hidden = join(attemptRoot, '.hidden-tests');
  rmSync(hidden, { recursive: true, force: true });
  mkdirSync(hidden, { recursive: true });
  for (const dir of testDirs) {
    if (existsSync(dir)) cpSync(dir, hidden, { recursive: true });
  }
  if (includeHarness && existsSync(HARNESS_DIR)) {
    cpSync(HARNESS_DIR, hidden, { recursive: true });
  }

  const studentSources = listJavaFiles(attemptRoot)
    .filter((f) => !f.replace(/\\/g, '/').includes('/out'))
    .filter((f) => !f.replace(/\\/g, '/').includes('/.hidden-tests/'));
  const testSources = listJavaFiles(hidden);

  if (testSources.length === 0) {
    return { compiled: false, diagnostics: [], results: [], summary: summarise([]),
             raw: 'no test sources found — check the problem\'s tests_base directory' };
  }

  const cp = [JUNIT_JAR, outDir].join(process.platform === 'win32' ? ';' : ':');

  try {
    await exec(javacPath(JDK_DIR), ['-cp', JUNIT_JAR, '-d', outDir, ...studentSources, ...testSources],
      { cwd: attemptRoot, maxBuffer: 8 * 1024 * 1024 });
  } catch (e) {
    const raw = `${e.stderr ?? ''}${e.stdout ?? ''}`;
    return { compiled: false, diagnostics: parseDiagnostics(raw, attemptRoot),
             results: [], summary: summarise([]), raw };
  }

  let raw = '';
  try {
    const r = await exec(javaPath(JDK_DIR), [
      '-jar', JUNIT_JAR,
      'execute',
      '--class-path', outDir,
      '--scan-class-path', outDir,
      '--reports-dir', reports,
      '--details=none',
      '--disable-ansi-colors',
      '--disable-banner',
    ], { cwd: attemptRoot, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 });
    raw = `${r.stdout}${r.stderr}`;
  } catch (e) {
    // A non-zero exit is normal when tests fail; the XML is still written.
    raw = `${e.stdout ?? ''}${e.stderr ?? ''}`;
  }

  const results = [];
  if (existsSync(reports)) {
    for (const f of readdirSync(reports).filter((n) => n.endsWith('.xml'))) {
      results.push(...parseJunitXml(readFileSync(join(reports, f), 'utf8')));
    }
  }

  rmSync(hidden, { recursive: true, force: true });   // never leave hidden tests behind
  return { compiled: true, diagnostics: [], results, summary: summarise(results), raw };
}
```

- [ ] **Step 4: Confirm unit tests pass**

Run: `node --test server/test/junit.test.mjs`
Expected: PASS — 5 tests

- [ ] **Step 5: Prove it against the real toolchain, end to end**

This is the step that actually matters. Create a throwaway attempt and suite by hand:

```bash
mkdir -p workspace/_junit_probe /tmp/dojo-tests
cat > workspace/_junit_probe/Calc.java <<'EOF'
public class Calc {
    public int add(int a, int b) { return a + b; }
    public int sub(int a, int b) { return a - b; }
}
EOF
cat > /tmp/dojo-tests/CalcBaseTest.java <<'EOF'
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class CalcBaseTest {
    @Test @DisplayName("adds two numbers")
    void adds() { assertEquals(5, new Calc().add(2, 3)); }

    @Test @DisplayName("subtracts two numbers")
    void subtracts() { assertEquals(9, new Calc().sub(2, 3)); }
}
EOF
```

Then run the suite through the module:

```bash
node -e "import('./server/lib/junit.mjs').then(async m => { const r = await m.runSuite({ attemptRoot: 'workspace/_junit_probe', testDirs: ['/tmp/dojo-tests'] }); console.log(JSON.stringify({compiled: r.compiled, summary: r.summary, results: r.results}, null, 2)); })"
```

Expected: `compiled: true`, `summary: {pass: 1, total: 2, green: false}`, and the failing entry named
`subtracts two numbers` — **note the display name, not the method name**, because that string is shown
to the user. If you get method names instead, the reporting flags are wrong; fix them before continuing.

Then fix `sub` to return `a - b` correctly relative to the test (`2 - 3` is `-1`, so change the test
expectation to `-1`) and confirm `green: true`, `total: 2`.

Finally confirm hygiene:

```bash
ls workspace/_junit_probe
```

Expected: **no `.hidden-tests` directory remains**. Hidden suites must never be left where the
candidate can read them.

- [ ] **Step 6: Snapshot** — `node tools/snapshot.mjs "harness-2-junit"`

---

### Task 3: Curveball diff measurement

The most valuable measurement in the app: it turns "is your design extensible" from an opinion into a
number. Atlassian's reported feedback clusters on candidates rewriting large portions when a scale-up
lands, so this is the metric that predicts that round.

**Files:**
- Create: `server/lib/diff.mjs`
- Create: `server/test/diff.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/diff.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { diffLines, measureChange } from '../lib/diff.mjs';

test('diffLines counts nothing for identical content', () => {
  assert.deepEqual(diffLines('a\nb\nc', 'a\nb\nc'), { added: 0, removed: 0, changed: 0 });
});

test('diffLines counts a pure insertion as added only', () => {
  assert.deepEqual(diffLines('a\nc', 'a\nb\nc'), { added: 1, removed: 0, changed: 1 });
});

test('diffLines counts a deletion as removed', () => {
  assert.deepEqual(diffLines('a\nb\nc', 'a\nc'), { added: 0, removed: 1, changed: 1 });
});

test('diffLines counts a modified line as one added and one removed', () => {
  const d = diffLines('a\nb\nc', 'a\nB\nc');
  assert.equal(d.added, 1);
  assert.equal(d.removed, 1);
  assert.equal(d.changed, 2);
});

test('diffLines ignores pure whitespace and blank-line differences', () => {
  assert.deepEqual(diffLines('a\n\n  b  ', 'a\nb'), { added: 0, removed: 0, changed: 0 });
});

test('measureChange charges edits to pre-existing files and nothing for new files', () => {
  const baseline = [{ path: 'Lot.java', content: 'class Lot {\n  int n;\n}' }];
  const now = [
    { path: 'Lot.java', content: 'class Lot {\n  int n;\n  int m;\n}' },   // +1 line
    { path: 'BikePolicy.java', content: 'class BikePolicy {\n}' },          // brand new — free
  ];
  const r = measureChange(baseline, now);
  assert.equal(r.newFiles, 1);
  assert.equal(r.touchedFiles, 1);
  assert.equal(r.diffLines, 1);
  assert.deepEqual(r.perFile['Lot.java'], { added: 1, removed: 0, changed: 1 });
  assert.equal(r.perFile['BikePolicy.java'], undefined);
});

test('measureChange charges a deleted pre-existing file for all of its lines', () => {
  const baseline = [{ path: 'Old.java', content: 'a\nb\nc' }];
  const r = measureChange(baseline, []);
  assert.equal(r.diffLines, 3);
  assert.equal(r.deletedFiles, 1);
});

test('measureChange verdict compares against the budget', () => {
  const baseline = [{ path: 'A.java', content: 'a\nb' }];
  const same = measureChange(baseline, [{ path: 'A.java', content: 'a\nb' }], 8);
  assert.equal(same.withinBudget, true);
  const blown = measureChange(baseline, [{ path: 'A.java', content: 'x\ny\nz\nw' }], 2);
  assert.equal(blown.withinBudget, false);
});
```

- [ ] **Step 2: Run it and confirm failure**

Run: `node --test server/test/diff.test.mjs`
Expected: FAIL — `Cannot find module '../lib/diff.mjs'`

- [ ] **Step 3: Implement `server/lib/diff.mjs`**

```javascript
/**
 * Line-level change measurement for Phase 4 (extension under fire).
 *
 * The question being answered: when a new requirement landed, how much of the code
 * that already existed did you have to go back and edit? A design with the right
 * seams absorbs a change by ADDING a file. A design without them forces edits
 * throughout, and that is what this counts.
 *
 * Lines in brand-new files are free. Edits to files that existed at injection time
 * are charged, including pure additions — because being forced back into an old file
 * to extend behaviour is exactly the cost being measured.
 */

/** Whitespace-only and blank-line differences are not design changes. */
function normalise(text) {
  return String(text)
    .split(/\r?\n/)
    .map((l) => l.replace(/\s+/g, ' ').trim())
    .filter((l) => l.length > 0);
}

/** Longest common subsequence length table, then walk it to count edits. */
function lcsCounts(a, b) {
  const n = a.length, m = b.length;
  const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
  for (let i = n - 1; i >= 0; i--) {
    for (let j = m - 1; j >= 0; j--) {
      dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
    }
  }
  const common = dp[0][0];
  return { removed: n - common, added: m - common };
}

export function diffLines(before, after) {
  const a = normalise(before);
  const b = normalise(after);
  const { added, removed } = lcsCounts(a, b);
  return { added, removed, changed: added + removed };
}

export function measureChange(baselineFiles, currentFiles, budget = null) {
  const base = new Map(baselineFiles.map((f) => [f.path, f.content]));
  const now = new Map(currentFiles.map((f) => [f.path, f.content]));

  const perFile = {};
  let diffLinesTotal = 0;
  let touchedFiles = 0;
  let newFiles = 0;
  let deletedFiles = 0;

  for (const [path, content] of now) {
    if (!base.has(path)) { newFiles++; continue; }          // new file — free
    const d = diffLines(base.get(path), content);
    if (d.changed > 0) {
      perFile[path] = d;
      diffLinesTotal += d.changed;
      touchedFiles++;
    }
  }

  for (const [path, content] of base) {
    if (now.has(path)) continue;
    const lines = normalise(content).length;                 // deleting an old file is a big edit
    perFile[path] = { added: 0, removed: lines, changed: lines };
    diffLinesTotal += lines;
    deletedFiles++;
    touchedFiles++;
  }

  return {
    diffLines: diffLinesTotal,
    perFile,
    touchedFiles,
    newFiles,
    deletedFiles,
    budget,
    withinBudget: budget === null ? null : diffLinesTotal <= budget,
  };
}
```

- [ ] **Step 4: Confirm the tests pass**

Run: `node --test server/test/diff.test.mjs`
Expected: PASS — 8 tests

- [ ] **Step 5: Sanity-check the measure against its own intent**

Run this and reason about whether the numbers match the design intent:

```bash
node -e "
import('./server/lib/diff.mjs').then(m => {
  const base = [{path:'Lot.java', content:'class Lot {\n  String price(String t){ if(t.equals(\"car\")) return \"20\"; return \"10\"; }\n}'}];
  const ifChain = [{path:'Lot.java', content:'class Lot {\n  String price(String t){ if(t.equals(\"car\")) return \"20\"; if(t.equals(\"bike\")) return \"5\"; return \"10\"; }\n}'}];
  const strategy = [
    {path:'Lot.java', content:'class Lot {\n  String price(String t){ if(t.equals(\"car\")) return \"20\"; return \"10\"; }\n}'},
    {path:'BikePricing.java', content:'class BikePricing implements PricingPolicy {\n  public String fee(){ return \"5\"; }\n}'}
  ];
  console.log('if-chain  ->', m.measureChange(base, ifChain, 8).diffLines);
  console.log('new file  ->', m.measureChange(base, strategy, 8).diffLines);
});
"
```

Expected: the if-chain edit charges lines; the added-file approach charges **0**. That contrast is the
whole point of the metric — if both report the same number, the implementation is wrong.

- [ ] **Step 6: Snapshot** — `node tools/snapshot.mjs "harness-3-diff"`

---

### Task 4: Concurrency stress runner

**Files:**
- Create: `server/lib/stress.mjs`
- Create: `server/test/stress.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/stress.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classifyStress } from '../lib/stress.mjs';

test('classifyStress reports survival when every repetition passed', () => {
  const r = classifyStress({ summary: { pass: 4, total: 4, green: true }, results: [], repetitions: 5 });
  assert.equal(r.survived, true);
  assert.equal(r.verdict, 'no lost updates across 5 repetitions');
});

test('classifyStress never reports survival for an empty suite', () => {
  const r = classifyStress({ summary: { pass: 0, total: 0, green: false }, results: [], repetitions: 5 });
  assert.equal(r.survived, false);
  assert.match(r.verdict, /no concurrency tests ran/i);
});

test('classifyStress names a deadlock distinctly from a failed assertion', () => {
  const r = classifyStress({
    summary: { pass: 0, total: 1, green: false },
    results: [{ name: 'stays consistent', passed: false, message: 'tasks did not finish in 20s — likely a deadlock or a lock held too long' }],
    repetitions: 5,
  });
  assert.equal(r.survived, false);
  assert.equal(r.kind, 'deadlock');
});

test('classifyStress identifies a lost update', () => {
  const r = classifyStress({
    summary: { pass: 0, total: 1, green: false },
    results: [{ name: 'stays consistent', passed: false, message: 'expected: <1000> but was: <987>' }],
    repetitions: 5,
  });
  assert.equal(r.kind, 'lost-update');
  assert.match(r.verdict, /987/);
});

test('classifyStress falls back to a generic race kind', () => {
  const r = classifyStress({
    summary: { pass: 0, total: 1, green: false },
    results: [{ name: 'x', passed: false, message: 'ConcurrentModificationException' }],
    repetitions: 5,
  });
  assert.equal(r.kind, 'race');
});
```

- [ ] **Step 2: Run it and confirm failure**

Run: `node --test server/test/stress.test.mjs`
Expected: FAIL — `Cannot find module '../lib/stress.mjs'`

- [ ] **Step 3: Implement `server/lib/stress.mjs`**

```javascript
import { runSuite } from './junit.mjs';

export const DEFAULT_REPETITIONS = 5;

/**
 * Turn a raw suite result into a concurrency verdict.
 *
 * Races are probabilistic, so the distinction between failure kinds matters more
 * here than elsewhere: a deadlock, a lost update and a thrown concurrent-modification
 * are three different design mistakes and deserve three different messages.
 */
export function classifyStress({ summary, results, repetitions = DEFAULT_REPETITIONS }) {
  if (summary.total === 0) {
    return { survived: false, kind: 'not-run', repetitions,
             verdict: 'No concurrency tests ran — the suite or classpath is wrong, which is never a pass.' };
  }

  if (summary.green) {
    return { survived: true, kind: 'clean', repetitions,
             verdict: `no lost updates across ${repetitions} repetitions` };
  }

  const failures = results.filter((r) => !r.passed);
  const joined = failures.map((f) => f.message ?? '').join(' | ');

  let kind = 'race';
  if (/deadlock|did not finish/i.test(joined)) kind = 'deadlock';
  else if (/expected:\s*<?\d+>?\s*but was:\s*<?\d+>?/i.test(joined)) kind = 'lost-update';

  const detail = {
    deadlock: 'Threads did not finish — a lock is held too long, or two are taken in different orders.',
    'lost-update': 'A count came out short, so two threads read-modify-wrote the same state.',
    race: 'State was observed mid-mutation.',
  }[kind];

  return {
    survived: false,
    kind,
    repetitions,
    failures,
    verdict: `${detail} ${failures.length} of ${summary.total} checks failed: ${joined}`.trim(),
  };
}

/**
 * Runs a problem's concurrency suite `repetitions` times and stops at the first failure.
 * Stopping early is deliberate: one reproduction is enough to act on, and the remaining
 * runs would cost seconds each for no extra information.
 */
export async function runStress({ attemptRoot, testDirs, repetitions = DEFAULT_REPETITIONS }) {
  let last = null;
  for (let i = 0; i < repetitions; i++) {
    last = await runSuite({ attemptRoot, testDirs, includeHarness: true });
    if (!last.compiled) {
      return { ...classifyStress({ ...last, repetitions: i }), compiled: false,
               diagnostics: last.diagnostics, verdict: 'The code did not compile, so no race could be tested.' };
    }
    if (!last.summary.green) {
      return { ...classifyStress({ ...last, repetitions: i + 1 }), compiled: true, diagnostics: [] };
    }
  }
  return { ...classifyStress({ ...last, repetitions }), compiled: true, diagnostics: [] };
}
```

- [ ] **Step 4: Confirm the tests pass**

Run: `node --test server/test/stress.test.mjs`
Expected: PASS — 5 tests

- [ ] **Step 5: Prove a real race is actually detected**

A stress harness that cannot catch a known race is worthless, so demonstrate it:

```bash
mkdir -p workspace/_stress_probe /tmp/dojo-stress
cat > workspace/_stress_probe/Counter.java <<'EOF'
public class Counter {
    private int n = 0;                 // deliberately NOT thread-safe
    public void inc() { n = n + 1; }
    public int get() { return n; }
}
EOF
cat > /tmp/dojo-stress/CounterStressTest.java <<'EOF'
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class CounterStressTest {
    @Test @DisplayName("counts every increment under contention")
    void counts() throws Exception {
        DojoConcurrency.repeat(3, () -> {
            Counter c = new Counter();
            DojoConcurrency.runContended(100, () -> { for (int i = 0; i < 500; i++) c.inc(); });
            assertEquals(50000, c.get());
        });
    }
}
EOF
node -e "import('./server/lib/stress.mjs').then(async m => console.log(JSON.stringify(await m.runStress({attemptRoot:'workspace/_stress_probe', testDirs:['/tmp/dojo-stress']}), null, 2)))"
```

Expected: `survived: false`, `kind: "lost-update"`, and a verdict quoting the short count.

**If it reports `survived: true`, the harness is not creating enough contention — raise the thread
count or iterations until the race reproduces reliably, then keep those numbers.** A stress test that
passes on broken code is worse than no stress test, because it produces false confidence.

Then make `Counter` thread-safe (`synchronized` on both methods, or an `AtomicInteger`) and confirm
`survived: true` with `kind: "clean"`.

- [ ] **Step 6: Clean up probes and snapshot**

```bash
rm -rf workspace/_junit_probe workspace/_stress_probe workspace/_harness_check /tmp/dojo-tests /tmp/dojo-stress
node tools/snapshot.mjs "harness-4-stress"
```

---

## Self-review

**Coverage.** The four tasks establish everything content authoring depends on: the fixed corpus test
layout and default-package convention (this document's Conventions section), the shared concurrency
helper (Task 1), suite execution with per-test display names (Task 2), curveball cost measurement
(Task 3), and race classification (Task 4).

**Two deliberate design decisions worth restating:**

1. **An empty suite is never green.** Both `summarise` and `classifyStress` refuse to pass a run that
   executed zero tests. A broken classpath is the most likely cause and it would otherwise read as
   success — the single most dangerous false positive in the whole grading system.
2. **New files are free; edits to pre-existing files are charged, including pure additions.** Being
   forced back into an old file to extend behaviour is precisely the cost Phase 4 measures. Step 5 of
   Task 3 exists to prove the if-chain and add-a-file approaches produce different numbers.

**Type consistency.** `runSuite` returns `{compiled, diagnostics, results, summary, raw}`; `runStress`
spreads that same shape into `classifyStress`, which reads only `summary`, `results` and `repetitions`.
`summarise` returns `{pass, total, green}` and every consumer reads exactly those keys.
`measureChange` returns `{diffLines, perFile, touchedFiles, newFiles, deletedFiles, budget,
withinBudget}` and the Phase 4 grader will persist `diff_lines` and `diff_budget` from it, matching the
`determinism` table columns already created in `db.mjs`.

**Known gap, stated rather than hidden.** `listJavaFiles` and `parseDiagnostics` are imported from
`server/lib/java.mjs`, which comes from the M1 plan's Task 6. If that file does not exist yet, Task 2
cannot run — implement M1 Task 6 first.
