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

@ -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);
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;
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 = 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);
}