// 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: a consumer with an AGENTS.template.md beside its // project.cpp has every build and test run write AGENTS.md from it, with the // AGENTS.consumer.md each dependency ships merged into a marked section. // // The template is the user's own file, committed and edited by hand, and // AGENTS.md is build output that stays out of version control: notes kept in // a committed AGENTS.md changed it under every diff. So what this guards // against is the generated file leaking into the user's: the template // written, an AGENTS.md someone wrote replaced (one committed before the // template was, or one beside no template at all), one written although it // is read-only, or replaced so that a hard link to it goes stale; and inside // AGENTS.md, the template's text changed or the section misplaced, a write // when nothing changed (an editor reloading), notes from a test's dependency // or a dependency's own host tooling leaking into the consumer's, and `lint`, // `run` or `test --list`, 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(in)), std::istreambuf_iterator()); } 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; } // MergeDependencyNotes with a throw counted as a failure rather than // ending the run, so the cases after it are still reported. std::string Merge(std::string_view doc, std::span notes) { try { return MergeDependencyNotes(doc, notes); } catch (const std::exception& e) { std::println(std::cerr, "FAIL: MergeDependencyNotes threw: {}", e.what()); ++Failures; return std::format("", e.what()); } } // Expect MergeDependencyNotes to throw; the message, or "" when it didn't. std::string MergeFails(std::string_view doc, std::span notes) { try { (void)MergeDependencyNotes(doc, notes); } catch (const std::exception& e) { return e.what(); } return ""; } std::int32_t RunWith(std::vector args) { std::vector argv; for (std::string& arg : args) argv.push_back(arg.data()); return Run(static_cast(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 = ""; constexpr std::string_view EndMarker = ""; constexpr std::string_view GeneratedLine = ""; } 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 two = { { .name = "one", .text = "## one\n\nFirst." }, { .name = "two", .text = "## two\n\nSecond." }, }; const std::string merged = Merge(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("\n## one\n\nFirst.\n"); const std::size_t second = merged.find("\n## two\n\nSecond.\n"); Check(first != std::string::npos && second != std::string::npos && first < second, "each note follows a line naming its dependency, in the order given"); Check(Merge(merged, two) == merged, "merging the same notes again changes nothing"); Check(Merge(merged, {}) == original, "removing the section gives the file its own bytes back"); Check(Merge(original, {}) == original, "a file with no section and no notes is left alone"); // A section the user moved is rewritten where it stands, and removing it // keeps the paragraphs around it apart. 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 changed = { { .name = "one", .text = "## one\n\nChanged." } }; const std::string rewritten = Merge(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"); const std::string between = std::format("Para one.\n\n{}Para two.\n", section); Check(Merge(between, {}) == "Para one.\n\nPara two.\n", "removing a section from between two paragraphs keeps them apart"); // An unclosed block after the section does not stop it being rewritten // in place, since the section is found before it. const std::string open = std::format("{}```\nnever closed\n", merged); Check(MergeFails(open, changed).empty() && Merge(open, changed).contains("Changed."), "a section before an unclosed code block is still rewritten"); } // A CRLF file gets a CRLF section and keeps it. { const std::string crlf = "# app\r\n\r\nText.\r\n"; const std::string withNotes = Merge(crlf, two); Check(withNotes.starts_with(std::format("{}\r\n{}\r\n", crlf, BeginMarker)) && Count(withNotes, "\n") == Count(withNotes, "\r\n"), "a CRLF file gets a section with CRLF line ends"); Check(Merge(withNotes, two) == withNotes && Merge(withNotes, {}) == crlf, "a CRLF section is found again and removed cleanly"); } // Markers quoted in a code block are not the section's, including a block // that quotes a fenced block of its own; and only real fences count as // code blocks. { const std::string quoted = std::format("# app\n\n````markdown\n{}\n```\nnested\n```\n{}\n````\n", BeginMarker, EndMarker); const std::string withNotes = Merge(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(Merge(withNotes, two) == withNotes, "the real section is found past the quoted markers"); // ```make``` is inline code: read as a fence, it would open a block // that hides everything after it. const std::string inlineCode = "# app\n\n```make``` builds everything.\n"; Check(MergeFails(inlineCode, two).empty(), "a backtick run with a backtick after it does not open a code block"); const std::string once = Merge(inlineCode, two); Check(Merge(once, two) == once && Count(once, BeginMarker) == 1, "a section after inline code is found again"); // A fence inside a list item, closed at the item's indentation. Read // without the list, the indented closer would open a block instead, // and the quoted markers after it would be taken for the section. const std::string listed = std::format("- ```bash\n x\n ```\n\n```\n{}\nThe user's quoted text.\n{}\n```\n", BeginMarker, EndMarker); const std::string afterList = Merge(listed, two); Check(afterList.starts_with(std::format("{}\n{}\n", listed, BeginMarker)), "a fence in a list item closes at the item's indentation"); Check(Merge(afterList, two) == afterList, "the section after a list item's fence is found again"); } // With notes to write, 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"), "appending to a document that ends inside a code block is refused"); // With none, leaving the file alone loses nothing, so it is not an error. const std::string broken = std::format("# app\n{}\ntext\n", BeginMarker); Check(MergeFails(broken, {}).empty() && Merge(broken, {}) == broken, "with no notes, unbalanced markers leave the file alone without an error"); Check(Merge("# app\n\n```\nnever closed\n", {}) == "# app\n\n```\nnever closed\n", "with no notes, an unclosed code block is no error"); const std::vector markerNote = { { .name = "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 fenceNote = { { .name = "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 home = fs::current_path(); const fs::path fixture = home / "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 templateFile = app / "AGENTS.template.md"; const fs::path projectFile = app / "project.cpp"; const fs::path libNotes = scratch / "lib" / "AGENTS.consumer.md"; // Checked in under another name, so that this repository's tree holds no // template a build could ever pick up. const std::string templateText = Read(app / "AGENTS.template.md.in"); Write(templateFile, templateText); // LocalProject resolves against the working directory, and app's paths // are written for `cd app && crafter-build`. fs::current_path(app); std::array 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 = "\n## tool\n\nRun tool before committing.\n"; const std::string libNote = "\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 = "\n## base\n\nbase counts from zero.\n"; // How every AGENTS.md written from templateText starts. const std::string generated = std::format("{}\n{}", GeneratedLine, templateText); Check(!fs::exists(agents) && UpdateAgentsMd(cfg, projectFile), "the first update creates AGENTS.md"); // Every case after this one reads or rewrites that file. if (!fs::exists(agents)) return 1; std::string text = Read(agents); Check(text.starts_with(std::format("{}\n{}\n", generated, BeginMarker)), "AGENTS.md is the line saying a build wrote it, the template's text untouched, then the section"); Check(Read(templateFile) == templateText, "the template is never written"); 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, even beside a dependency's project file"); Check(!text.contains("libtool"), "lib's host tooling stays out of its consumers' notes"); Check(!text.contains("testonly"), "a test's dependency stays out"); 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 AGENTS.md"); 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(libNotes, "## 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"); // AGENTS.md follows the template: an edit made there shows up here, and // the template's marker lines say where the notes go. Write(templateFile, std::format("{}\nA paragraph added to the template.\n", templateText)); Check(UpdateAgentsMd(cfg, projectFile), "an edited template is picked up"); Check(Read(agents).starts_with(std::format("{}\nA paragraph added to the template.\n\n{}\n", generated, BeginMarker)), "the template's new text goes in ahead of the section"); Write(templateFile, std::format("# app\n\n{}\n{}\n\n## After\n\nText after the notes.\n", BeginMarker, EndMarker)); Check(UpdateAgentsMd(cfg, projectFile), "a template that places the section is picked up"); text = Read(agents); Check(text.starts_with(std::format("{}\n# app\n\n{}\n", GeneratedLine, BeginMarker)) && text.contains(toolNote) && text.ends_with(std::format("{}\n\n## After\n\nText after the notes.\n", EndMarker)), "the section goes where the template's markers are"); Write(templateFile, templateText); Check(UpdateAgentsMd(cfg, projectFile) && Read(agents).starts_with(generated), "AGENTS.md follows the template back"); // A CLAUDE.md hard-linked to AGENTS.md has to keep reading the same file. fs::create_hard_link(agents, app / "CLAUDE.md"); Write(libNotes, "## lib\n\nlib, through a link.\n"); Check(UpdateAgentsMd(cfg, projectFile), "a hard-linked AGENTS.md is updated"); Check(Read(app / "CLAUDE.md") == Read(agents) && Read(agents).contains("lib, through a link.") && fs::hard_link_count(agents) == 2, "the update is visible through every link to the file"); fs::remove(app / "CLAUDE.md"); // A read-only AGENTS.md is a lock someone set on purpose. Root writes // through the permission bits, so the check needs an ordinary user. fs::permissions(agents, fs::perms::owner_read | fs::perms::group_read | fs::perms::others_read); if (!std::ofstream(agents, std::ios::app).is_open()) { const std::string locked = Read(agents); Write(libNotes, "## lib\n\nlib, locked out.\n"); Check(!UpdateAgentsMd(cfg, projectFile) && Read(agents) == locked, "a read-only AGENTS.md is left alone"); } fs::permissions(agents, fs::perms::owner_write, fs::perm_options::add); // A build's own AGENTS.md is output, an edit made in it included; any // other text in its place is someone's, and only an empty file is not. (void)UpdateAgentsMd(cfg, projectFile); const std::string settled = Read(agents); Write(agents, std::format("{}\nAn edit made in AGENTS.md itself.\n", GeneratedLine)); Check(UpdateAgentsMd(cfg, projectFile) && Read(agents) == settled, "an edit made in AGENTS.md is replaced by the next build"); const std::string handWritten = "# app\n\nAn AGENTS.md committed before the template was.\n"; Write(agents, handWritten); Check(!UpdateAgentsMd(cfg, projectFile) && Read(agents) == handWritten, "an AGENTS.md no build wrote is never replaced"); Write(agents, ""); Check(UpdateAgentsMd(cfg, projectFile) && Read(agents) == settled, "an empty AGENTS.md, all a write cut short leaves of a new one, is replaced"); cfg.dependencyNotes = DependencyNotes::None; Check(UpdateAgentsMd(cfg, projectFile), "None rewrites AGENTS.md"); Check(Read(agents) == generated, "under None, AGENTS.md holds the template's text alone"); cfg.dependencyNotes = DependencyNotes::Transitive; // Linked to the template, AGENTS.md would have every build write the // file that is committed. fs::remove(agents); fs::create_symlink("AGENTS.template.md", agents); Check(!UpdateAgentsMd(cfg, projectFile) && Read(templateFile) == templateText, "an AGENTS.md that links to the template is not written"); fs::remove(agents); // A template the notes cannot be placed in leaves no AGENTS.md behind, // not even an empty one. Write(templateFile, std::format("# app\n\n{}\n", BeginMarker)); Check(!UpdateAgentsMd(cfg, projectFile) && !fs::exists(agents), "a template with a begin marker and no end marker writes no AGENTS.md"); // A CRLF template makes a CRLF AGENTS.md, the line a build adds included. Write(templateFile, "# app\r\n\r\nText.\r\n"); Check(UpdateAgentsMd(cfg, projectFile), "a CRLF template is written"); text = Read(agents); Check(text.starts_with(std::format("{}\r\n# app\r\n", GeneratedLine)) && Count(text, "\n") == Count(text, "\r\n"), "AGENTS.md from a CRLF template ends every line in CRLF"); Write(templateFile, templateText); fs::remove(agents); // A layout that 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", ""); fs::create_symlink("docs/agents.md", agents); Check(UpdateAgentsMd(cfg, projectFile), "an AGENTS.md that is a symlink is written"); Check(fs::is_symlink(agents) && Read(app / "docs" / "agents.md").contains(toolNote), "the write goes through the link, which stays a link"); fs::remove(agents); // Without a template, AGENTS.md is someone's own: never created, and // never written, not even to take out a section an earlier crafter-build // kept in it, which only gets a warning. fs::remove(templateFile); Check(!UpdateAgentsMd(cfg, projectFile) && !fs::exists(agents), "a project without a template gets no AGENTS.md"); const std::vector oldNotes = { { .name = "lib", .text = "## lib\n\nNotes a committed AGENTS.md still holds." } }; const std::string committed = Merge(templateText, oldNotes); Write(agents, committed); Check(!UpdateAgentsMd(cfg, projectFile) && Read(agents) == committed, "without a template, an AGENTS.md holding a section is left alone"); fs::remove(agents); Write(templateFile, templateText); // The CLI: only a build or a test run writes it. (void)RunWith({"crafter-build", "-q", "lint"}); Check(!fs::exists(agents), "lint writes no AGENTS.md"); (void)RunWith({"crafter-build", "-q", "run"}); Check(!fs::exists(agents), "run writes no AGENTS.md"); (void)RunWith({"crafter-build", "-q", "test", "--list"}); Check(!fs::exists(agents), "test --list writes no AGENTS.md"); (void)RunWith({"crafter-build", "-q", "test"}); Check(Read(agents).contains(toolNote), "a test run writes AGENTS.md"); // app has no sources, so this build fails at the link, after the update. fs::remove(agents); (void)RunWith({"crafter-build", "-q"}); Check(Read(agents).contains(toolNote), "a build writes AGENTS.md before compiling, so one that fails still does"); // The builds above leave a std PCM per configuration in a tmpfs; a run // that failed keeps them for a look. fs::current_path(home); if (Failures == 0) fs::remove_all(scratch, ec); return Failures > 0 ? 1 : 0; }