Merge branch 'claude/dependency-notes': a library's AGENTS.consumer.md in its consumers' AGENTS.md
All checks were successful
Flatpak / flatpak (push) Successful in 12m18s
All checks were successful
Flatpak / flatpak (push) Successful in 12m18s
Brings in dependency notes: a crafter-build project that ships an AGENTS.consumer.md beside its project.cpp has it kept in a marked section of each consumer's AGENTS.md, refreshed by `crafter-build` and `crafter-build test`, chosen by cfg.dependencyNotes (Transitive, Direct, None). The new partition is Crafter.Build:Agents; the loader records where each GitProject and LocalProject came from and what a project file's pragmas resolved to. The branch started before master's four external-fetch commits. Both touch External.cpp, the Clang partition, project.cpp, README.md and AGENTS.md, but in different places, so the merge needed no hand reconciliation; the merged tree passes build.sh, the self-host build, format --check and the test suite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
cc0a6aed8c
29 changed files with 1238 additions and 19 deletions
4
tests/DependencyNotes/fixture/app/AGENTS.consumer.md
Normal file
4
tests/DependencyNotes/fixture/app/AGENTS.consumer.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## app
|
||||
|
||||
Notes from app for its own consumers. They must never appear in app's own
|
||||
AGENTS.md.
|
||||
4
tests/DependencyNotes/fixture/app/AGENTS.md.in
Normal file
4
tests/DependencyNotes/fixture/app/AGENTS.md.in
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# app
|
||||
|
||||
The consumer's own instructions. Nothing in this file may change, except the
|
||||
section crafter-build appends.
|
||||
17
tests/DependencyNotes/fixture/app/helper.cpp
Normal file
17
tests/DependencyNotes/fixture/app/helper.cpp
Normal file
|
|
@ -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<const std::string_view>) {
|
||||
Configuration cfg;
|
||||
cfg.path = "./";
|
||||
cfg.name = "helper";
|
||||
cfg.outputName = "helper";
|
||||
cfg.type = ConfigurationType::LibraryStatic;
|
||||
return cfg;
|
||||
}
|
||||
27
tests/DependencyNotes/fixture/app/project.cpp
Normal file
27
tests/DependencyNotes/fixture/app/project.cpp
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// 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, 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<const std::string_view>) {
|
||||
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" }),
|
||||
LocalProject({ .projectFile = "helper.cpp" }),
|
||||
};
|
||||
cfg.AddTest("t").Dependencies({ LocalProject({ .projectFile = "../testonly/project.cpp" }) });
|
||||
return cfg;
|
||||
}
|
||||
7
tests/DependencyNotes/fixture/app/tests/t/main.cpp
Normal file
7
tests/DependencyNotes/fixture/app/tests/t/main.cpp
Normal file
|
|
@ -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;
|
||||
}
|
||||
6
tests/DependencyNotes/fixture/base/AGENTS.consumer.md
Normal file
6
tests/DependencyNotes/fixture/base/AGENTS.consumer.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
|
||||
## base
|
||||
|
||||
base counts from zero.
|
||||
|
||||
17
tests/DependencyNotes/fixture/base/project.cpp
Normal file
17
tests/DependencyNotes/fixture/base/project.cpp
Normal file
|
|
@ -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<const std::string_view>) {
|
||||
Configuration cfg;
|
||||
cfg.path = "./";
|
||||
cfg.name = "base";
|
||||
cfg.outputName = "base";
|
||||
cfg.type = ConfigurationType::LibraryStatic;
|
||||
return cfg;
|
||||
}
|
||||
3
tests/DependencyNotes/fixture/lib/AGENTS.consumer.md
Normal file
3
tests/DependencyNotes/fixture/lib/AGENTS.consumer.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## lib
|
||||
|
||||
Call lib through its module, never through its headers.
|
||||
21
tests/DependencyNotes/fixture/lib/project.cpp
Normal file
21
tests/DependencyNotes/fixture/lib/project.cpp
Normal file
|
|
@ -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<const std::string_view>) {
|
||||
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;
|
||||
}
|
||||
3
tests/DependencyNotes/fixture/libtool/AGENTS.consumer.md
Normal file
3
tests/DependencyNotes/fixture/libtool/AGENTS.consumer.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## libtool
|
||||
|
||||
How lib is built. This must never reach app.
|
||||
16
tests/DependencyNotes/fixture/libtool/project.cpp
Normal file
16
tests/DependencyNotes/fixture/libtool/project.cpp
Normal file
|
|
@ -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<const std::string_view>) {
|
||||
Configuration cfg;
|
||||
cfg.path = "./";
|
||||
cfg.name = "libtool";
|
||||
cfg.outputName = "libtool";
|
||||
cfg.type = ConfigurationType::LibraryStatic;
|
||||
return cfg;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
## testonly
|
||||
|
||||
Only a test uses this. It must never reach app.
|
||||
17
tests/DependencyNotes/fixture/testonly/project.cpp
Normal file
17
tests/DependencyNotes/fixture/testonly/project.cpp
Normal file
|
|
@ -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<const std::string_view>) {
|
||||
Configuration cfg;
|
||||
cfg.path = "./";
|
||||
cfg.name = "testonly";
|
||||
cfg.outputName = "testonly";
|
||||
cfg.type = ConfigurationType::LibraryStatic;
|
||||
return cfg;
|
||||
}
|
||||
3
tests/DependencyNotes/fixture/tool/AGENTS.consumer.md
Normal file
3
tests/DependencyNotes/fixture/tool/AGENTS.consumer.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## tool
|
||||
|
||||
Run tool before committing.
|
||||
18
tests/DependencyNotes/fixture/tool/project.cpp
Normal file
18
tests/DependencyNotes/fixture/tool/project.cpp
Normal file
|
|
@ -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<const std::string_view>) {
|
||||
Configuration cfg;
|
||||
cfg.path = "./";
|
||||
cfg.name = "tool";
|
||||
cfg.outputName = "tool";
|
||||
cfg.type = ConfigurationType::LibraryStatic;
|
||||
return cfg;
|
||||
}
|
||||
295
tests/DependencyNotes/main.cpp
Normal file
295
tests/DependencyNotes/main.cpp
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// 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, 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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<const DependencyNote> notes) {
|
||||
try {
|
||||
return MergeDependencyNotes(doc, notes);
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "FAIL: MergeDependencyNotes threw: {}", e.what());
|
||||
++Failures;
|
||||
return std::format("<threw: {}>", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
{ .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("<!-- from one -->\n## one\n\nFirst.\n");
|
||||
const std::size_t second = merged.find("<!-- from two -->\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<DependencyNote> 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<DependencyNote> 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<DependencyNote> 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 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");
|
||||
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 -->\n## tool\n\nRun tool before committing.\n";
|
||||
const std::string libNote = "<!-- from lib -->\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 -->\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, 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 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(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");
|
||||
|
||||
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) == 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;
|
||||
}
|
||||
Loading…
Reference in a new issue