Crafter.Build/tests/DependencyNotes/main.cpp
Jorijn van der Graaf eb1c3a4b1d
test(agents): what a consumer's AGENTS.md gets, and what stays put
The merge on its own: the section is appended after one blank line,
merging again changes nothing, a moved section is rewritten where it
stands, and removing it gives the file its own bytes back. Markers
inside a fenced block, one quoting a fenced block of its own, are not
the section's. Unbalanced markers, a document that ends inside a code
block and notes that would break the section are refused, naming the
line.

End to end, through LoadProject on a staged fixture: app names lib
and has tool as host tooling; lib builds against base and has libtool
as its own. Transitive gives tool, lib and base in that order, and
neither app's own notes nor libtool's. A second update writes nothing,
mtime included. Direct drops base, edited notes replace the old ones
in the one section, and None restores the original bytes. A project
without an AGENTS.md does not get one, and a symlinked AGENTS.md is
written through the link, which stays a link. Through Run in process,
lint and run leave the file alone, while `test --list` and a build
that fails at the link both refresh it.

The fixture's AGENTS.md is checked in as AGENTS.md.in: an AGENTS.md in
this tree would read as instructions for the agents working on it.

Moving the CLI hook above the run and lint branches, or letting Direct
walk past the first loaded project, fails four of the checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:18:21 +01:00

219 lines
12 KiB
C++

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
// Dependency notes: every dependency that ships an AGENTS.consumer.md beside
// its project.cpp has the file kept in a marked section of its consumer's
// AGENTS.md, refreshed by each build and test run.
//
// AGENTS.md is the user's own file, committed and edited by hand, so what
// this guards against is everything else moving: a section appended again on
// every build, text outside the section rewritten or lost, a write when
// nothing changed (a dirty tree, an editor reloading), an AGENTS.md created in
// a project that has none, notes from a dependency's own host tooling leaking
// into its consumers, and `lint` or `run`, which promise not to touch the
// project, writing it anyway.
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
std::string Read(const fs::path& file) {
std::ifstream in(file, std::ios::binary);
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
}
void Write(const fs::path& file, std::string_view content) {
std::ofstream(file, std::ios::binary | std::ios::trunc) << content;
}
std::size_t Count(std::string_view text, std::string_view needle) {
std::size_t count = 0;
for (std::size_t pos = text.find(needle); pos != std::string_view::npos; pos = text.find(needle, pos + needle.size())) ++count;
return count;
}
// Expect MergeDependencyNotes to throw; the message, or "" when it didn't.
std::string MergeFails(std::string_view doc, std::span<const DependencyNote> notes) {
try {
(void)MergeDependencyNotes(doc, notes);
} catch (const std::exception& e) {
return e.what();
}
return "";
}
std::int32_t RunWith(std::vector<std::string> args) {
std::vector<char*> argv;
for (std::string& arg : args) argv.push_back(arg.data());
return Run(static_cast<std::int32_t>(argv.size()), argv.data());
}
// LoadProject compiles a project file against the installed module
// sources; without them the end-to-end half cannot run at all.
bool ModuleSourcesInstalled() {
try {
return fs::exists(GetCrafterBuildHome() / "Crafter.Build.cppm");
} catch (const std::exception&) {
return false;
}
}
constexpr std::string_view BeginMarker = "<!-- crafter-build: dependency notes begin -->";
constexpr std::string_view EndMarker = "<!-- crafter-build: dependency notes end -->";
}
int main() {
// The merge on its own: what goes where in a document, with no project.
const std::string original = "# app\n\nThe consumer's own text.\n";
const std::vector<DependencyNote> two = {
{ .source = "https://example.invalid/one.git", .text = "## one\n\nFirst." },
{ .source = "../two/AGENTS.consumer.md", .text = "## two\n\nSecond." },
};
const std::string merged = MergeDependencyNotes(original, two);
Check(merged.starts_with(std::format("{}\n{}\n", original, BeginMarker)), "the section is appended after one blank line, and the text before it is untouched");
Check(merged.ends_with(std::format("{}\n", EndMarker)), "the section ends with its end marker");
const std::size_t first = merged.find("<!-- from https://example.invalid/one.git -->\n## one\n\nFirst.\n");
const std::size_t second = merged.find("<!-- from ../two/AGENTS.consumer.md -->\n## two\n\nSecond.\n");
Check(first != std::string::npos && second != std::string::npos && first < second, "each note follows a line naming its source, in the order given");
Check(MergeDependencyNotes(merged, two) == merged, "merging the same notes again changes nothing");
Check(MergeDependencyNotes(merged, {}) == original, "removing the section gives the file its own bytes back");
Check(MergeDependencyNotes(original, {}) == original, "a file with no section and no notes is left alone");
// A section the user moved is rewritten where it stands.
{
const std::string section = merged.substr(original.size() + 1);
const std::string moved = std::format("# app\n\n{}\n## After\n\nThe user's text after it.\n", section);
const std::vector<DependencyNote> changed = { { .source = "https://example.invalid/one.git", .text = "## one\n\nChanged." } };
const std::string rewritten = MergeDependencyNotes(moved, changed);
Check(rewritten.starts_with(std::format("# app\n\n{}\n", BeginMarker)), "the text before a moved section is kept");
Check(rewritten.ends_with("\n## After\n\nThe user's text after it.\n"), "the text after a moved section is kept");
Check(rewritten.contains("Changed.") && !rewritten.contains("First.") && !rewritten.contains("Second."), "a rewritten section holds exactly the new notes");
}
// Markers quoted in a code block are not the section's, including a block
// that quotes a fenced block of its own.
{
const std::string quoted = std::format("# app\n\n````markdown\n{}\n```\nnested\n```\n{}\n````\n", BeginMarker, EndMarker);
const std::string withNotes = MergeDependencyNotes(quoted, two);
Check(withNotes.starts_with(std::format("{}\n{}\n", quoted, BeginMarker)), "markers quoted in a code block are left alone, and a section is appended after them");
Check(MergeDependencyNotes(withNotes, two) == withNotes, "the real section is found past the quoted markers");
}
// Guessing which part of someone's file is generated would overwrite their
// text, and missing a section would add another on every build.
Check(MergeFails(std::format("# app\n{}\ntext\n", BeginMarker), two).contains("line 2"), "a begin marker with no end marker is refused, naming its line");
Check(MergeFails(std::format("# app\n\n{}\n", EndMarker), two).contains("line 3"), "an end marker with no begin marker is refused");
Check(MergeFails(std::format("{}\n{}\n", EndMarker, BeginMarker), two).contains("line 1"), "an end marker before the begin marker is refused");
Check(MergeFails(std::format("{0}\n{1}\n{0}\n{1}\n", BeginMarker, EndMarker), two).contains("line 3"), "a second section is refused");
Check(MergeFails("# app\n\n```\nnever closed\n", two).contains("line 3"), "a document that ends inside a code block is refused");
const std::vector<DependencyNote> markerNote = { { .source = "marker-note", .text = std::format("## bad\n{}", EndMarker) } };
Check(MergeFails(original, markerNote).contains("marker-note"), "a note with a marker line of its own is refused");
const std::vector<DependencyNote> fenceNote = { { .source = "fence-note", .text = "## bad\n```\nnever closed" } };
Check(MergeFails(original, fenceNote).contains("fence-note"), "a note that leaves a code block open is refused");
if (!ModuleSourcesInstalled()) {
std::println(std::cerr, "SKIP: no module sources to compile the fixture's project files against");
return Failures > 0 ? 1 : 77;
}
// Through LoadProject and the CLI. Staged, because each case rewrites
// AGENTS.md and every load drops a build/ tree beside its project file.
const fs::path fixture = fs::current_path() / "tests" / "DependencyNotes" / "fixture";
const fs::path scratch = fs::temp_directory_path() / "crafter-build-dependency-notes";
std::error_code ec;
fs::remove_all(scratch, ec);
fs::create_directories(scratch);
fs::copy(fixture, scratch, fs::copy_options::recursive);
const fs::path app = scratch / "app";
const fs::path agents = app / "AGENTS.md";
const fs::path projectFile = app / "project.cpp";
// Checked in under another name: an AGENTS.md in this repository's tree
// would read as instructions for the agents working on it.
const std::string agentsOriginal = Read(app / "AGENTS.md.in");
Write(agents, agentsOriginal);
// LocalProject resolves against the working directory, and app's paths
// are written for `cd app && crafter-build`.
fs::current_path(app);
std::array<std::string_view, 0> noArgs = {};
Configuration cfg;
try {
cfg = LoadProject(projectFile, noArgs);
} catch (const std::exception& e) {
std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what());
return 1;
}
const std::string toolNote = "<!-- from ../tool/AGENTS.consumer.md -->\n## tool\n\nRun tool before committing.\n";
const std::string libNote = "<!-- from ../lib/AGENTS.consumer.md -->\n## lib\n\nCall lib through its module, never through its headers.\n";
// base's file opens and ends with blank lines, which the section drops.
const std::string baseNote = "<!-- from ../base/AGENTS.consumer.md -->\n## base\n\nbase counts from zero.\n";
Check(UpdateAgentsMd(cfg, projectFile), "the first update writes AGENTS.md");
std::string text = Read(agents);
Check(text.starts_with(std::format("{}\n{}\n", agentsOriginal, BeginMarker)), "the section follows the consumer's own text, which is untouched");
const std::size_t tool = text.find(toolNote);
const std::size_t lib = text.find(libNote);
const std::size_t base = text.find(baseNote);
Check(tool != std::string::npos && lib != std::string::npos && base != std::string::npos, std::format("tool, lib and base each contribute their notes (got:\n{})", text));
Check(tool < lib && lib < base, "host tooling first, then what app names, then what lib builds against");
Check(!text.contains("Notes from app"), "app's own AGENTS.consumer.md stays out of its own AGENTS.md");
Check(!text.contains("libtool"), "lib's host tooling stays out of its consumers' notes");
const fs::file_time_type written = fs::last_write_time(agents);
Check(!UpdateAgentsMd(cfg, projectFile), "an update with nothing changed writes nothing");
Check(Read(agents) == text && fs::last_write_time(agents) == written, "the bytes and the mtime are left alone when nothing changed");
cfg.dependencyNotes = DependencyNotes::Direct;
Check(UpdateAgentsMd(cfg, projectFile), "switching to Direct rewrites the section");
text = Read(agents);
Check(text.contains(toolNote) && text.contains(libNote) && !text.contains("## base"), "Direct keeps what app names and drops what only lib does");
cfg.dependencyNotes = DependencyNotes::Transitive;
Write(scratch / "lib" / "AGENTS.consumer.md", "## lib\n\nlib was rewritten.\n");
Check(UpdateAgentsMd(cfg, projectFile), "a dependency's edited notes are picked up");
text = Read(agents);
Check(text.contains("lib was rewritten.") && !text.contains("never through its headers") && Count(text, BeginMarker) == 1, "edited notes replace the old ones, in the one section");
cfg.dependencyNotes = DependencyNotes::None;
Check(UpdateAgentsMd(cfg, projectFile), "None removes the section");
Check(Read(agents) == agentsOriginal, "removing the section gives AGENTS.md its own bytes back");
cfg.dependencyNotes = DependencyNotes::Transitive;
fs::remove(agents);
Check(!UpdateAgentsMd(cfg, projectFile) && !fs::exists(agents), "a project without an AGENTS.md does not get one");
// A common layout keeps the file elsewhere and links it, which a write
// that replaced the link with a copy would quietly undo.
fs::create_directories(app / "docs");
Write(app / "docs" / "agents.md", agentsOriginal);
fs::create_symlink("docs/agents.md", agents);
Check(UpdateAgentsMd(cfg, projectFile), "an AGENTS.md that is a symlink is updated");
Check(fs::is_symlink(agents) && Read(app / "docs" / "agents.md").contains(toolNote), "the update goes through the link, which stays a link");
fs::remove(agents);
// The CLI: only a build or a test run writes it.
Write(agents, agentsOriginal);
(void)RunWith({"crafter-build", "-q", "lint"});
Check(Read(agents) == agentsOriginal, "lint leaves AGENTS.md alone");
(void)RunWith({"crafter-build", "-q", "run"});
Check(Read(agents) == agentsOriginal, "run leaves AGENTS.md alone");
(void)RunWith({"crafter-build", "-q", "test", "--list"});
Check(Read(agents).contains(toolNote), "a test run refreshes AGENTS.md");
// app has no sources, so this build fails at the link, after the update.
Write(agents, agentsOriginal);
(void)RunWith({"crafter-build", "-q"});
Check(Read(agents).contains(toolNote), "a build refreshes AGENTS.md before compiling, so one that fails still does");
return Failures > 0 ? 1 : 0;
}