// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include #include #include #include module Crafter.Build:Agents_impl; import std; import :Agents; import :Clang; import :External; import :Platform; import :Progress; namespace fs = std::filesystem; using namespace Crafter; namespace { constexpr std::string_view BeginMarker = ""; constexpr std::string_view EndMarker = ""; // Right under the begin marker, for whoever opens the file: an edit made // inside the section is gone after the next build. constexpr std::string_view Preamble = ""; // The first line of every AGENTS.md a build writes. It tells whoever // opens the file, an agent asked to change it included, where an edit // has to be made to outlive the next build, and it is how a build tells // its own AGENTS.md from one someone wrote, which it never replaces. A // changed word would make every AGENTS.md written before look like // someone's own. constexpr std::string_view GeneratedLine = ""; constexpr std::string_view NotesFileName = "AGENTS.consumer.md"; constexpr std::string_view TemplateFileName = "AGENTS.template.md"; constexpr std::string_view AgentsFileName = "AGENTS.md"; void Warn(std::string_view message) { Progress::Diagnostic(std::format("warning: {}", message)); } // The line ending `text` uses, so a CRLF file does not end up mixed. std::string_view LineEnd(std::string_view text) { return text.contains("\r\n") ? "\r\n" : "\n"; } std::string ErrnoMessage() { return std::error_code(errno, std::generic_category()).message(); } // Closes on the way out of scope, which also drops an flock taken // through it. class Descriptor { public: explicit Descriptor(std::int32_t fd) : fd_(fd) {} ~Descriptor() { if (fd_ >= 0) ::close(fd_); } Descriptor(const Descriptor&) = delete; Descriptor& operator=(const Descriptor&) = delete; std::int32_t Get() const { return fd_; } bool Valid() const { return fd_ >= 0; } private: std::int32_t fd_; }; // Everything `fd` reads, into `out`; empty on success, otherwise the // error. Through read(2) rather than a stream: libc++'s filebuf reports a // failed read as the end of the file, so an I/O error came back as a // short read that looked successful, and the section was then written // over a truncated AGENTS.md. std::string ReadAll(std::int32_t fd, std::string& out) { out.clear(); std::array buffer; for (;;) { const auto got = ::read(fd, buffer.data(), buffer.size()); if (got > 0) { out.append(buffer.data(), static_cast(got)); } else if (got == 0) { return ""; } else if (errno != EINTR) { return ErrnoMessage(); } } } std::string ReadFile(const fs::path& file, std::string& out) { const Descriptor in(::open(file.c_str(), O_RDONLY | O_CLOEXEC)); if (!in.Valid()) return ErrnoMessage(); return ReadAll(in.Get(), out); } struct Line { std::string_view text; // without the newline and a '\r' before it std::size_t begin = 0; // offset of the line's first byte std::size_t next = 0; // offset of the line after it }; std::vector SplitLines(std::string_view text) { std::vector lines; std::size_t pos = 0; while (pos < text.size()) { const std::size_t newline = text.find('\n', pos); const std::size_t end = newline == std::string_view::npos ? text.size() : newline; std::string_view line = text.substr(pos, end - pos); if (line.ends_with('\r')) line.remove_suffix(1); const std::size_t next = newline == std::string_view::npos ? text.size() : newline + 1; lines.push_back({ .text = line, .begin = pos, .next = next }); pos = next; } return lines; } std::string_view TrimEnd(std::string_view text) { const std::size_t last = text.find_last_not_of(" \t"); return last == std::string_view::npos ? std::string_view() : text.substr(0, last + 1); } // A line with the block quote markers in front of it taken off. struct Shape { std::size_t quotes = 0; // `>` markers std::size_t indent = 0; // columns of indentation after them std::string_view rest; // the line from its first other character }; Shape ShapeOf(std::string_view line) { Shape shape; std::size_t pos = 0; for (;;) { std::size_t probe = pos; while (probe < line.size() && probe - pos < 3 && line[probe] == ' ') ++probe; if (probe >= line.size() || line[probe] != '>') break; pos = probe + 1; if (pos < line.size() && line[pos] == ' ') ++pos; ++shape.quotes; } for (; pos < line.size() && (line[pos] == ' ' || line[pos] == '\t'); ++pos) { shape.indent += line[pos] == '\t' ? 4 - shape.indent % 4 : 1; } shape.rest = line.substr(pos); return shape; } // The columns a list item marker at the start of `text` takes, with the // spaces after it: `-`, `+` or `*`, or up to nine digits and `.` or `)`, // then at least one space. Zero when `text` starts with none. std::size_t ListMarker(std::string_view text) { std::size_t pos = 0; if (!text.empty() && (text[0] == '-' || text[0] == '+' || text[0] == '*')) { pos = 1; } else { while (pos < text.size() && pos < 9 && text[pos] >= '0' && text[pos] <= '9') ++pos; if (pos == 0 || pos >= text.size() || (text[pos] != '.' && text[pos] != ')')) return 0; ++pos; } const std::size_t marker = pos; while (pos < text.size() && text[pos] == ' ') ++pos; return pos == marker ? 0 : pos; } struct Fence { char ch = '\0'; std::size_t length = 0; std::string_view after; // the rest of the line behind the run }; // The run of three or more backticks or tildes `text` starts with. std::optional FenceRun(std::string_view text) { if (text.empty() || (text[0] != '`' && text[0] != '~')) return std::nullopt; std::size_t length = 0; while (length < text.size() && text[length] == text[0]) ++length; if (length < 3) return std::nullopt; return Fence{ .ch = text[0], .length = length, .after = text.substr(length) }; } // Fenced code blocks, close to how CommonMark reads them. A run of three // or more backticks or tildes opens one after at most three spaces of // indentation; a backtick run only when no backtick follows it on the // line, since ```make``` is inline code. A run of the same character at // least as long, with nothing behind it but spaces, closes it. Keeping // the character and the length is what lets a ~~~ block quote a ``` one. // // Block quotes and list items are followed as far as fences need: the // `>` markers come off every line, list markers off the line that opens // a block, and a block ends with the container it sits in, at a line with // fewer `>` or one indented less than its list item's content. Read // without them, `- ```bash` was missed while its indented closer was // taken for an opener, and the rest of the file came out inside out. Not // followed: indented code blocks, and the continuation lines of a list // item before a fence that opens there. class CodeFences { public: // Feed the lines in order. True for a fence and for every line // inside a block. bool InCode(std::string_view line) { const Shape shape = ShapeOf(line); if (open_ != '\0') { const bool blank = TrimEnd(shape.rest).empty(); const bool left = !blank && (shape.quotes < quotes_ || shape.indent < content_); if (!left) { const std::optional fence = FenceRun(shape.rest); if (fence && fence->ch == open_ && fence->length >= length_ && TrimEnd(fence->after).empty() && shape.indent <= content_ + 3) open_ = '\0'; return true; } // The container ended, and the block with it; the line is // read again as one outside any block. open_ = '\0'; } if (shape.indent > 3) return false; std::size_t column = shape.indent; std::string_view text = shape.rest; std::size_t content = 0; for (std::size_t marker = ListMarker(text); marker > 0; marker = ListMarker(text)) { column += marker; text.remove_prefix(marker); content = column; } const std::optional fence = FenceRun(text); if (!fence || (fence->ch == '`' && fence->after.contains('`'))) return false; open_ = fence->ch; length_ = fence->length; quotes_ = shape.quotes; content_ = content; ++opened_; return true; } bool Open() const { return open_ != '\0'; } // How many blocks have opened so far. std::size_t Opened() const { return opened_; } private: char open_ = '\0'; std::size_t length_ = 0; std::size_t quotes_ = 0; // the block quote depth it opened at std::size_t content_ = 0; // its list item's content column; 0 outside a list std::size_t opened_ = 0; }; // Where a text's marker lines are, outside code blocks, as indices into // `lines`. struct MarkerScan { std::vector lines; std::vector begins; std::vector ends; // The line a code block that never closes opens on. std::optional unclosedBlock; }; MarkerScan ScanMarkers(std::string_view text) { MarkerScan scan; scan.lines = SplitLines(text); CodeFences fences; for (std::size_t i = 0; i < scan.lines.size(); ++i) { const std::size_t opened = fences.Opened(); const bool code = fences.InCode(scan.lines[i].text); if (fences.Opened() != opened) scan.unclosedBlock = i; if (code) continue; const std::string_view line = TrimEnd(scan.lines[i].text); if (line == BeginMarker) scan.begins.push_back(i); if (line == EndMarker) scan.ends.push_back(i); } if (!fences.Open()) scan.unclosedBlock.reset(); return scan; } // Why `text` cannot go into the section as it is; empty when it can. A // marker line of its own would end or restart the section for whoever // reads it, a template copied from the result included, and a code block // left open would run on over the notes after it and the end marker. // Lines are numbered from `firstLine`. std::string NoteProblem(std::string_view text, std::size_t firstLine) { const MarkerScan scan = ScanMarkers(text); std::optional marker; if (!scan.begins.empty()) marker = scan.begins[0]; if (!scan.ends.empty()) marker = std::min(marker.value_or(scan.ends[0]), scan.ends[0]); if (marker) return std::format("line {} is a dependency notes marker", *marker + firstLine); if (scan.unclosedBlock) return std::format("line {} opens a code block that never closes", *scan.unclosedBlock + firstLine); return ""; } struct NormalizedNote { std::string text; // The line of the file `text` starts on. std::size_t firstLine = 1; }; // A notes file as it goes into the section: LF line ends, no blank lines // in front and no whitespace behind, so the section spaces its notes the // same whatever the files end with. NormalizedNote NormalizeNote(std::string_view raw) { std::string text; text.reserve(raw.size()); for (std::size_t i = 0; i < raw.size(); ++i) { if (raw[i] == '\r' && i + 1 < raw.size() && raw[i + 1] == '\n') continue; text.push_back(raw[i]); } const std::size_t last = text.find_last_not_of(" \t\r\n"); if (last == std::string::npos) return {}; const std::size_t first = text.find_first_not_of(" \t\r\n"); const std::size_t newline = text.rfind('\n', first); const std::size_t begin = newline == std::string::npos ? 0 : newline + 1; const auto skipped = std::count(text.begin(), text.begin() + static_cast(begin), '\n'); return { .text = text.substr(begin, last + 1 - begin), .firstLine = static_cast(skipped) + 1 }; } // `text` with `eol` ending its lines, whether they ended in LF or CRLF. std::string WithLineEnds(std::string_view text, std::string_view eol) { std::string out; out.reserve(text.size()); for (std::size_t i = 0; i < text.size(); ++i) { if (text[i] == '\r' && i + 1 < text.size() && text[i + 1] == '\n') continue; if (text[i] == '\n') { out += eol; } else { out.push_back(text[i]); } } return out; } std::string RenderSection(std::span notes, std::string_view eol) { std::string section = std::format("{}{}{}{}", BeginMarker, eol, Preamble, eol); for (const DependencyNote& note : notes) section += std::format("{0}{0}{2}{0}", eol, note.name, WithLineEnds(note.text, eol)); section += std::format("{0}{1}{0}", eol, EndMarker); return section; } // The note beside a dependency's project file, or nullopt when there is // none or it cannot be used. std::optional ReadNote(const fs::path& file, const std::string& name) { std::error_code ec; if (!fs::is_regular_file(file, ec)) return std::nullopt; std::string content; if (const std::string error = ReadFile(file, content); !error.empty()) { Warn(std::format("{} could not be read ({}), so its notes are left out", file.string(), error)); return std::nullopt; } NormalizedNote note = NormalizeNote(content); if (note.text.empty()) return std::nullopt; // On the very bytes the merge checks, so the two can never disagree, // and numbered as the author's file is. if (const std::string problem = NoteProblem(note.text, note.firstLine); !problem.empty()) { Warn(std::format("{}: {}, so its notes are left out", file.string(), problem)); return std::nullopt; } return DependencyNote{ .name = name, .text = std::move(note.text) }; } // The loaded projects `configs` lead to without passing through another // loaded project, in the order the configurations name them: one ring // further out. `walked` spans the whole collection, so each // Configuration is looked at once however many paths reach it. std::vector NextRing(std::span configs, std::unordered_set& walked) { std::vector ring; std::vector pending(configs.rbegin(), configs.rend()); while (!pending.empty()) { const Configuration* cfg = pending.back(); pending.pop_back(); if (!cfg || !walked.insert(cfg).second) continue; if (LoadedProjectOf(*cfg)) { ring.push_back(cfg); continue; } for (auto dep = cfg->dependencies.rbegin(); dep != cfg->dependencies.rend(); ++dep) pending.push_back(*dep); } return ring; } // The AGENTS.consumer.md of every project `project` depends on, as // project.dependencyNotes selects them, ring by ring: first the loaded // projects (the ones GitProject and LocalProject returned) that its // `#pragma crafter dependency` lines and its configurations name, in // that order, then the ones those depend on. Its own configurations, and // a dependency's internal ones, are only walked through. Direct stops // after the first ring. // // Never walked: the project's tests, since a plain build never touches // them and a test's dependency can point at the Configuration the project // file returned by value, which has since moved; and a dependency's own // pragma tooling, which is about how the dependency is built, not how it // is used, and is no `dependencies` edge to begin with. std::vector CollectNotes(const Configuration& project, const fs::path& projectFile, const fs::path& projectDir) { std::unordered_set walked = { &project }; // The project's own notes are for its consumers, so they never go // into its own AGENTS.md, whatever dependency they sit beside. Both // places count for a symlinked project file. std::unordered_set files = { projectDir / NotesFileName }; std::error_code ec; if (const fs::path canonical = fs::canonical(projectFile, ec); !ec) files.insert(canonical.parent_path() / NotesFileName); std::unordered_set identities; std::vector notes; std::vector start; for (const Configuration* dep : PragmaDependencies(projectFile)) start.push_back(dep); start.insert(start.end(), project.dependencies.begin(), project.dependencies.end()); for (std::vector ring = NextRing(start, walked); !ring.empty();) { std::vector next; for (const Configuration* cfg : ring) { const std::optional loaded = LoadedProjectOf(*cfg); const fs::path file = loaded->projectFile.parent_path() / NotesFileName; // One library under two specs (a pin here, another revision // further down) is described once, as the ring nearest the // project has it. if (identities.insert(loaded->identity).second && files.insert(file).second) { if (std::optional note = ReadNote(file, loaded->name)) notes.push_back(std::move(*note)); } next.insert(next.end(), cfg->dependencies.begin(), cfg->dependencies.end()); } if (project.dependencyNotes == DependencyNotes::Direct) break; ring = NextRing(next, walked); } return notes; } // Whether a build may replace `agentsMd`, the AGENTS.md beside a // template: one a build wrote, or one with no text at all, which is what // a write cut short leaves of a new file. Anything else someone wrote, // and beside a template that is easily the case: an AGENTS.md committed // before the template was, or one an agent kept its own notes in. bool Replaceable(std::string_view agentsMd) { if (agentsMd.find_first_not_of(" \t\r\n") == std::string_view::npos) return true; std::string_view first = agentsMd.substr(0, agentsMd.find('\n')); if (first.ends_with('\r')) first.remove_suffix(1); return first == GeneratedLine; } // An AGENTS.md with no template beside it is someone's own, and no build // writes it. One that still holds a section from the builds that kept the // notes in a committed AGENTS.md would go stale without a word, under a // preamble saying every build rewrites it, so each build says so until // the file has moved to a template or the section is gone. void WarnStaleSection(const fs::path& agents) { std::error_code ec; if (!fs::is_regular_file(agents, ec)) return; std::string text; if (!ReadFile(agents, text).empty()) return; const MarkerScan scan = ScanMarkers(text); if (scan.begins.empty() && scan.ends.empty()) return; Warn(std::format("{} has a dependency notes section, which builds no longer update; rename the file to {} and keep {} out of version control to have every build write it with current notes, or delete the section", agents.string(), TemplateFileName, AgentsFileName)); } // Bring `fd`, which holds `current`, to `wanted`: from the first byte the // two differ at, so the text in front of the section is never rewritten, // then cut to length and synced. Empty on success, otherwise the error. // // In place rather than through a temporary renamed over the file: a // rename swaps in another file, which drops whatever belongs to the old // one (a hard link such as a CLAUDE.md beside it, ACLs, ownership, being // read-only) and needs fsyncs to be crash safe at all. The price is that // a reader can catch the file half written, and that a write failing // partway, on a full disk say, leaves it so until the next build writes // it again. std::string WriteChanged(std::int32_t fd, std::string_view current, std::string_view wanted) { const auto diverge = std::ranges::mismatch(current, wanted); for (auto done = static_cast(diverge.in2 - wanted.begin()); done < wanted.size();) { const auto wrote = ::pwrite(fd, wanted.data() + done, wanted.size() - done, static_cast(done)); if (wrote > 0) { done += static_cast(wrote); } else if (wrote == 0) { return "the write made no progress"; } else if (errno != EINTR) { return ErrnoMessage(); } } if (::ftruncate(fd, static_cast(wanted.size())) != 0) return ErrnoMessage(); if (::fsync(fd) != 0) return ErrnoMessage(); return ""; } } std::string Crafter::MergeDependencyNotes(std::string_view templateMd, std::span notes) { for (const DependencyNote& note : notes) { if (const std::string problem = NoteProblem(note.text, 1); !problem.empty()) throw std::runtime_error(std::format("the notes from {}: {}", note.name, problem)); } const MarkerScan scan = ScanMarkers(templateMd); const bool section = scan.begins.size() == 1 && scan.ends.size() == 1 && scan.begins[0] < scan.ends[0]; const std::string_view eol = LineEnd(templateMd); if (notes.empty()) { // With nothing to place, nothing can land in the wrong place, so // anything short of a well-formed section is left as it was written. if (!section) return std::string(templateMd); std::string_view before = templateMd.substr(0, scan.lines[scan.begins[0]].begin); const std::string_view after = templateMd.substr(scan.lines[scan.ends[0]].next); // A section that ends the template takes the blank line in front of // it, which an appended one gets. One placed elsewhere keeps the text // around it apart. if (after.empty() && before.ends_with(std::format("{0}{0}", eol))) before.remove_suffix(eol.size()); return std::format("{}{}", before, after); } if (scan.begins.size() > 1) throw std::runtime_error(std::format("line {}: a second dependency notes begin marker (the first is on line {})", scan.begins[1] + 1, scan.begins[0] + 1)); if (scan.ends.size() > 1) throw std::runtime_error(std::format("line {}: a second dependency notes end marker (the first is on line {})", scan.ends[1] + 1, scan.ends[0] + 1)); if (!scan.ends.empty() && (scan.begins.empty() || scan.ends[0] < scan.begins[0])) throw std::runtime_error(std::format("line {}: a dependency notes end marker with no begin marker before it", scan.ends[0] + 1)); if (!scan.begins.empty() && scan.ends.empty()) throw std::runtime_error(std::format("line {}: a dependency notes begin marker with no end marker after it", scan.begins[0] + 1)); const std::string rendered = RenderSection(notes, eol); if (section) return std::format("{}{}{}", templateMd.substr(0, scan.lines[scan.begins[0]].begin), rendered, templateMd.substr(scan.lines[scan.ends[0]].next)); // Appended, the section would sit inside that block and read as code. if (scan.unclosedBlock) throw std::runtime_error(std::format("line {}: a code block opens here and never closes", *scan.unclosedBlock + 1)); if (templateMd.empty()) return rendered; // On a line of its own, after a blank one. return std::format("{}{}{}{}", templateMd, templateMd.ends_with('\n') ? std::string_view() : eol, eol, rendered); } bool Crafter::UpdateAgentsMd(const Configuration& project, const fs::path& projectFile) { std::error_code ec; // Beside the project file as it was named, so a symlinked project.cpp // keeps the AGENTS.md beside the link. const fs::path projectDir = fs::absolute(projectFile, ec).lexically_normal().parent_path(); if (ec) return false; const fs::path agents = projectDir / AgentsFileName; const fs::path templateFile = projectDir / TemplateFileName; if (!fs::is_regular_file(templateFile, ec)) { WarnStaleSection(agents); return false; } // Linked to the template, AGENTS.md would have every build write the // file that is committed, which is what the template is there to stop. if (fs::equivalent(agents, templateFile, ec)) { Warn(std::format("{} and {} are the same file, so {} was not written", agents.string(), templateFile.string(), AgentsFileName)); return false; } std::string templateMd; if (const std::string error = ReadFile(templateFile, templateMd); !error.empty()) { Warn(std::format("{} could not be read ({}), so {} was not written", templateFile.string(), error, agents.string())); return false; } const std::vector notes = project.dependencyNotes == DependencyNotes::None ? std::vector() : CollectNotes(project, projectFile, projectDir); std::string wanted; try { wanted = std::format("{}{}{}", GeneratedLine, LineEnd(templateMd), MergeDependencyNotes(templateMd, notes)); } catch (const std::exception& e) { Warn(std::format("{}: {}, so {} was not written", templateFile.string(), e.what(), agents.string())); return false; } // Created when missing, since a fresh clone has no build output, and only // now, with the text in hand, so a template the notes cannot be placed // in leaves no empty AGENTS.md behind. Locked from the read through the // write, so two builds never interleave their writes, and on the file // itself, which also covers two projects that link one AGENTS.md. A // read-only descriptor takes the lock as well, so a read-only AGENTS.md // is still read without complaint. const Descriptor reading(::open(agents.c_str(), O_RDONLY | O_CREAT | O_CLOEXEC, 0666)); if (!reading.Valid()) { Warn(std::format("{} could not be opened ({}), so it was not written", agents.string(), ErrnoMessage())); return false; } while (::flock(reading.Get(), LOCK_EX) != 0 && errno == EINTR) {} std::string current; if (const std::string error = ReadAll(reading.Get(), current); !error.empty()) { Warn(std::format("{} could not be read ({}), so it was not written", agents.string(), error)); return false; } if (current == wanted) return false; if (!Replaceable(current)) { Warn(std::format("{} was not written by a build, so it was not replaced by one written from {}; once the template holds what it says, delete it", agents.string(), TemplateFileName)); return false; } // Only now, with something to write: a read-only AGENTS.md is refused // here, and not on every build that leaves it as it is. const Descriptor writing(::open(agents.c_str(), O_WRONLY | O_CLOEXEC)); if (!writing.Valid()) { Warn(std::format("{} could not be opened for writing ({}), so it was not written", agents.string(), ErrnoMessage())); return false; } if (const std::string error = WriteChanged(writing.Get(), current, wanted); !error.empty()) { // Put back what was there. A full disk that refused the new bytes // usually still takes the old ones, whose space was just freed. if (WriteChanged(writing.Get(), wanted, current).empty()) { Warn(std::format("{} could not be written ({}), so it was left as it was", agents.string(), error)); } else { Warn(std::format("{} could not be written ({}) and may now be half written; the next build writes it again", agents.string(), error)); } return false; } // A file that appears beside project.cpp, or changes there, unasked, // says where it came from. if (Progress::GetVerbosity() != Progress::Verbosity::Quiet) { Progress::Clear(); if (notes.empty()) { std::println("Wrote {} from {}", agents.string(), TemplateFileName); } else { std::println("Wrote {} from {} and the notes of {} {}", agents.string(), TemplateFileName, notes.size(), notes.size() == 1 ? "dependency" : "dependencies"); } } return true; }