feat(agents): write AGENTS.md from a tracked AGENTS.template.md
The dependency notes were kept in a section of the AGENTS.md beside project.cpp, the file a project commits, so a build could change a tracked file at any time: a dependency editing its AGENTS.consumer.md, an unpinned one moving on, a flag pulling a dependency in or out, each showed up in the consumer's diff as a change nobody made. Users reported it as the feature clobbering their diffs. A project now commits its instructions as AGENTS.template.md, and a build and a test run write AGENTS.md from it: a first line saying the file is generated and where to edit, the template's text as written, then the notes section, after it or where the template's marker lines put it. AGENTS.md is build output and goes into .gitignore. It is created when missing, since a fresh clone has none, and otherwise written as before: only when its text changes, in place and under a lock, so links, ownership and a read-only file are respected. The first line is also how a build tells its own AGENTS.md from one someone wrote. Beside a template the second is easily there, as a committed AGENTS.md nobody has removed yet; it is never replaced, and each build warns until it is deleted. An empty file counts as a build's, since that is what a write cut short leaves of a new one. An AGENTS.md linked to the template is refused: writing it would write the committed file again. A template the notes cannot be placed in leaves no empty AGENTS.md behind. The in-place mode is removed rather than kept as the fallback for a project without a template. It is the default that put generated text into tracked files unasked, the moment any dependency started shipping notes. Without a template a build no longer touches AGENTS.md. One that still holds a section from 3.0.0 would go stale silently, under a preamble promising that every build rewrites it, so it gets a warning naming the move instead. MergeDependencyNotes keeps its rules and now runs on the template. The test changes in this commit rather than its own, because it pinned the in-place behaviour this removes: it now covers the generated file's shape, edits and markers in the template, the ownership check, a CRLF template, a link to the template and the cases without one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b26807f5aa
commit
74e8370b2b
8 changed files with 259 additions and 121 deletions
|
|
@ -6,19 +6,22 @@ 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.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// 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;
|
||||
|
||||
|
|
@ -84,6 +87,7 @@ namespace {
|
|||
|
||||
constexpr std::string_view BeginMarker = "<!-- crafter-build: dependency notes begin -->";
|
||||
constexpr std::string_view EndMarker = "<!-- crafter-build: dependency notes end -->";
|
||||
constexpr std::string_view GeneratedLine = "<!-- Written by crafter-build from AGENTS.template.md, and rewritten on every build. Edit that file, not this one. -->";
|
||||
}
|
||||
|
||||
int main() {
|
||||
|
|
@ -187,12 +191,13 @@ int main() {
|
|||
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: 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);
|
||||
// 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);
|
||||
|
|
@ -210,10 +215,15 @@ int main() {
|
|||
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";
|
||||
// How every AGENTS.md written from templateText starts.
|
||||
const std::string generated = std::format("{}\n{}", GeneratedLine, templateText);
|
||||
|
||||
Check(UpdateAgentsMd(cfg, projectFile), "the first update writes AGENTS.md");
|
||||
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", agentsOriginal, BeginMarker)), "the section follows the consumer's own text, which is untouched");
|
||||
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);
|
||||
|
|
@ -228,7 +238,7 @@ int main() {
|
|||
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");
|
||||
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");
|
||||
|
||||
|
|
@ -238,6 +248,18 @@ int main() {
|
|||
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");
|
||||
|
|
@ -255,37 +277,77 @@ int main() {
|
|||
}
|
||||
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 removes the section");
|
||||
Check(Read(agents) == agentsOriginal, "removing the section gives AGENTS.md its own bytes back");
|
||||
|
||||
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;
|
||||
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");
|
||||
// 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<DependencyNote> 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.
|
||||
Write(agents, agentsOriginal);
|
||||
(void)RunWith({"crafter-build", "-q", "lint"});
|
||||
Check(Read(agents) == agentsOriginal, "lint leaves AGENTS.md alone");
|
||||
Check(!fs::exists(agents), "lint writes no AGENTS.md");
|
||||
(void)RunWith({"crafter-build", "-q", "run"});
|
||||
Check(Read(agents) == agentsOriginal, "run leaves AGENTS.md alone");
|
||||
Check(!fs::exists(agents), "run writes no AGENTS.md");
|
||||
(void)RunWith({"crafter-build", "-q", "test", "--list"});
|
||||
Check(Read(agents) == agentsOriginal, "test --list leaves AGENTS.md alone");
|
||||
Check(!fs::exists(agents), "test --list writes no AGENTS.md");
|
||||
(void)RunWith({"crafter-build", "-q", "test"});
|
||||
Check(Read(agents).contains(toolNote), "a test run refreshes AGENTS.md");
|
||||
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.
|
||||
Write(agents, agentsOriginal);
|
||||
fs::remove(agents);
|
||||
(void)RunWith({"crafter-build", "-q"});
|
||||
Check(Read(agents).contains(toolNote), "a build refreshes AGENTS.md before compiling, so one that fails still does");
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue