diff --git a/app/src/test/java/net/kollnig/missioncontrol/AgentConfigTest.java b/app/src/test/java/net/kollnig/missioncontrol/AgentConfigTest.java new file mode 100644 index 00000000..e0878473 --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/AgentConfigTest.java @@ -0,0 +1,231 @@ +/* + * TrackerControl is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * TrackerControl is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Copyright © 2026 + */ + +package net.kollnig.missioncontrol; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Guards the topology of the agent configuration described in agents/README.md. + *

+ * AGENTS.md is loaded into every agent session; agents/docs/ is loaded by nothing and is + * only ever read because an always-loaded file points at it. That makes three failures + * silent rather than loud: a doc nothing points at (never read), a pointer that no longer + * resolves (guidance quietly removed), and a fact restated in AGENTS.md that has since + * drifted from its source of truth. Each is cheap to catch mechanically and expensive to + * notice by hand. + */ +public class AgentConfigTest { + + /** + * AGENTS.md is paid for in every session by every tool, so it carries only what decides + * most work. Situational material belongs in agents/docs/ behind the "read these when" + * table. The exact number is arbitrary; the ratchet is not. + */ + private static final int AGENTS_MD_LINE_BUDGET = 150; + + /** Extensions we are willing to assert about when a reference names a file. */ + private static final Pattern FILE_REFERENCE = Pattern.compile( + ".*\\.(md|sh|json|java|kt|c|h|gradle|toml|rs|py|yml|yaml)$"); + + /** + * A tool name immediately followed by a version. Versions live in the build files and are + * pointed at, not copied — a restated one goes stale without anything failing. + */ + private static final Pattern VOLATILE_VERSION = Pattern.compile( + "(?i)\\b(jdk|java|ndk|sdk|cmake|gradle|agp|kotlin|rust|cargo|robolectric|compileSdk|targetSdk|minSdk)" + + "\\b[^.\\n]{0,20}?\\b\\d+(\\.\\d+)*\\b"); + + @Test + public void everyAgentDocIsReachableFromAnAlwaysLoadedFile() throws IOException { + String agentsMd = read(repoRoot().resolve("AGENTS.md")); + + List unreachable = new ArrayList<>(); + for (Path doc : agentDocs()) { + String reference = "agents/docs/" + doc.getFileName(); + if (!agentsMd.contains(reference)) + unreachable.add(reference); + } + + assertTrue("Nothing loads agents/docs/, so a doc AGENTS.md does not point at will never be" + + " read. Add a row to the \"read these when the situation applies\" table for: " + + unreachable, unreachable.isEmpty()); + } + + @Test + public void everyPathReferenceResolves() throws IOException { + List dangling = new ArrayList<>(); + + for (Path file : configFiles()) { + int lineNumber = 0; + boolean inCodeBlock = false; + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + lineNumber++; + if (line.trim().startsWith("```")) { + inCodeBlock = !inCodeBlock; + continue; + } + if (inCodeBlock) + continue; + + for (String reference : referencesIn(line)) { + if (!resolves(file, reference)) + dangling.add(relative(file) + ":" + lineNumber + " -> " + reference); + } + } + } + + if (!dangling.isEmpty()) + fail("A mistyped pointer silently removes guidance instead of failing loudly. Dangling" + + " references:\n " + String.join("\n ", dangling)); + } + + @Test + public void agentsMdDoesNotRestateVolatileVersions() throws IOException { + List restated = new ArrayList<>(); + + int lineNumber = 0; + boolean inCodeBlock = false; + for (String line : Files.readAllLines(repoRoot().resolve("AGENTS.md"), StandardCharsets.UTF_8)) { + lineNumber++; + if (line.trim().startsWith("```")) { + inCodeBlock = !inCodeBlock; + continue; + } + if (inCodeBlock) + continue; + + Matcher matcher = VOLATILE_VERSION.matcher(line); + while (matcher.find()) + restated.add("AGENTS.md:" + lineNumber + " -> " + matcher.group().trim()); + } + + if (!restated.isEmpty()) + fail("Versions belong in the build files and in agents/docs/build-and-test.md, pointed" + + " at rather than copied into the always-loaded file:\n " + + String.join("\n ", restated)); + } + + @Test + public void agentsMdStaysShortEnoughToLoadEverySession() throws IOException { + List lines = Files.readAllLines(repoRoot().resolve("AGENTS.md"), StandardCharsets.UTF_8); + + assertTrue("AGENTS.md is " + lines.size() + " lines, over the " + AGENTS_MD_LINE_BUDGET + + "-line budget. Situational guidance belongs in agents/docs/ behind a row in the" + + " \"read these when the situation applies\" table.", lines.size() <= AGENTS_MD_LINE_BUDGET); + } + + @Test + public void vendorWiringPointsAtSharedScripts() throws IOException { + Path claudeMd = repoRoot().resolve("CLAUDE.md"); + assertTrue("CLAUDE.md must hand Claude Code the shared entry point, not carry its own copy" + + " of the guidance", read(claudeMd).contains("AGENTS.md")); + + Path settings = repoRoot().resolve(".claude/settings.json"); + if (!Files.exists(settings)) + return; + + Matcher matcher = Pattern.compile("agents/hooks/[A-Za-z0-9_.-]+\\.sh").matcher(read(settings)); + while (matcher.find()) { + Path script = repoRoot().resolve(matcher.group()); + assertTrue(".claude/settings.json points at " + matcher.group() + ", which does not exist." + + " Vendor directories hold wiring; the scripts live in agents/.", Files.exists(script)); + } + } + + /** Backticked or markdown-linked paths that name something inside this repository. */ + private static List referencesIn(String line) throws IOException { + List references = new ArrayList<>(); + Matcher matcher = Pattern.compile("`([^`]+)`|\\]\\(([^)]+)\\)").matcher(line); + + while (matcher.find()) { + String candidate = matcher.group(1) != null ? matcher.group(1) : matcher.group(2); + candidate = candidate.trim(); + + int anchor = candidate.indexOf('#'); + if (anchor >= 0) + candidate = candidate.substring(0, anchor); + + if (candidate.isEmpty() || !candidate.contains("/")) + continue; + // Globs, placeholders, shell fragments and absolute paths are not repo references. + if (candidate.matches(".*[*{}<>\\[\\]|$ ].*") || candidate.startsWith("/") + || candidate.startsWith("~") || candidate.contains("://")) + continue; + if (!FILE_REFERENCE.matcher(candidate).matches() && !candidate.endsWith("/")) + continue; + + String first = candidate.startsWith("..") ? ".." : candidate.split("/")[0]; + if (!"..".equals(first) && !Files.exists(repoRoot().resolve(first))) + continue; // e.g. a package path or a build-output path, not a repo reference + + references.add(candidate); + } + return references; + } + + private static boolean resolves(Path referencedFrom, String reference) throws IOException { + if (reference.startsWith("..")) + return Files.exists(referencedFrom.getParent().resolve(reference).normalize()); + + return Files.exists(repoRoot().resolve(reference)); + } + + private static List configFiles() throws IOException { + List files = new ArrayList<>(); + files.add(repoRoot().resolve("AGENTS.md")); + files.add(repoRoot().resolve("agents/README.md")); + files.addAll(agentDocs()); + return files; + } + + private static List agentDocs() throws IOException { + try (Stream docs = Files.list(repoRoot().resolve("agents/docs"))) { + return docs.filter(path -> path.getFileName().toString().endsWith(".md")) + .sorted() + .collect(Collectors.toList()); + } + } + + private static String read(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + + private static String relative(Path path) { + return repoRoot().relativize(path).toString(); + } + + /** Unit tests run from the module directory locally and from the repo root in some setups. */ + private static Path repoRoot() { + Path moduleRelative = Path.of("..", "AGENTS.md"); + if (Files.exists(moduleRelative)) + return Path.of("..").toAbsolutePath().normalize(); + + return Path.of(".").toAbsolutePath().normalize(); + } +}