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

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:
Jorijn van der Graaf 2026-10-31 05:05:07 +01:00
commit cc0a6aed8c
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
29 changed files with 1238 additions and 19 deletions

View file

@ -29,6 +29,7 @@ Partitions and what they own:
- `:Test` holds `AddTest`, `RunTests` and `TestRunner`.
- `:Lint` is the lint/format driver plus the libclang token and AST layer.
- `:Script` is the driver behind `crafter-build run`: it looks a name up in `cfg.scripts`, calls the callback and turns what it returns into the exit code.
- `:Agents` keeps the dependency notes section of a project's AGENTS.md: it walks the dependencies `cfg.dependencyNotes` selects, reads each one's `AGENTS.consumer.md` and merges them between the section's markers. Which Configurations belong to another project, and where it lives, comes from the loader (`LoadedProjectOf` in `:External`, `PragmaDependencies` in `:Platform`), because only the Configuration `GitProject` and `LocalProject` return has its `path` rebased.
- `:Progress` is console output. `:Shader` compiles shaders through glslang. `:Asset` is the hook into Crafter.Asset.
## Dev environment
@ -188,3 +189,4 @@ Local builds are two scripts in `project.cpp`. Run them with the native binary:
- A warning is printed only when its unit actually recompiles. A build with nothing to do is silent, which is not evidence that a warning is gone.
- A std module source only compiles against the headers of its own libc++ release, and a sysroot does not always ship one (Alpine). The Flatpak therefore carries the sources of the release next to the bundled one under `share/crafter-build/libc++-modules/<major>/`, one `libcxx-<major>-modules` manifest module each. When the bundled LLVM is bumped, keep the release it leaves behind as such a module: sysroots on the stable distro stay on it for months. The native loop has no such directory; stage one by hand under `share/crafter-build/` to test against a foreign sysroot.
- `VariantId()` names the output directory, so CI globs `bin/crafter.build-exe-<target>-<march>-*/` rather than predicting the hash. The hash includes the compiler, so after a toolchain update the previous variant's directories stay in `bin/` and `build/` until `clean`, and such a glob matches both.
- The self-host build and `test` keep the dependency notes section of this very file. None of Crafter.Math, Crafter.Asset and Crafter.LintRules ships an `AGENTS.consumer.md` yet. Once one does, a build here can change AGENTS.md whenever that repository changes its notes, since all three are unpinned. The tree is then dirty, so the next build's `git describe --dirty` version differs, which is a new `crafter.build-lib` variant and a full rebuild. Commit the section to settle it.

View file

@ -94,6 +94,39 @@ import Crafter.LintRules;
#pragma crafter dependency "<source>" [branch "<name>" | commit "<sha>"] [project "<file>"]
```
### Dependency notes for agents
An agent working on a project reads its `AGENTS.md`, which says nothing about the libraries the project builds against unless someone copied that in by hand. A library ships it instead: an `AGENTS.consumer.md` beside its `project.cpp`, written for whoever uses the library rather than whoever works on it. When a consumer has an `AGENTS.md` beside its own `project.cpp`, `crafter-build` and `crafter-build test` keep its dependencies' notes in a section of it:
```markdown
<!-- crafter-build: dependency notes begin -->
<!-- Written by crafter-build from the AGENTS.consumer.md each dependency ships, and rewritten on every build. Edit those files, not this section; cfg.dependencyNotes in project.cpp picks which dependencies are included. -->
<!-- from Crafter.Math -->
## Crafter.Math
...
<!-- crafter-build: dependency notes end -->
```
- Each file goes in as written, under a line naming the dependency: its repository's name, or a `LocalProject`'s directory name. That reads the same on every machine, and for a `--local` checkout and its git clone alike. The file lands among the consumer's own headings, so start it with a `##` heading that names the library.
- The section is appended the first time, after a blank line, and rewritten in place after that, wherever it has been moved to. Edits inside it are lost on the next build; the text around it is left as it is. The file is written only when the section changes, in place, so hard links, ownership and a read-only `AGENTS.md` are respected, and an `AGENTS.md` is never created.
- The update runs once `project.cpp` has loaded and before the project compiles, so a build that fails still has the notes. A `project.cpp` that fails to load gets no update. `test --list`, `lint`, `format`, `run` and `clean` leave the file alone.
- The section follows what the build loaded: a dependency that only some flags pull in (a `--target=`, a flag of the project's own) comes and goes with them. An unpinned dependency's notes follow its default branch, as its code does, so the section can change on any build. Pin a `commit` to decide when.
- A library reached through two specs, pinned at one revision here and another further down, is described once, as the nearer one has it.
- When there are notes to write, unbalanced markers leave the file as it is, with a warning. A notes file that would break the section (a code block it never closes, a marker line of its own) is left out, also with a warning. Markers quoted inside a fenced code block don't count.
`cfg.dependencyNotes` picks the dependencies:
| Value | Notes from |
|---|---|
| `DependencyNotes::Transitive` (default) | everything the project builds against, all the way down: its `dependencies` and its own `#pragma crafter dependency` tooling |
| `DependencyNotes::Direct` | only the projects the project's own configurations and `#pragma crafter dependency` lines name |
| `DependencyNotes::None` | none, and a section from an earlier build is removed |
Three things never count: the project's own `AGENTS.consumer.md`; its tests' dependencies, since a plain build never looks at the tests; and a dependency's `#pragma crafter dependency` tooling, which is about how the dependency is built, not how it is used.
## Tests
Tests live under `tests/<Name>/`. The simplest case is a single C++ file:

View file

@ -48,7 +48,7 @@ cp wasi-runtime/index.html.in share/crafter-build/wasi-runtime/
# Partition order is the import order: every unit is listed before the
# units that import it, and the umbrella last.
PARTITIONS="Toolchain Shader Platform Interface Implementation External Clang Test Lint Script Progress Asset"
PARTITIONS="Toolchain Shader Platform Interface Implementation External Agents Clang Test Lint Script Progress Asset"
cp interfaces/Crafter.Build.cppm share/crafter-build/
for p in $PARTITIONS; do

View file

@ -0,0 +1,530 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include <errno.h>
#include <fcntl.h>
#include <sys/file.h>
#include <unistd.h>
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 = "<!-- crafter-build: dependency notes begin -->";
constexpr std::string_view EndMarker = "<!-- crafter-build: dependency notes end -->";
// 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 = "<!-- Written by crafter-build from the AGENTS.consumer.md each dependency ships, and rewritten on every build. Edit those files, not this section; cfg.dependencyNotes in project.cpp picks which dependencies are included. -->";
constexpr std::string_view NotesFileName = "AGENTS.consumer.md";
void Warn(std::string_view message) {
Progress::Diagnostic(std::format("warning: {}", message));
}
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<char, 16384> buffer;
for (;;) {
const auto got = ::read(fd, buffer.data(), buffer.size());
if (got > 0) {
out.append(buffer.data(), static_cast<std::size_t>(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<Line> SplitLines(std::string_view text) {
std::vector<Line> 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<Fence> 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> 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> 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<Line> lines;
std::vector<std::size_t> begins;
std::vector<std::size_t> ends;
// The line a code block that never closes opens on.
std::optional<std::size_t> 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 once the file
// is read back, and a code block left open would hide the end marker:
// either way the next build could no longer find what this one wrote.
// Lines are numbered from `firstLine`.
std::string NoteProblem(std::string_view text, std::size_t firstLine) {
const MarkerScan scan = ScanMarkers(text);
std::optional<std::size_t> 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<std::ptrdiff_t>(begin), '\n');
return { .text = text.substr(begin, last + 1 - begin), .firstLine = static_cast<std::size_t>(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<const DependencyNote> notes, std::string_view eol) {
std::string section = std::format("{}{}{}{}", BeginMarker, eol, Preamble, eol);
for (const DependencyNote& note : notes) section += std::format("{0}<!-- from {1} -->{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<DependencyNote> 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<const Configuration*> NextRing(std::span<const Configuration* const> configs, std::unordered_set<const Configuration*>& walked) {
std::vector<const Configuration*> ring;
std::vector<const Configuration*> 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<DependencyNote> CollectNotes(const Configuration& project, const fs::path& projectFile, const fs::path& projectDir) {
std::unordered_set<const Configuration*> 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<fs::path> 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<std::string> identities;
std::vector<DependencyNote> notes;
std::vector<const Configuration*> start;
for (const Configuration* dep : PragmaDependencies(projectFile)) start.push_back(dep);
start.insert(start.end(), project.dependencies.begin(), project.dependencies.end());
for (std::vector<const Configuration*> ring = NextRing(start, walked); !ring.empty();) {
std::vector<const Configuration*> next;
for (const Configuration* cfg : ring) {
const std::optional<LoadedProject> 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<DependencyNote> 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;
}
// 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 section half written, and that a write failing
// partway, on a full disk say, leaves it so. With the section last,
// where it is appended, only the section can be damaged, and the next
// build reports its markers instead of guessing.
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<std::size_t>(diverge.in2 - wanted.begin()); done < wanted.size();) {
const auto wrote = ::pwrite(fd, wanted.data() + done, wanted.size() - done, static_cast<off_t>(done));
if (wrote > 0) {
done += static_cast<std::size_t>(wrote);
} else if (wrote == 0) {
return "the write made no progress";
} else if (errno != EINTR) {
return ErrnoMessage();
}
}
if (::ftruncate(fd, static_cast<off_t>(wanted.size())) != 0) return ErrnoMessage();
if (::fsync(fd) != 0) return ErrnoMessage();
return "";
}
}
std::string Crafter::MergeDependencyNotes(std::string_view agentsMd, std::span<const DependencyNote> 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(agentsMd);
const bool section = scan.begins.size() == 1 && scan.ends.size() == 1 && scan.begins[0] < scan.ends[0];
// The file's own line ending, so a CRLF file does not end up mixed.
const std::string_view eol = agentsMd.contains("\r\n") ? "\r\n" : "\n";
if (notes.empty()) {
// With nothing to write, leaving the file alone loses nothing, so
// anything short of a well-formed section is no concern of this one.
if (!section) return std::string(agentsMd);
std::string_view before = agentsMd.substr(0, scan.lines[scan.begins[0]].begin);
const std::string_view after = agentsMd.substr(scan.lines[scan.ends[0]].next);
// The blank line a section at the end was appended after goes with
// it. One the user moved 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("{}{}{}", agentsMd.substr(0, scan.lines[scan.begins[0]].begin), rendered, agentsMd.substr(scan.lines[scan.ends[0]].next));
// Appended, the section would sit inside that block and never be found
// again, so every build would add another.
if (scan.unclosedBlock) throw std::runtime_error(std::format("line {}: a code block opens here and never closes", *scan.unclosedBlock + 1));
if (agentsMd.empty()) return rendered;
// On a line of its own, after a blank one.
return std::format("{}{}{}{}", agentsMd, agentsMd.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 / "AGENTS.md";
if (!fs::is_regular_file(agents, ec)) return false;
const std::vector<DependencyNote> notes = project.dependencyNotes == DependencyNotes::None ? std::vector<DependencyNote>() : CollectNotes(project, projectFile, projectDir);
// 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_CLOEXEC));
if (!reading.Valid()) {
Warn(std::format("{} could not be read ({}), so its dependency notes were not updated", 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 its dependency notes were not updated", agents.string(), error));
return false;
}
std::string merged;
try {
merged = MergeDependencyNotes(current, notes);
} catch (const std::exception& e) {
Warn(std::format("{}: {}, so its dependency notes were not updated", agents.string(), e.what()));
return false;
}
if (merged == current) 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 its dependency notes were not updated", agents.string(), ErrnoMessage()));
return false;
}
if (const std::string error = WriteChanged(writing.Get(), current, merged); !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(), merged, current).empty()) {
Warn(std::format("{} could not be written ({}), so its dependency notes were not updated", agents.string(), error));
} else {
Warn(std::format("{} could not be written ({}) and may now end in part of the section; the next build reports it if so", agents.string(), error));
}
return false;
}
// A build that edits a file the user commits says so.
if (Progress::GetVerbosity() != Progress::Verbosity::Quiet) {
Progress::Clear();
if (notes.empty()) {
std::println("Removed the dependency notes from {}", agents.string());
} else {
std::println("Updated the dependency notes in {} ({} {})", agents.string(), notes.size(), notes.size() == 1 ? "dependency" : "dependencies");
}
}
return true;
}

View file

@ -13,6 +13,7 @@ import :Toolchain;
import :Test;
import :Lint;
import :Script;
import :Agents;
import :Progress;
import :Asset;
namespace fs = std::filesystem;
@ -1710,6 +1711,13 @@ Clean (after the `clean` subcommand):
so it works when the project itself no longer compiles. Every target, variant
and dependency artifact under those trees goes with it.
AGENTS.md:
When an AGENTS.md sits beside the project file, a build and a test run keep a
section in it holding the AGENTS.consumer.md each dependency ships beside its
project.cpp, and write the file only when that section changes. An AGENTS.md
is never created. cfg.dependencyNotes picks the dependencies: Transitive (the
default), Direct, or None, which removes the section.
Project args:
Any flag not consumed above is forwarded verbatim to CrafterBuildProject as
part of its `args` span. Project-specific flags (e.g. --target=, custom
@ -1878,6 +1886,14 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) {
return lintOpts.mode == LintMode::Check && !lintSummary.changedFiles.empty() ? 1 : 0;
}
// Builds and test runs only: lint and `format --check` promise to
// write nothing, `format` rewrites the project's sources and nothing
// else, `run` is for chores and `test --list` only lists. Here,
// before compiling, because loading has just fetched every
// dependency, so the notes are current, and an agent fixing a build
// that fails needs them.
if (!testOpts.listOnly) UpdateAgentsMd(config, projectFile);
if (runTests) {
TestSummary summary = RunTests(config, testOpts, projectArgs);
return summary.AllPassed() ? 0 : 1;

View file

@ -324,6 +324,10 @@ ExternalBuildResult Crafter::ExternalOutputs(const ExternalDependency& dep, std:
namespace {
std::mutex ProjectCacheMutex;
std::unordered_map<std::string, std::unique_ptr<Configuration>> ProjectCache;
// Keyed by the Configurations ProjectCache owns, which live as long as
// the process does, so the pointers never dangle. Guarded by
// ProjectCacheMutex as well.
std::unordered_map<const Configuration*, LoadedProject> LoadedProjects;
// Run the dep's project.cpp from its own directory so its relative paths
// (cfg.path = "./", interfaces/, lib/, ...) resolve against the dep
@ -353,6 +357,36 @@ namespace {
for (Shader& s : cfg.shaders) s.path = abs(s.path);
return cfg;
}
// Into ProjectCache under `fullHash`, with `loaded` recorded beside it.
// When another thread stored the same spec first, its Configuration
// wins and `cfg` is dropped.
Configuration* StoreProject(std::string fullHash, Configuration cfg, LoadedProject loaded) {
std::lock_guard lock(ProjectCacheMutex);
if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) {
return it->second.get();
}
auto stored = std::make_unique<Configuration>(std::move(cfg));
Configuration* ptr = stored.get();
ProjectCache.emplace(std::move(fullHash), std::move(stored));
LoadedProjects.emplace(ptr, std::move(loaded));
return ptr;
}
// `url` as it names a repository: without user info, which is where a
// token goes, and without trailing slashes or ".git", so specs that only
// spell the repository differently name the same one.
std::string RepositoryKey(std::string_view url) {
std::string key(url);
if (const std::size_t scheme = key.find("://"); scheme != std::string::npos) {
const std::size_t host = scheme + 3;
const std::size_t at = key.rfind('@', key.find('/', host));
if (at != std::string::npos && at >= host) key.erase(host, at + 1 - host);
}
while (key.ends_with('/')) key.pop_back();
if (key.ends_with(".git")) key.resize(key.size() - 4);
return key;
}
}
Configuration* Crafter::GitProject(const GitProjectSpec& spec) {
@ -405,14 +439,13 @@ Configuration* Crafter::GitProject(const GitProjectSpec& spec) {
std::vector<std::string_view> argViews(spec.args.begin(), spec.args.end());
Configuration cfg = LoadProjectFromRoot(projectPath, cloneDir, argViews);
std::lock_guard lock(ProjectCacheMutex);
if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) {
return it->second.get();
}
auto stored = std::make_unique<Configuration>(std::move(cfg));
Configuration* ptr = stored.get();
ProjectCache.emplace(std::move(fullHash), std::move(stored));
return ptr;
const fs::path projectInRepo = spec.projectFile.lexically_normal();
const fs::path below = projectInRepo.parent_path();
return StoreProject(std::move(fullHash), std::move(cfg), {
.projectFile = projectPath.lexically_normal(),
.name = below.empty() ? name : std::format("{}/{}", name, below.generic_string()),
.identity = std::format("git|{}|{}", RepositoryKey(spec.source.url), projectInRepo.generic_string()),
});
}
fs::path Crafter::GitFetch(const GitSource& source) {
@ -467,12 +500,15 @@ Configuration* Crafter::LocalProject(const LocalProjectSpec& spec) {
std::vector<std::string_view> argViews(spec.args.begin(), spec.args.end());
Configuration cfg = LoadProjectFromRoot(canonical, depRoot, argViews);
return StoreProject(std::move(fullHash), std::move(cfg), {
.projectFile = canonical,
.name = depRoot.filename().string(),
.identity = std::format("local|{}", canonical.string()),
});
}
std::optional<LoadedProject> Crafter::LoadedProjectOf(const Configuration& cfg) {
std::lock_guard lock(ProjectCacheMutex);
if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) {
return it->second.get();
}
auto stored = std::make_unique<Configuration>(std::move(cfg));
Configuration* ptr = stored.get();
ProjectCache.emplace(std::move(fullHash), std::move(stored));
return ptr;
if (auto it = LoadedProjects.find(&cfg); it != LoadedProjects.end()) return it->second;
return std::nullopt;
}

View file

@ -422,6 +422,13 @@ namespace {
// the stack is per thread. ProjectDependency() reads the top frame.
thread_local std::vector<ProjectFileFrame> LoadingFrames;
// A frame's dependencies, kept past the frame for PragmaDependencies().
// Process-wide rather than per thread: whoever asks need not be the
// thread that loaded the file. The Configurations live in the project
// cache, which outlives every load.
std::mutex PragmaDependenciesMutex;
std::unordered_map<fs::path, std::vector<Configuration*>> PragmaDependenciesByProject;
// Held for the whole of one LoadProject call. Pushing a file that is
// already on the stack is a cycle, which used to recurse forever.
class ProjectFileGuard {
@ -580,6 +587,15 @@ bool Crafter::IsTopLevelProject() {
return LoadingFrames.size() <= 1;
}
std::vector<Configuration*> Crafter::PragmaDependencies(const fs::path& projectFile) {
std::error_code ec;
const fs::path absProject = fs::canonical(projectFile, ec);
if (ec) return {};
std::lock_guard lock(PragmaDependenciesMutex);
if (auto it = PragmaDependenciesByProject.find(absProject); it != PragmaDependenciesByProject.end()) return it->second;
return {};
}
namespace {
// The one reading of a wait status (pclose, system) for every helper
// that runs a command: a signal death is reported the way a shell
@ -823,13 +839,14 @@ std::string Crafter::GetBaseCommand(const Configuration& config) {
}
namespace {
constexpr std::array<std::string_view, 13> CrafterBuildModules = {
constexpr std::array<std::string_view, 14> CrafterBuildModules = {
"Crafter.Build-Toolchain",
"Crafter.Build-Shader",
"Crafter.Build-Platform",
"Crafter.Build-Interface",
"Crafter.Build-Implementation",
"Crafter.Build-External",
"Crafter.Build-Agents",
"Crafter.Build-Clang",
"Crafter.Build-Test",
"Crafter.Build-Lint",
@ -966,6 +983,10 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
throw std::runtime_error(std::format("CrafterBuildProject not found in {}: {}", soPath.string(), err));
}
{
std::lock_guard lock(PragmaDependenciesMutex);
PragmaDependenciesByProject[absProject] = preDeps.configs;
}
guard.Frame().dependencies = std::move(preDeps.configs);
return fn(args);
}

View file

@ -0,0 +1,72 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Build:Agents;
import std;
namespace fs = std::filesystem;
// Dependency notes. A crafter-build project ships an AGENTS.consumer.md beside
// its project.cpp, written for the agents of the projects that depend on it,
// and crafter-build keeps those files in each consumer's AGENTS.md. Without
// that, an agent working on a consumer knows nothing about the libraries it
// builds against unless someone copied the knowledge in by hand, and a copy
// goes stale the moment the library changes.
export namespace Crafter {
struct Configuration;
// Which dependencies' notes a project's AGENTS.md carries: the value of
// Configuration::dependencyNotes.
enum class DependencyNotes {
// The projects this one builds against, and the projects those build
// against, all the way down.
Transitive,
// Only the projects this one's own configurations and
// `#pragma crafter dependency` lines name.
Direct,
// None. A section an earlier build wrote is removed.
None,
};
// One dependency's AGENTS.consumer.md, ready to merge.
struct DependencyNote {
// What the comment above the note calls the dependency: its
// repository's name, or a local project's directory name.
std::string name;
// The file's content, without leading blank lines or trailing
// whitespace. Lines end in LF; the merge writes the line ending the
// document uses.
std::string text;
};
// `agentsMd` with its dependency notes section holding `notes`, in order.
// A document without a section gets one appended after a blank line; one
// with a section has it rewritten in place, wherever it was moved to; and
// empty `notes` remove it. Removing a section that ends the document takes
// the blank line in front of it too, so a document that ended in a newline
// before it gained the section gets its own bytes back. The section uses
// the document's line ending, so a CRLF file stays CRLF.
//
// The two marker lines count only as whole lines outside fenced code
// blocks, which is what lets a document quote them. Throws, naming the
// line, when there are notes to write and the markers are unbalanced, or
// when the section would have to be appended to a document that ends
// inside a code block: guessing which part of someone's file is generated
// would overwrite their text, and missing the section would add another on
// every build. With no notes it never throws, since leaving the document
// alone loses nothing. Also throws for a note that would break the section
// when it is read back: one with a marker line of its own, or a code block
// it never closes.
std::string MergeDependencyNotes(std::string_view agentsMd, std::span<const DependencyNote> notes);
// Refresh the dependency notes section of the AGENTS.md beside
// `projectFile` from the AGENTS.consumer.md of every dependency
// project.dependencyNotes selects. `project` is the Configuration that
// file returned, after LoadProject: loading is what found the
// dependencies. Does nothing when there is no AGENTS.md, which is never
// created. Writes only when the text changes, in place, so hard links,
// ownership and a read-only AGENTS.md are respected; returns whether it
// wrote. A read or write that fails, a section that cannot be placed and
// a dependency's unusable notes are warnings: the notes never fail a
// build, and nothing is reported when nothing would have been written.
bool UpdateAgentsMd(const Configuration& project, const fs::path& projectFile);
}

View file

@ -8,6 +8,7 @@ import :Interface;
import :Implementation;
import :External;
import :Toolchain;
import :Agents;
namespace fs = std::filesystem;
export namespace Crafter {
@ -494,6 +495,14 @@ export namespace Crafter {
// }});
// Not part of VariantId: a script changes nothing that is compiled.
std::vector<Script> scripts;
// Which dependencies' AGENTS.consumer.md `crafter-build` and
// `crafter-build test` keep in the AGENTS.md beside project.cpp, when
// there is one (see Crafter.Build:Agents):
// cfg.dependencyNotes = DependencyNotes::Direct;
// Only read on the Configuration project.cpp returns; a dependency's
// setting decides nothing for its consumers. Not part of VariantId:
// the notes change nothing that is compiled.
DependencyNotes dependencyNotes = DependencyNotes::Transitive;
void GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations);
// Retry the `import X;` names GetInterfacesAndImplementations could not
// place, against the dependency DAG as it stands now. Sources are

View file

@ -114,3 +114,30 @@ export namespace Crafter {
// SHA for reproducible, offline-after-first-fetch builds.
fs::path GitFetch(const GitSource& source);
}
namespace Crafter {
// Where GitProject or LocalProject loaded a Configuration from. The
// loader keeps this beside the cache that owns the Configuration, since
// the Configuration itself cannot say: only the one they return has its
// path rebased, and the configurations a dependency's project file makes
// for itself keep a path such as "./", which resolves against the
// consumer's directory.
struct LoadedProject {
fs::path projectFile; // absolute
// What a consumer's AGENTS.md calls it: the repository's name for a
// git project, with the path below the clone's root for one that is
// not at it, and the directory's name for a local one. The same on
// every machine and for a --local checkout and its clone alike,
// which a path into the cache or a sibling checkout is not, and free
// of what a URL can carry, credentials included.
std::string name;
// Which library this is, whatever the revision: the repository and
// the project file in it for git, the project file for a local one.
// Two specs that pin one library differently are one library.
std::string identity;
};
// The LoadedProject behind a Configuration that GitProject or
// LocalProject returned; nullopt for any other: the project crafter-build
// was pointed at, and every configuration a project file makes itself.
std::optional<LoadedProject> LoadedProjectOf(const Configuration& cfg);
}

View file

@ -75,6 +75,13 @@ namespace Crafter {
// that library as a dependency, the test's GitProject loads crafter-build's
// project file in turn and the two recurse until the cycle guard throws.
export bool IsTopLevelProject();
// What the `#pragma crafter dependency` lines of `projectFile` resolved
// to the last time LoadProject loaded it in this process, in file order;
// empty when it has none or was never loaded. Unlike ProjectDependency
// this still answers once LoadProject has returned, which is when the
// dependency notes of the project crafter-build was pointed at are
// gathered (Crafter.Build:Agents).
std::vector<Configuration*> PragmaDependencies(const fs::path& projectFile);
// Resolves the directory holding distributed runtime assets (Crafter.Build
// module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise
// derives <prefix>/share/crafter-build from the running executable's path.

View file

@ -8,6 +8,7 @@ export import :Platform;
export import :Implementation;
export import :Shader;
export import :External;
export import :Agents;
export import :Test;
export import :Lint;
export import :Script;

View file

@ -179,7 +179,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// it reaches clang, which must still see a string literal.
CrafterBuildLib->defines.push_back({"CRAFTER_BUILD_VERSION", std::format("\\\"{}\\\"", version)});
{
std::array<fs::path, 13> interfaces = {
std::array<fs::path, 14> interfaces = {
"interfaces/Crafter.Build",
"interfaces/Crafter.Build-Toolchain",
"interfaces/Crafter.Build-Shader",
@ -187,6 +187,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"interfaces/Crafter.Build-Interface",
"interfaces/Crafter.Build-Implementation",
"interfaces/Crafter.Build-External",
"interfaces/Crafter.Build-Agents",
"interfaces/Crafter.Build-Clang",
"interfaces/Crafter.Build-Test",
"interfaces/Crafter.Build-Lint",
@ -194,13 +195,14 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"interfaces/Crafter.Build-Progress",
"interfaces/Crafter.Build-Asset",
};
std::array<fs::path, 12> implementations = {
std::array<fs::path, 13> implementations = {
"implementations/Crafter.Build-Toolchain",
"implementations/Crafter.Build-Shader",
"implementations/Crafter.Build-Platform",
"implementations/Crafter.Build-Interface",
"implementations/Crafter.Build-Implementation",
"implementations/Crafter.Build-External",
"implementations/Crafter.Build-Agents",
"implementations/Crafter.Build-Clang",
"implementations/Crafter.Build-Test",
"implementations/Crafter.Build-Lint",
@ -289,6 +291,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> 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() });
}

View file

@ -0,0 +1,4 @@
## app
Notes from app for its own consumers. They must never appear in app's own
AGENTS.md.

View file

@ -0,0 +1,4 @@
# app
The consumer's own instructions. Nothing in this file may change, except the
section crafter-build appends.

View 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;
}

View 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;
}

View 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;
}

View file

@ -0,0 +1,6 @@
## base
base counts from zero.

View 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;
}

View file

@ -0,0 +1,3 @@
## lib
Call lib through its module, never through its headers.

View 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;
}

View file

@ -0,0 +1,3 @@
## libtool
How lib is built. This must never reach app.

View 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;
}

View file

@ -0,0 +1,3 @@
## testonly
Only a test uses this. It must never reach app.

View 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;
}

View file

@ -0,0 +1,3 @@
## tool
Run tool before committing.

View 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;
}

View 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;
}