diff --git a/project.cpp b/project.cpp index ec5f3e8..b6ba192 100644 --- a/project.cpp +++ b/project.cpp @@ -290,6 +290,8 @@ extern "C" Configuration CrafterBuildProject(std::span a .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("Script").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); + cfg.AddTest("DependencyNotes").Dependencies({ CrafterBuildLib.get() }) + .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("Lint").Dependencies({ CrafterBuildLib.get() }); } diff --git a/tests/DependencyNotes/fixture/app/AGENTS.consumer.md b/tests/DependencyNotes/fixture/app/AGENTS.consumer.md new file mode 100644 index 0000000..4e0f6a1 --- /dev/null +++ b/tests/DependencyNotes/fixture/app/AGENTS.consumer.md @@ -0,0 +1,4 @@ +## app + +Notes from app for its own consumers. They must never appear in app's own +AGENTS.md. diff --git a/tests/DependencyNotes/fixture/app/AGENTS.md.in b/tests/DependencyNotes/fixture/app/AGENTS.md.in new file mode 100644 index 0000000..e623e81 --- /dev/null +++ b/tests/DependencyNotes/fixture/app/AGENTS.md.in @@ -0,0 +1,4 @@ +# app + +The consumer's own instructions. Nothing in this file may change, except the +section crafter-build appends. diff --git a/tests/DependencyNotes/fixture/app/project.cpp b/tests/DependencyNotes/fixture/app/project.cpp new file mode 100644 index 0000000..6c459de --- /dev/null +++ b/tests/DependencyNotes/fixture/app/project.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +#pragma crafter dependency "../tool" +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. +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "app"; + cfg.outputName = "app"; + cfg.type = ConfigurationType::Executable; + // Against the working directory, as with `cd app && crafter-build`. + cfg.dependencies = { LocalProject({ .projectFile = "../lib/project.cpp" }) }; + return cfg; +} diff --git a/tests/DependencyNotes/fixture/base/AGENTS.consumer.md b/tests/DependencyNotes/fixture/base/AGENTS.consumer.md new file mode 100644 index 0000000..e98e8b0 --- /dev/null +++ b/tests/DependencyNotes/fixture/base/AGENTS.consumer.md @@ -0,0 +1,6 @@ + + +## base + +base counts from zero. + diff --git a/tests/DependencyNotes/fixture/base/project.cpp b/tests/DependencyNotes/fixture/base/project.cpp new file mode 100644 index 0000000..6097fd2 --- /dev/null +++ b/tests/DependencyNotes/fixture/base/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; + +// lib's dependency, so app's only through lib: in its notes under +// Transitive, gone under Direct. +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "base"; + cfg.outputName = "base"; + cfg.type = ConfigurationType::LibraryStatic; + return cfg; +} diff --git a/tests/DependencyNotes/fixture/lib/AGENTS.consumer.md b/tests/DependencyNotes/fixture/lib/AGENTS.consumer.md new file mode 100644 index 0000000..4f7ffeb --- /dev/null +++ b/tests/DependencyNotes/fixture/lib/AGENTS.consumer.md @@ -0,0 +1,3 @@ +## lib + +Call lib through its module, never through its headers. diff --git a/tests/DependencyNotes/fixture/lib/project.cpp b/tests/DependencyNotes/fixture/lib/project.cpp new file mode 100644 index 0000000..f997bcb --- /dev/null +++ b/tests/DependencyNotes/fixture/lib/project.cpp @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +#pragma crafter dependency "../libtool" +using namespace Crafter; + +// What app depends on. It builds against base, which app only reaches +// through it. libtool is lib's own host tooling: it says nothing about using +// lib, so its notes must not reach app. +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "lib"; + cfg.outputName = "lib"; + cfg.type = ConfigurationType::LibraryStatic; + // Loaded from lib/, so this resolves against it. + cfg.dependencies = { LocalProject({ .projectFile = "../base/project.cpp" }) }; + return cfg; +} diff --git a/tests/DependencyNotes/fixture/libtool/AGENTS.consumer.md b/tests/DependencyNotes/fixture/libtool/AGENTS.consumer.md new file mode 100644 index 0000000..97f2aba --- /dev/null +++ b/tests/DependencyNotes/fixture/libtool/AGENTS.consumer.md @@ -0,0 +1,3 @@ +## libtool + +How lib is built. This must never reach app. diff --git a/tests/DependencyNotes/fixture/libtool/project.cpp b/tests/DependencyNotes/fixture/libtool/project.cpp new file mode 100644 index 0000000..d1aac50 --- /dev/null +++ b/tests/DependencyNotes/fixture/libtool/project.cpp @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +using namespace Crafter; + +// lib's host tooling. Like tool, a library with no sources. +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "libtool"; + cfg.outputName = "libtool"; + cfg.type = ConfigurationType::LibraryStatic; + return cfg; +} diff --git a/tests/DependencyNotes/fixture/tool/AGENTS.consumer.md b/tests/DependencyNotes/fixture/tool/AGENTS.consumer.md new file mode 100644 index 0000000..0229c64 --- /dev/null +++ b/tests/DependencyNotes/fixture/tool/AGENTS.consumer.md @@ -0,0 +1,3 @@ +## tool + +Run tool before committing. diff --git a/tests/DependencyNotes/fixture/tool/project.cpp b/tests/DependencyNotes/fixture/tool/project.cpp new file mode 100644 index 0000000..5cdcc83 --- /dev/null +++ b/tests/DependencyNotes/fixture/tool/project.cpp @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +using namespace Crafter; + +// app's host tooling, named by its `#pragma crafter dependency`. Built for +// the host before app's project.cpp compiles, so it is a library with no +// sources: an empty archive is all the pre-pass needs. +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "tool"; + cfg.outputName = "tool"; + cfg.type = ConfigurationType::LibraryStatic; + return cfg; +} diff --git a/tests/DependencyNotes/main.cpp b/tests/DependencyNotes/main.cpp new file mode 100644 index 0000000..1e621d0 --- /dev/null +++ b/tests/DependencyNotes/main.cpp @@ -0,0 +1,219 @@ +// 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(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; + } + + // 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 = ""; +} + +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." }, + }; + 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("\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"); + + // 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 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 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 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 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"; + + 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; +}