diff --git a/tests/DependencyNotes/fixture/app/helper.cpp b/tests/DependencyNotes/fixture/app/helper.cpp new file mode 100644 index 0000000..6a84206 --- /dev/null +++ b/tests/DependencyNotes/fixture/app/helper.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +using namespace Crafter; + +// A second project file in app/: a dependency whose AGENTS.consumer.md, by +// where it sits, is app's own. +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "helper"; + cfg.outputName = "helper"; + cfg.type = ConfigurationType::LibraryStatic; + return cfg; +} diff --git a/tests/DependencyNotes/fixture/app/project.cpp b/tests/DependencyNotes/fixture/app/project.cpp index 6c459de..d88e590 100644 --- a/tests/DependencyNotes/fixture/app/project.cpp +++ b/tests/DependencyNotes/fixture/app/project.cpp @@ -9,7 +9,8 @@ using namespace Crafter; // The consumer whose AGENTS.md the test watches. It names lib itself, reaches // base only through lib, and uses tool as host tooling. Its own // AGENTS.consumer.md is for its consumers and must never land in its own -// AGENTS.md. +// AGENTS.md, not even through helper, a dependency whose project file sits +// beside this one. testonly is only a test's dependency. extern "C" Configuration CrafterBuildProject(std::span) { Configuration cfg; cfg.path = "./"; @@ -17,6 +18,10 @@ extern "C" Configuration CrafterBuildProject(std::span) cfg.outputName = "app"; cfg.type = ConfigurationType::Executable; // Against the working directory, as with `cd app && crafter-build`. - cfg.dependencies = { LocalProject({ .projectFile = "../lib/project.cpp" }) }; + cfg.dependencies = { + LocalProject({ .projectFile = "../lib/project.cpp" }), + LocalProject({ .projectFile = "helper.cpp" }), + }; + cfg.AddTest("t").Dependencies({ LocalProject({ .projectFile = "../testonly/project.cpp" }) }); return cfg; } diff --git a/tests/DependencyNotes/fixture/app/tests/t/main.cpp b/tests/DependencyNotes/fixture/app/tests/t/main.cpp new file mode 100644 index 0000000..963b8e0 --- /dev/null +++ b/tests/DependencyNotes/fixture/app/tests/t/main.cpp @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// app's one test, there to have a dependency of its own. +int main() { + return 0; +} diff --git a/tests/DependencyNotes/fixture/testonly/AGENTS.consumer.md b/tests/DependencyNotes/fixture/testonly/AGENTS.consumer.md new file mode 100644 index 0000000..9297975 --- /dev/null +++ b/tests/DependencyNotes/fixture/testonly/AGENTS.consumer.md @@ -0,0 +1,3 @@ +## testonly + +Only a test uses this. It must never reach app. diff --git a/tests/DependencyNotes/fixture/testonly/project.cpp b/tests/DependencyNotes/fixture/testonly/project.cpp new file mode 100644 index 0000000..fff99f7 --- /dev/null +++ b/tests/DependencyNotes/fixture/testonly/project.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +using namespace Crafter; + +// Only app's test depends on it. A plain build never looks at the tests, so +// its notes must not reach app. +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "testonly"; + cfg.outputName = "testonly"; + cfg.type = ConfigurationType::LibraryStatic; + return cfg; +} diff --git a/tests/DependencyNotes/main.cpp b/tests/DependencyNotes/main.cpp index 1e621d0..9624229 100644 --- a/tests/DependencyNotes/main.cpp +++ b/tests/DependencyNotes/main.cpp @@ -12,11 +12,13 @@ using namespace Crafter; // // 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. +// every build, text outside the section rewritten, lost or run together, a +// write when nothing changed (a dirty tree, an editor reloading), an +// AGENTS.md created in a project that has none, one written although it is +// read-only, or replaced so that a hard link to it goes stale, 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; @@ -42,6 +44,18 @@ namespace { 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 { @@ -76,49 +90,85 @@ 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 = { - { .source = "https://example.invalid/one.git", .text = "## one\n\nFirst." }, - { .source = "../two/AGENTS.consumer.md", .text = "## two\n\nSecond." }, + { .name = "one", .text = "## one\n\nFirst." }, + { .name = "two", .text = "## two\n\nSecond." }, }; - const std::string merged = MergeDependencyNotes(original, two); + 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 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"); + 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. + // 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 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 = { { .source = "https://example.invalid/one.git", .text = "## one\n\nChanged." } }; - const std::string rewritten = MergeDependencyNotes(moved, changed); + 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. + // 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 = MergeDependencyNotes(quoted, two); + 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(MergeDependencyNotes(withNotes, two) == withNotes, "the real section is found past the quoted markers"); + 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"); } - // Guessing which part of someone's file is generated would overwrite their - // text, and missing a section would add another on every build. + // 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"), "a document that ends inside a code block is refused"); - const std::vector markerNote = { { .source = "marker-note", .text = std::format("## bad\n{}", EndMarker) } }; + 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 = { { .source = "fence-note", .text = "## bad\n```\nnever closed" } }; + 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()) { @@ -128,7 +178,8 @@ int main() { // 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 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); @@ -137,6 +188,7 @@ int main() { const fs::path app = scratch / "app"; const fs::path agents = app / "AGENTS.md"; const fs::path projectFile = app / "project.cpp"; + const fs::path libNotes = scratch / "lib" / "AGENTS.consumer.md"; // 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"); @@ -154,10 +206,10 @@ int main() { 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"; + 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"; + const std::string baseNote = "\n## base\n\nbase counts from zero.\n"; Check(UpdateAgentsMd(cfg, projectFile), "the first update writes AGENTS.md"); std::string text = Read(agents); @@ -167,8 +219,9 @@ int main() { 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("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"); @@ -180,11 +233,28 @@ int main() { 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"); + 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"); + // 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); + 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"); @@ -209,11 +279,17 @@ int main() { (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) == agentsOriginal, "test --list leaves AGENTS.md alone"); + (void)RunWith({"crafter-build", "-q", "test"}); 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"); + // 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; }