Compare commits

...
Author SHA1 Message Date
7678286f0d
Merge branch 'claude/agents-template': AGENTS.md written from a tracked AGENTS.template.md
All checks were successful
Flatpak / flatpak (push) Successful in 11m17s
Brings in the template: a project commits AGENTS.template.md, and
`crafter-build` and `crafter-build test` write a gitignored AGENTS.md
from it with the dependency notes merged in, so a change to a
dependency's notes no longer shows up in the consumer's diff. An
AGENTS.md no build wrote is never replaced, and without a template
AGENTS.md is left alone, with a warning when it still holds a 3.0.0
section. This repository moves its own AGENTS.md to the template.

The branch starts at master's tip, so the merge is a tree identical to
the branch's; that tree passes format --check, lint and the test suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:56:27 +01:00
5c3e9a874b
docs: keep this repository's agent notes in AGENTS.template.md
This repository now uses the template itself. AGENTS.md is written by
the self-host build and `test` and is gitignored, so the notes
Crafter.Math ships reach the agents working here, and a change to them
no longer leaves a tracked file modified, which also made every build
afterwards a dirty-tree version, a new crafter.build-lib variant and a
full rebuild.

The gotcha that described the old arrangement now says where to edit,
that a fresh clone or worktree has no AGENTS.md until its first build
(./build.sh does not write it), and what an older harness does to the
generated file. The paths table and the never-commit list name both
files. The CI error for a missing signing key and a manifest comment
point at the template, since the forge no longer shows an AGENTS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:56:11 +01:00
2a5f541485
docs: AGENTS.template.md in the README
The dependency notes section now starts from the template: what to
commit and what to ignore, the two commands that move a project with a
committed AGENTS.md over, what a build writes, where the section goes,
and what becomes of an AGENTS.md no build wrote and of one that still
holds a section from 3.0.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:53:42 +01:00
74e8370b2b
feat(agents): write AGENTS.md from a tracked AGENTS.template.md
The dependency notes were kept in a section of the AGENTS.md beside
project.cpp, the file a project commits, so a build could change a
tracked file at any time: a dependency editing its AGENTS.consumer.md,
an unpinned one moving on, a flag pulling a dependency in or out, each
showed up in the consumer's diff as a change nobody made. Users
reported it as the feature clobbering their diffs.

A project now commits its instructions as AGENTS.template.md, and a
build and a test run write AGENTS.md from it: a first line saying the
file is generated and where to edit, the template's text as written,
then the notes section, after it or where the template's marker lines
put it. AGENTS.md is build output and goes into .gitignore. It is
created when missing, since a fresh clone has none, and otherwise
written as before: only when its text changes, in place and under a
lock, so links, ownership and a read-only file are respected.

The first line is also how a build tells its own AGENTS.md from one
someone wrote. Beside a template the second is easily there, as a
committed AGENTS.md nobody has removed yet; it is never replaced, and
each build warns until it is deleted. An empty file counts as a
build's, since that is what a write cut short leaves of a new one. An
AGENTS.md linked to the template is refused: writing it would write
the committed file again. A template the notes cannot be placed in
leaves no empty AGENTS.md behind.

The in-place mode is removed rather than kept as the fallback for a
project without a template. It is the default that put generated text
into tracked files unasked, the moment any dependency started shipping
notes. Without a template a build no longer touches AGENTS.md. One
that still holds a section from 3.0.0 would go stale silently, under a
preamble promising that every build rewrites it, so it gets a warning
naming the move instead.

MergeDependencyNotes keeps its rules and now runs on the template. The
test changes in this commit rather than its own, because it pinned the
in-place behaviour this removes: it now covers the generated file's
shape, edits and markers in the template, the ownership check, a CRLF
template, a link to the template and the cases without one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:53:42 +01:00
12 changed files with 285 additions and 128 deletions

View file

@ -140,7 +140,7 @@ jobs:
# (/etc/crafter-flatpak: signing-key.asc and key-id) and mounted # (/etc/crafter-flatpak: signing-key.asc and key-id) and mounted
# read-only; it is imported into a throwaway keyring per run. # read-only; it is imported into a throwaway keyring per run.
if [ ! -s /run/crafter-flatpak/signing-key.asc ] || [ ! -s /run/crafter-flatpak/key-id ]; then if [ ! -s /run/crafter-flatpak/signing-key.asc ] || [ ! -s /run/crafter-flatpak/key-id ]; then
echo "no signing key under /etc/crafter-flatpak on the runner host; generate it as described in AGENTS.md (CI and publishing)" >&2 echo "no signing key under /etc/crafter-flatpak on the runner host; generate it as described in AGENTS.template.md (CI and publishing)" >&2
exit 1 exit 1
fi fi
export GNUPGHOME=$(mktemp -d) export GNUPGHOME=$(mktemp -d)

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ share/crafter-build/
.claude .claude
# written by CI next to the manifest so relative source paths still resolve # written by CI next to the manifest so relative source paths still resolve
packaging/flatpak/ci-manifest.yaml packaging/flatpak/ci-manifest.yaml
# written by every build and test run from AGENTS.template.md
/AGENTS.md

View file

@ -11,6 +11,7 @@ crafter-build is a C++26 modules build system. A project is described by a `proj
| `interfaces/Crafter.Build-<Partition>.cppm` | Module interface partitions. `Crafter.Build.cppm` is the umbrella that re-exports them. | | `interfaces/Crafter.Build-<Partition>.cppm` | Module interface partitions. `Crafter.Build.cppm` is the umbrella that re-exports them. |
| `implementations/Crafter.Build-<Partition>.cpp` | Implementation units, one per partition, named `Crafter.Build:<Partition>_impl`. `main.cpp` is a thin wrapper around `Crafter::Run`. | | `implementations/Crafter.Build-<Partition>.cpp` | Implementation units, one per partition, named `Crafter.Build:<Partition>_impl`. `main.cpp` is a thin wrapper around `Crafter::Run`. |
| `project.cpp` | The self-host build description: a `crafter.build-lib` library, a `crafter.build-exe` executable, the test declarations, the lint rules and the `flatpak` and `install` scripts. | | `project.cpp` | The self-host build description: a `crafter.build-lib` library, a `crafter.build-exe` executable, the test declarations, the lint rules and the `flatpak` and `install` scripts. |
| `AGENTS.template.md` | The source of AGENTS.md. Every build and test run writes AGENTS.md from it, with the notes the dependencies ship; AGENTS.md is gitignored, so changes go here. |
| `tests/<Name>/main.cpp` | One self-test per directory; `tests/<Name>/fixture/` holds the sample project it builds. | | `tests/<Name>/main.cpp` | One self-test per directory; `tests/<Name>/fixture/` holds the sample project it builds. |
| `examples/` | Standalone user-facing projects, MIT licensed. | | `examples/` | Standalone user-facing projects, MIT licensed. |
| `build.sh` | Bootstrap script. It compiles everything by hand, without crafter-build. | | `build.sh` | Bootstrap script. It compiles everything by hand, without crafter-build. |
@ -29,7 +30,7 @@ Partitions and what they own:
- `:Test` holds `AddTest`, `RunTests` and `TestRunner`. - `:Test` holds `AddTest`, `RunTests` and `TestRunner`.
- `:Lint` is the lint/format driver plus the libclang token and AST layer. - `: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. - `: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. - `:Agents` writes a project's AGENTS.md from its AGENTS.template.md: it walks the dependencies `cfg.dependencyNotes` selects, reads each one's `AGENTS.consumer.md` and merges them into the template's dependency notes section. 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. - `:Progress` is console output. `:Shader` compiles shaders through glslang. `:Asset` is the hook into Crafter.Asset.
## Dev environment ## Dev environment
@ -141,7 +142,7 @@ These were decided deliberately. Do not undo them without the maintainer's say-s
- Separate commits for the fix, its test and the README change when each stands on its own. - Separate commits for the fix, its test and the README change when each stands on its own.
- Update the README in the same PR when user-visible behaviour changes. - Update the README in the same PR when user-visible behaviour changes.
- `format`, `lint` and `test` green before pushing. CI builds the Flatpak and runs the suite inside it on PRs, publishes the `beta` branch of the Catcrafts repo on every push to `master`, and the `stable` branch plus a release on an `X.Y.Z` tag. - `format`, `lint` and `test` green before pushing. CI builds the Flatpak and runs the suite inside it on PRs, publishes the `beta` branch of the Catcrafts repo on every push to `master`, and the `stable` branch plus a release on an `X.Y.Z` tag.
- Never commit `bin/`, `build/`, `share/crafter-build/`, `lib/*.a` or fixture build outputs (all gitignored), and never edit the vendored files in `lib/`. - Never commit `bin/`, `build/`, `share/crafter-build/`, `lib/*.a`, `AGENTS.md` or fixture build outputs (all gitignored), and never edit the vendored files in `lib/`.
- Keep `CRAFTER_BUILD_MARCH=x86-64-v2` in the manifest for the shipped binary: it has to run on any x86_64 machine. User projects still default to `native`. - Keep `CRAFTER_BUILD_MARCH=x86-64-v2` in the manifest for the shipped binary: it has to run on any x86_64 machine. User projects still default to `native`.
## Security considerations ## Security considerations
@ -194,4 +195,4 @@ Local builds are two scripts in `project.cpp`. Run them with the native binary:
- The wasm compile flags live in one place, `WasmBaselineFlags` in `Crafter.Build-Clang.cppm`: the std module and every unit have to agree on them, and `VariantId()` and the std PCM directory hash them, so a change rebuilds instead of mixing old and new BMIs. They enable C++ exceptions, so the link takes the exception-enabled libc++ and libunwind from the sysroot's `lib/<triple>/eh/` (wasi-sdk 33 and later). The Flatpak's sysroot has them and a distro's may not; where they are missing the wasm tests skip, so run those through the Flatpak. A browser page checks the engine with the `WasmBaselineProbes` named in `variants.json`. - The wasm compile flags live in one place, `WasmBaselineFlags` in `Crafter.Build-Clang.cppm`: the std module and every unit have to agree on them, and `VariantId()` and the std PCM directory hash them, so a change rebuilds instead of mixing old and new BMIs. They enable C++ exceptions, so the link takes the exception-enabled libc++ and libunwind from the sysroot's `lib/<triple>/eh/` (wasi-sdk 33 and later). The Flatpak's sysroot has them and a distro's may not; where they are missing the wasm tests skip, so run those through the Flatpak. A browser page checks the engine with the `WasmBaselineProbes` named in `variants.json`.
- `WasiBrowserRuntimeExit` runs `wasi-runtime/runtime.js` under node and skips where there is none. The Flatpak has no node, so CI never runs it: after touching `runtime.js`, run the suite in the native loop with node on `PATH`. - `WasiBrowserRuntimeExit` runs `wasi-runtime/runtime.js` under node and skips where there is none. The Flatpak has no node, so CI never runs it: after touching `runtime.js`, run the suite in the native loop with node on `PATH`.
- `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. - `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. - AGENTS.md is build output: the self-host build and `test` write it from `AGENTS.template.md`, with the `AGENTS.consumer.md` of each dependency that ships one (Crafter.Math, today) merged in, and it is gitignored. Edit the template; an edit made in AGENTS.md is gone after the next build. A fresh clone or worktree has no AGENTS.md until its first `./bin/crafter-build` or `test`, since `./build.sh` does not write it. A harness from before the template (3.0.0 and older) never creates one and keeps its section in one it finds; the next build with a current harness writes it over.

View file

@ -101,9 +101,23 @@ Such a dependency is built for the host, whatever the project targets, and its `
### Dependency notes for agents ### 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: 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.
A consumer that wants its dependencies' notes writes its own instructions in an `AGENTS.template.md` beside its `project.cpp`, and commits that instead of an `AGENTS.md`. `crafter-build` and `crafter-build test` then write `AGENTS.md` from the template with the notes added, so `AGENTS.md` is build output and stays out of version control: a dependency that changes its notes changes nothing a diff shows. A project with a committed `AGENTS.md` moves over from the directory of its `project.cpp`:
```bash
git mv AGENTS.md AGENTS.template.md
echo /AGENTS.md >> .gitignore
```
What a build then writes:
```markdown ```markdown
<!-- Written by crafter-build from AGENTS.template.md, and rewritten on every build. Edit that file, not this one. -->
# The template's text
...
<!-- crafter-build: dependency notes begin --> <!-- 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. --> <!-- 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. -->
@ -116,11 +130,13 @@ An agent working on a project reads its `AGENTS.md`, which says nothing about th
``` ```
- 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. - 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 section goes after the template's text and a blank line. To have it elsewhere, put the begin and end marker lines in the template where it should go: whatever they enclose is replaced. A template moved over from a committed `AGENTS.md` has them where its section was, around notes that can be deleted.
- `AGENTS.md` is written only when its text changes, in place, so a `CLAUDE.md` linked to it, its ownership and a read-only `AGENTS.md` are respected. An edit made in it is gone after the next build, and a fresh clone has none until its first build. The template is never written.
- An `AGENTS.md` that no build wrote, such as a committed one still next to a new template, is never replaced: each build warns instead, until it is deleted. Without a template, `AGENTS.md` is never touched. One that still holds a section from crafter-build 3.0.0, which kept the notes in a committed `AGENTS.md`, gets a warning: move it over as above, or delete the section.
- 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 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. - 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. - 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. - When there are notes to write, unbalanced markers in the template leave `AGENTS.md` 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: `cfg.dependencyNotes` picks the dependencies:
@ -128,7 +144,7 @@ An agent working on a project reads its `AGENTS.md`, which says nothing about th
|---|---| |---|---|
| `DependencyNotes::Transitive` (default) | everything the project builds against, all the way down: its `dependencies` and its own `#pragma crafter dependency` tooling | | `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::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 | | `DependencyNotes::None` | none: `AGENTS.md` holds the template's text alone |
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. 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.

View file

@ -22,12 +22,26 @@ namespace {
// Right under the begin marker, for whoever opens the file: an edit made // Right under the begin marker, for whoever opens the file: an edit made
// inside the section is gone after the next build. // 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 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. -->";
// The first line of every AGENTS.md a build writes. It tells whoever
// opens the file, an agent asked to change it included, where an edit
// has to be made to outlive the next build, and it is how a build tells
// its own AGENTS.md from one someone wrote, which it never replaces. A
// changed word would make every AGENTS.md written before look like
// someone's own.
constexpr std::string_view GeneratedLine = "<!-- Written by crafter-build from AGENTS.template.md, and rewritten on every build. Edit that file, not this one. -->";
constexpr std::string_view NotesFileName = "AGENTS.consumer.md"; constexpr std::string_view NotesFileName = "AGENTS.consumer.md";
constexpr std::string_view TemplateFileName = "AGENTS.template.md";
constexpr std::string_view AgentsFileName = "AGENTS.md";
void Warn(std::string_view message) { void Warn(std::string_view message) {
Progress::Diagnostic(std::format("warning: {}", message)); Progress::Diagnostic(std::format("warning: {}", message));
} }
// The line ending `text` uses, so a CRLF file does not end up mixed.
std::string_view LineEnd(std::string_view text) {
return text.contains("\r\n") ? "\r\n" : "\n";
}
std::string ErrnoMessage() { std::string ErrnoMessage() {
return std::error_code(errno, std::generic_category()).message(); return std::error_code(errno, std::generic_category()).message();
} }
@ -249,9 +263,9 @@ namespace {
} }
// Why `text` cannot go into the section as it is; empty when it can. A // 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 // marker line of its own would end or restart the section for whoever
// is read back, and a code block left open would hide the end marker: // reads it, a template copied from the result included, and a code block
// either way the next build could no longer find what this one wrote. // left open would run on over the notes after it and the end marker.
// Lines are numbered from `firstLine`. // Lines are numbered from `firstLine`.
std::string NoteProblem(std::string_view text, std::size_t firstLine) { std::string NoteProblem(std::string_view text, std::size_t firstLine) {
const MarkerScan scan = ScanMarkers(text); const MarkerScan scan = ScanMarkers(text);
@ -397,6 +411,33 @@ namespace {
return notes; return notes;
} }
// Whether a build may replace `agentsMd`, the AGENTS.md beside a
// template: one a build wrote, or one with no text at all, which is what
// a write cut short leaves of a new file. Anything else someone wrote,
// and beside a template that is easily the case: an AGENTS.md committed
// before the template was, or one an agent kept its own notes in.
bool Replaceable(std::string_view agentsMd) {
if (agentsMd.find_first_not_of(" \t\r\n") == std::string_view::npos) return true;
std::string_view first = agentsMd.substr(0, agentsMd.find('\n'));
if (first.ends_with('\r')) first.remove_suffix(1);
return first == GeneratedLine;
}
// An AGENTS.md with no template beside it is someone's own, and no build
// writes it. One that still holds a section from the builds that kept the
// notes in a committed AGENTS.md would go stale without a word, under a
// preamble saying every build rewrites it, so each build says so until
// the file has moved to a template or the section is gone.
void WarnStaleSection(const fs::path& agents) {
std::error_code ec;
if (!fs::is_regular_file(agents, ec)) return;
std::string text;
if (!ReadFile(agents, text).empty()) return;
const MarkerScan scan = ScanMarkers(text);
if (scan.begins.empty() && scan.ends.empty()) return;
Warn(std::format("{} has a dependency notes section, which builds no longer update; rename the file to {} and keep {} out of version control to have every build write it with current notes, or delete the section", agents.string(), TemplateFileName, AgentsFileName));
}
// Bring `fd`, which holds `current`, to `wanted`: from the first byte the // 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, // 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. // then cut to length and synced. Empty on success, otherwise the error.
@ -405,10 +446,9 @@ namespace {
// rename swaps in another file, which drops whatever belongs to the old // 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 // 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 // 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 // a reader can catch the file half written, and that a write failing
// partway, on a full disk say, leaves it so. With the section last, // partway, on a full disk say, leaves it so until the next build writes
// where it is appended, only the section can be damaged, and the next // it again.
// build reports its markers instead of guessing.
std::string WriteChanged(std::int32_t fd, std::string_view current, std::string_view wanted) { std::string WriteChanged(std::int32_t fd, std::string_view current, std::string_view wanted) {
const auto diverge = std::ranges::mismatch(current, wanted); const auto diverge = std::ranges::mismatch(current, wanted);
for (auto done = static_cast<std::size_t>(diverge.in2 - wanted.begin()); done < wanted.size();) { for (auto done = static_cast<std::size_t>(diverge.in2 - wanted.begin()); done < wanted.size();) {
@ -427,24 +467,24 @@ namespace {
} }
} }
std::string Crafter::MergeDependencyNotes(std::string_view agentsMd, std::span<const DependencyNote> notes) { std::string Crafter::MergeDependencyNotes(std::string_view templateMd, std::span<const DependencyNote> notes) {
for (const DependencyNote& note : 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)); 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 MarkerScan scan = ScanMarkers(templateMd);
const bool section = scan.begins.size() == 1 && scan.ends.size() == 1 && scan.begins[0] < scan.ends[0]; 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 = LineEnd(templateMd);
const std::string_view eol = agentsMd.contains("\r\n") ? "\r\n" : "\n";
if (notes.empty()) { if (notes.empty()) {
// With nothing to write, leaving the file alone loses nothing, so // With nothing to place, nothing can land in the wrong place, so
// anything short of a well-formed section is no concern of this one. // anything short of a well-formed section is left as it was written.
if (!section) return std::string(agentsMd); if (!section) return std::string(templateMd);
std::string_view before = agentsMd.substr(0, scan.lines[scan.begins[0]].begin); std::string_view before = templateMd.substr(0, scan.lines[scan.begins[0]].begin);
const std::string_view after = agentsMd.substr(scan.lines[scan.ends[0]].next); const std::string_view after = templateMd.substr(scan.lines[scan.ends[0]].next);
// The blank line a section at the end was appended after goes with // A section that ends the template takes the blank line in front of
// it. One the user moved keeps the text around it apart. // it, which an appended one gets. One placed elsewhere keeps the text
// around it apart.
if (after.empty() && before.ends_with(std::format("{0}{0}", eol))) before.remove_suffix(eol.size()); if (after.empty() && before.ends_with(std::format("{0}{0}", eol))) before.remove_suffix(eol.size());
return std::format("{}{}", before, after); return std::format("{}{}", before, after);
} }
@ -455,13 +495,12 @@ std::string Crafter::MergeDependencyNotes(std::string_view agentsMd, std::span<c
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)); 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); 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)); if (section) return std::format("{}{}{}", templateMd.substr(0, scan.lines[scan.begins[0]].begin), rendered, templateMd.substr(scan.lines[scan.ends[0]].next));
// Appended, the section would sit inside that block and never be found // Appended, the section would sit inside that block and read as code.
// 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 (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; if (templateMd.empty()) return rendered;
// On a line of its own, after a blank one. // On a line of its own, after a blank one.
return std::format("{}{}{}{}", agentsMd, agentsMd.ends_with('\n') ? std::string_view() : eol, eol, rendered); return std::format("{}{}{}{}", templateMd, templateMd.ends_with('\n') ? std::string_view() : eol, eol, rendered);
} }
bool Crafter::UpdateAgentsMd(const Configuration& project, const fs::path& projectFile) { bool Crafter::UpdateAgentsMd(const Configuration& project, const fs::path& projectFile) {
@ -470,60 +509,82 @@ bool Crafter::UpdateAgentsMd(const Configuration& project, const fs::path& proje
// keeps the AGENTS.md beside the link. // keeps the AGENTS.md beside the link.
const fs::path projectDir = fs::absolute(projectFile, ec).lexically_normal().parent_path(); const fs::path projectDir = fs::absolute(projectFile, ec).lexically_normal().parent_path();
if (ec) return false; if (ec) return false;
const fs::path agents = projectDir / "AGENTS.md"; const fs::path agents = projectDir / AgentsFileName;
if (!fs::is_regular_file(agents, ec)) return false; const fs::path templateFile = projectDir / TemplateFileName;
if (!fs::is_regular_file(templateFile, ec)) {
WarnStaleSection(agents);
return false;
}
// Linked to the template, AGENTS.md would have every build write the
// file that is committed, which is what the template is there to stop.
if (fs::equivalent(agents, templateFile, ec)) {
Warn(std::format("{} and {} are the same file, so {} was not written", agents.string(), templateFile.string(), AgentsFileName));
return false;
}
std::string templateMd;
if (const std::string error = ReadFile(templateFile, templateMd); !error.empty()) {
Warn(std::format("{} could not be read ({}), so {} was not written", templateFile.string(), error, agents.string()));
return false;
}
const std::vector<DependencyNote> notes = project.dependencyNotes == DependencyNotes::None ? std::vector<DependencyNote>() : CollectNotes(project, projectFile, projectDir); const std::vector<DependencyNote> notes = project.dependencyNotes == DependencyNotes::None ? std::vector<DependencyNote>() : CollectNotes(project, projectFile, projectDir);
std::string wanted;
try {
wanted = std::format("{}{}{}", GeneratedLine, LineEnd(templateMd), MergeDependencyNotes(templateMd, notes));
} catch (const std::exception& e) {
Warn(std::format("{}: {}, so {} was not written", templateFile.string(), e.what(), agents.string()));
return false;
}
// Locked from the read through the write, so two builds never interleave // Created when missing, since a fresh clone has no build output, and only
// their writes, and on the file itself, which also covers two projects // now, with the text in hand, so a template the notes cannot be placed
// that link one AGENTS.md. A read-only descriptor takes the lock as // in leaves no empty AGENTS.md behind. Locked from the read through the
// well, so a read-only AGENTS.md is still read without complaint. // write, so two builds never interleave their writes, and on the file
const Descriptor reading(::open(agents.c_str(), O_RDONLY | O_CLOEXEC)); // itself, which also covers two projects that link one AGENTS.md. A
// read-only descriptor takes the lock as well, so a read-only AGENTS.md
// is still read without complaint.
const Descriptor reading(::open(agents.c_str(), O_RDONLY | O_CREAT | O_CLOEXEC, 0666));
if (!reading.Valid()) { if (!reading.Valid()) {
Warn(std::format("{} could not be read ({}), so its dependency notes were not updated", agents.string(), ErrnoMessage())); Warn(std::format("{} could not be opened ({}), so it was not written", agents.string(), ErrnoMessage()));
return false; return false;
} }
while (::flock(reading.Get(), LOCK_EX) != 0 && errno == EINTR) {} while (::flock(reading.Get(), LOCK_EX) != 0 && errno == EINTR) {}
std::string current; std::string current;
if (const std::string error = ReadAll(reading.Get(), current); !error.empty()) { 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)); Warn(std::format("{} could not be read ({}), so it was not written", agents.string(), error));
return false; return false;
} }
if (current == wanted) return false;
std::string merged; if (!Replaceable(current)) {
try { Warn(std::format("{} was not written by a build, so it was not replaced by one written from {}; once the template holds what it says, delete it", agents.string(), TemplateFileName));
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; return false;
} }
if (merged == current) return false;
// Only now, with something to write: a read-only AGENTS.md is refused // 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. // here, and not on every build that leaves it as it is.
const Descriptor writing(::open(agents.c_str(), O_WRONLY | O_CLOEXEC)); const Descriptor writing(::open(agents.c_str(), O_WRONLY | O_CLOEXEC));
if (!writing.Valid()) { if (!writing.Valid()) {
Warn(std::format("{} could not be opened for writing ({}), so its dependency notes were not updated", agents.string(), ErrnoMessage())); Warn(std::format("{} could not be opened for writing ({}), so it was not written", agents.string(), ErrnoMessage()));
return false; return false;
} }
if (const std::string error = WriteChanged(writing.Get(), current, merged); !error.empty()) { if (const std::string error = WriteChanged(writing.Get(), current, wanted); !error.empty()) {
// Put back what was there. A full disk that refused the new bytes // Put back what was there. A full disk that refused the new bytes
// usually still takes the old ones, whose space was just freed. // usually still takes the old ones, whose space was just freed.
if (WriteChanged(writing.Get(), merged, current).empty()) { if (WriteChanged(writing.Get(), wanted, current).empty()) {
Warn(std::format("{} could not be written ({}), so its dependency notes were not updated", agents.string(), error)); Warn(std::format("{} could not be written ({}), so it was left as it was", agents.string(), error));
} else { } 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)); Warn(std::format("{} could not be written ({}) and may now be half written; the next build writes it again", agents.string(), error));
} }
return false; return false;
} }
// A build that edits a file the user commits says so. // A file that appears beside project.cpp, or changes there, unasked,
// says where it came from.
if (Progress::GetVerbosity() != Progress::Verbosity::Quiet) { if (Progress::GetVerbosity() != Progress::Verbosity::Quiet) {
Progress::Clear(); Progress::Clear();
if (notes.empty()) { if (notes.empty()) {
std::println("Removed the dependency notes from {}", agents.string()); std::println("Wrote {} from {}", agents.string(), TemplateFileName);
} else { } else {
std::println("Updated the dependency notes in {} ({} {})", agents.string(), notes.size(), notes.size() == 1 ? "dependency" : "dependencies"); std::println("Wrote {} from {} and the notes of {} {}", agents.string(), TemplateFileName, notes.size(), notes.size() == 1 ? "dependency" : "dependencies");
} }
} }
return true; return true;

View file

@ -1840,11 +1840,13 @@ Clean (after the `clean` subcommand):
and dependency artifact under those trees goes with it. and dependency artifact under those trees goes with it.
AGENTS.md: AGENTS.md:
When an AGENTS.md sits beside the project file, a build and a test run keep a When an AGENTS.template.md sits beside the project file, a build and a test
section in it holding the AGENTS.consumer.md each dependency ships beside its run write AGENTS.md from it, with a section holding the AGENTS.consumer.md
project.cpp, and write the file only when that section changes. An AGENTS.md each dependency ships beside its project.cpp, and only when the text changes.
is never created. cfg.dependencyNotes picks the dependencies: Transitive (the Commit the template and keep AGENTS.md out of version control. An AGENTS.md
default), Direct, or None, which removes the section. no build wrote is never replaced, and without a template AGENTS.md is left
alone. cfg.dependencyNotes picks the dependencies: Transitive (the default),
Direct, or None.
Project args: Project args:
Any flag not consumed above is forwarded verbatim to CrafterBuildProject as Any flag not consumed above is forwarded verbatim to CrafterBuildProject as

View file

@ -7,10 +7,17 @@ namespace fs = std::filesystem;
// Dependency notes. A crafter-build project ships an AGENTS.consumer.md beside // 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, // 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 // and crafter-build merges those files into each consumer's AGENTS.md. Without
// that, an agent working on a consumer knows nothing about the libraries it // 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 // builds against unless someone copied the knowledge in by hand, and a copy
// goes stale the moment the library changes. // goes stale the moment the library changes.
//
// The consumer writes its own instructions in an AGENTS.template.md beside its
// project.cpp, and every build writes AGENTS.md from that, so AGENTS.md is
// build output and stays out of version control. Kept in a committed
// AGENTS.md instead, as they once were, the notes changed that file whenever
// a dependency changed its own, on builds that changed nothing else, and
// every diff and rebase carried changes nobody had made.
export namespace Crafter { export namespace Crafter {
struct Configuration; struct Configuration;
@ -23,7 +30,7 @@ export namespace Crafter {
// Only the projects this one's own configurations and // Only the projects this one's own configurations and
// `#pragma crafter dependency` lines name. // `#pragma crafter dependency` lines name.
Direct, Direct,
// None. A section an earlier build wrote is removed. // None: AGENTS.md holds the template's text alone.
None, None,
}; };
@ -34,38 +41,43 @@ export namespace Crafter {
std::string name; std::string name;
// The file's content, without leading blank lines or trailing // The file's content, without leading blank lines or trailing
// whitespace. Lines end in LF; the merge writes the line ending the // whitespace. Lines end in LF; the merge writes the line ending the
// document uses. // template uses.
std::string text; std::string text;
}; };
// `agentsMd` with its dependency notes section holding `notes`, in order. // `templateMd` with its dependency notes section holding `notes`, in
// A document without a section gets one appended after a blank line; one // order: AGENTS.md as it reads below the line that says a build wrote it.
// with a section has it rewritten in place, wherever it was moved to; and // A template without a section gets one appended after a blank line; one
// empty `notes` remove it. Removing a section that ends the document takes // with a section, which is how a template places it, has it rewritten
// the blank line in front of it too, so a document that ended in a newline // where it stands, whatever it held; and empty `notes` remove it.
// before it gained the section gets its own bytes back. The section uses // Removing a section that ends the template takes the blank line in front
// the document's line ending, so a CRLF file stays CRLF. // of it too, so the result ends as the text before it did. The section
// uses the template's line ending, so a CRLF file stays CRLF.
// //
// The two marker lines count only as whole lines outside fenced code // The two marker lines count only as whole lines outside fenced code
// blocks, which is what lets a document quote them. Throws, naming the // 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 // 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 // when the section would have to be appended to a template that ends
// inside a code block: guessing which part of someone's file is generated // inside a code block: a lone or doubled marker is a slip in the
// would overwrite their text, and missing the section would add another on // template, and any guess at what it meant would put the notes, or cut
// every build. With no notes it never throws, since leaving the document // the text, where its author did not, while notes appended inside a code
// alone loses nothing. Also throws for a note that would break the section // block would read as code. With no notes it never throws, since nothing
// when it is read back: one with a marker line of its own, or a code block // has to be placed. Also throws for a note that would break the section
// it never closes. // for whoever reads it: one with a marker line of its own, or a code
std::string MergeDependencyNotes(std::string_view agentsMd, std::span<const DependencyNote> notes); // block it never closes.
std::string MergeDependencyNotes(std::string_view templateMd, std::span<const DependencyNote> notes);
// Refresh the dependency notes section of the AGENTS.md beside // Write the AGENTS.md beside `projectFile` from the AGENTS.template.md
// `projectFile` from the AGENTS.consumer.md of every dependency // beside it, with the AGENTS.consumer.md of every dependency
// project.dependencyNotes selects. `project` is the Configuration that // project.dependencyNotes selects merged in. `project` is the
// file returned, after LoadProject: loading is what found the // Configuration that file returned, after LoadProject: loading is what
// dependencies. Does nothing when there is no AGENTS.md, which is never // found the dependencies. Creates AGENTS.md when it is missing, and
// created. Writes only when the text changes, in place, so hard links, // replaces only one a build wrote: an AGENTS.md someone wrote is left
// ownership and a read-only AGENTS.md are respected; returns whether it // alone, with a warning. Without a template it writes nothing, and only
// wrote. A read or write that fails, a section that cannot be placed and // warns about a section an earlier crafter-build left in AGENTS.md.
// 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 template the notes cannot be placed in and
// a dependency's unusable notes are warnings: the notes never fail a // a dependency's unusable notes are warnings: the notes never fail a
// build, and nothing is reported when nothing would have been written. // build, and nothing is reported when nothing would have been written.
bool UpdateAgentsMd(const Configuration& project, const fs::path& projectFile); bool UpdateAgentsMd(const Configuration& project, const fs::path& projectFile);

View file

@ -529,8 +529,9 @@ export namespace Crafter {
// Not part of VariantId: a script changes nothing that is compiled. // Not part of VariantId: a script changes nothing that is compiled.
std::vector<Script> scripts; std::vector<Script> scripts;
// Which dependencies' AGENTS.consumer.md `crafter-build` and // Which dependencies' AGENTS.consumer.md `crafter-build` and
// `crafter-build test` keep in the AGENTS.md beside project.cpp, when // `crafter-build test` merge into the AGENTS.md they write from the
// there is one (see Crafter.Build:Agents): // AGENTS.template.md beside project.cpp, when there is one (see
// Crafter.Build:Agents):
// cfg.dependencyNotes = DependencyNotes::Direct; // cfg.dependencyNotes = DependencyNotes::Direct;
// Only read on the Configuration project.cpp returns; a dependency's // Only read on the Configuration project.cpp returns; a dependency's
// setting decides nothing for its consumers. Not part of VariantId: // setting decides nothing for its consumers. Not part of VariantId:

View file

@ -224,7 +224,7 @@ modules:
build-options: build-options:
# The self-host build fetches Crafter.Math, Crafter.Asset, # The self-host build fetches Crafter.Math, Crafter.Asset,
# Crafter.LintRules and glslang from git; they are unpinned on purpose # Crafter.LintRules and glslang from git; they are unpinned on purpose
# (sibling repos tracking their default branch, see AGENTS.md). # (sibling repos tracking their default branch, see AGENTS.template.md).
build-args: build-args:
- --share=network - --share=network
# On this module, not at the top of the manifest: top-level build # On this module, not at the top of the manifest: top-level build

View file

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

View file

@ -0,0 +1,4 @@
# app
The consumer's own instructions. AGENTS.md has to carry them unchanged, below
its first line and above the dependency notes.

View file

@ -6,19 +6,22 @@ import Crafter.Build;
namespace fs = std::filesystem; namespace fs = std::filesystem;
using namespace Crafter; using namespace Crafter;
// Dependency notes: every dependency that ships an AGENTS.consumer.md beside // Dependency notes: a consumer with an AGENTS.template.md beside its
// its project.cpp has the file kept in a marked section of its consumer's // project.cpp has every build and test run write AGENTS.md from it, with the
// AGENTS.md, refreshed by each build and test run. // AGENTS.consumer.md each dependency ships merged into a marked section.
// //
// AGENTS.md is the user's own file, committed and edited by hand, so what // The template is the user's own file, committed and edited by hand, and
// this guards against is everything else moving: a section appended again on // AGENTS.md is build output that stays out of version control: notes kept in
// every build, text outside the section rewritten, lost or run together, a // a committed AGENTS.md changed it under every diff. So what this guards
// write when nothing changed (a dirty tree, an editor reloading), an // against is the generated file leaking into the user's: the template
// AGENTS.md created in a project that has none, one written although it is // written, an AGENTS.md someone wrote replaced (one committed before the
// read-only, or replaced so that a hard link to it goes stale, notes from a // template was, or one beside no template at all), one written although it
// test's dependency or a dependency's own host tooling leaking into the // is read-only, or replaced so that a hard link to it goes stale; and inside
// consumer's, and `lint`, `run` or `test --list`, which promise not to touch // AGENTS.md, the template's text changed or the section misplaced, a write
// the project, writing it anyway. // when nothing changed (an editor reloading), notes from a test's dependency
// or a dependency's own host tooling leaking into the consumer's, and `lint`,
// `run` or `test --list`, which promise not to touch the project, writing it
// anyway.
namespace { namespace {
std::int32_t Failures = 0; std::int32_t Failures = 0;
@ -84,6 +87,7 @@ namespace {
constexpr std::string_view BeginMarker = "<!-- crafter-build: dependency notes begin -->"; constexpr std::string_view BeginMarker = "<!-- crafter-build: dependency notes begin -->";
constexpr std::string_view EndMarker = "<!-- crafter-build: dependency notes end -->"; constexpr std::string_view EndMarker = "<!-- crafter-build: dependency notes end -->";
constexpr std::string_view GeneratedLine = "<!-- Written by crafter-build from AGENTS.template.md, and rewritten on every build. Edit that file, not this one. -->";
} }
int main() { int main() {
@ -187,12 +191,13 @@ int main() {
fs::copy(fixture, scratch, fs::copy_options::recursive); fs::copy(fixture, scratch, fs::copy_options::recursive);
const fs::path app = scratch / "app"; const fs::path app = scratch / "app";
const fs::path agents = app / "AGENTS.md"; const fs::path agents = app / "AGENTS.md";
const fs::path templateFile = app / "AGENTS.template.md";
const fs::path projectFile = app / "project.cpp"; const fs::path projectFile = app / "project.cpp";
const fs::path libNotes = scratch / "lib" / "AGENTS.consumer.md"; const fs::path libNotes = scratch / "lib" / "AGENTS.consumer.md";
// Checked in under another name: an AGENTS.md in this repository's tree // Checked in under another name, so that this repository's tree holds no
// would read as instructions for the agents working on it. // template a build could ever pick up.
const std::string agentsOriginal = Read(app / "AGENTS.md.in"); const std::string templateText = Read(app / "AGENTS.template.md.in");
Write(agents, agentsOriginal); Write(templateFile, templateText);
// LocalProject resolves against the working directory, and app's paths // LocalProject resolves against the working directory, and app's paths
// are written for `cd app && crafter-build`. // are written for `cd app && crafter-build`.
fs::current_path(app); fs::current_path(app);
@ -210,10 +215,15 @@ int main() {
const std::string libNote = "<!-- from lib -->\n## lib\n\nCall lib through its module, never through its headers.\n"; 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. // 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"; const std::string baseNote = "<!-- from base -->\n## base\n\nbase counts from zero.\n";
// How every AGENTS.md written from templateText starts.
const std::string generated = std::format("{}\n{}", GeneratedLine, templateText);
Check(UpdateAgentsMd(cfg, projectFile), "the first update writes AGENTS.md"); Check(!fs::exists(agents) && UpdateAgentsMd(cfg, projectFile), "the first update creates AGENTS.md");
// Every case after this one reads or rewrites that file.
if (!fs::exists(agents)) return 1;
std::string text = Read(agents); std::string text = Read(agents);
Check(text.starts_with(std::format("{}\n{}\n", agentsOriginal, BeginMarker)), "the section follows the consumer's own text, which is untouched"); Check(text.starts_with(std::format("{}\n{}\n", generated, BeginMarker)), "AGENTS.md is the line saying a build wrote it, the template's text untouched, then the section");
Check(Read(templateFile) == templateText, "the template is never written");
const std::size_t tool = text.find(toolNote); const std::size_t tool = text.find(toolNote);
const std::size_t lib = text.find(libNote); const std::size_t lib = text.find(libNote);
const std::size_t base = text.find(baseNote); const std::size_t base = text.find(baseNote);
@ -228,7 +238,7 @@ int main() {
Check(Read(agents) == text && fs::last_write_time(agents) == written, "the bytes and the mtime are left alone when nothing changed"); 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; cfg.dependencyNotes = DependencyNotes::Direct;
Check(UpdateAgentsMd(cfg, projectFile), "switching to Direct rewrites the section"); Check(UpdateAgentsMd(cfg, projectFile), "switching to Direct rewrites AGENTS.md");
text = Read(agents); text = Read(agents);
Check(text.contains(toolNote) && text.contains(libNote) && !text.contains("## base"), "Direct keeps what app names and drops what only lib does"); Check(text.contains(toolNote) && text.contains(libNote) && !text.contains("## base"), "Direct keeps what app names and drops what only lib does");
@ -238,6 +248,18 @@ int main() {
text = Read(agents); 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"); Check(text.contains("lib was rewritten.") && !text.contains("never through its headers") && Count(text, BeginMarker) == 1, "edited notes replace the old ones, in the one section");
// AGENTS.md follows the template: an edit made there shows up here, and
// the template's marker lines say where the notes go.
Write(templateFile, std::format("{}\nA paragraph added to the template.\n", templateText));
Check(UpdateAgentsMd(cfg, projectFile), "an edited template is picked up");
Check(Read(agents).starts_with(std::format("{}\nA paragraph added to the template.\n\n{}\n", generated, BeginMarker)), "the template's new text goes in ahead of the section");
Write(templateFile, std::format("# app\n\n{}\n{}\n\n## After\n\nText after the notes.\n", BeginMarker, EndMarker));
Check(UpdateAgentsMd(cfg, projectFile), "a template that places the section is picked up");
text = Read(agents);
Check(text.starts_with(std::format("{}\n# app\n\n{}\n", GeneratedLine, BeginMarker)) && text.contains(toolNote) && text.ends_with(std::format("{}\n\n## After\n\nText after the notes.\n", EndMarker)), "the section goes where the template's markers are");
Write(templateFile, templateText);
Check(UpdateAgentsMd(cfg, projectFile) && Read(agents).starts_with(generated), "AGENTS.md follows the template back");
// A CLAUDE.md hard-linked to AGENTS.md has to keep reading the same file. // A CLAUDE.md hard-linked to AGENTS.md has to keep reading the same file.
fs::create_hard_link(agents, app / "CLAUDE.md"); fs::create_hard_link(agents, app / "CLAUDE.md");
Write(libNotes, "## lib\n\nlib, through a link.\n"); Write(libNotes, "## lib\n\nlib, through a link.\n");
@ -255,37 +277,77 @@ int main() {
} }
fs::permissions(agents, fs::perms::owner_write, fs::perm_options::add); fs::permissions(agents, fs::perms::owner_write, fs::perm_options::add);
// A build's own AGENTS.md is output, an edit made in it included; any
// other text in its place is someone's, and only an empty file is not.
(void)UpdateAgentsMd(cfg, projectFile);
const std::string settled = Read(agents);
Write(agents, std::format("{}\nAn edit made in AGENTS.md itself.\n", GeneratedLine));
Check(UpdateAgentsMd(cfg, projectFile) && Read(agents) == settled, "an edit made in AGENTS.md is replaced by the next build");
const std::string handWritten = "# app\n\nAn AGENTS.md committed before the template was.\n";
Write(agents, handWritten);
Check(!UpdateAgentsMd(cfg, projectFile) && Read(agents) == handWritten, "an AGENTS.md no build wrote is never replaced");
Write(agents, "");
Check(UpdateAgentsMd(cfg, projectFile) && Read(agents) == settled, "an empty AGENTS.md, all a write cut short leaves of a new one, is replaced");
cfg.dependencyNotes = DependencyNotes::None; cfg.dependencyNotes = DependencyNotes::None;
Check(UpdateAgentsMd(cfg, projectFile), "None removes the section"); Check(UpdateAgentsMd(cfg, projectFile), "None rewrites AGENTS.md");
Check(Read(agents) == agentsOriginal, "removing the section gives AGENTS.md its own bytes back"); Check(Read(agents) == generated, "under None, AGENTS.md holds the template's text alone");
cfg.dependencyNotes = DependencyNotes::Transitive; 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 // Linked to the template, AGENTS.md would have every build write the
// that replaced the link with a copy would quietly undo. // file that is committed.
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); fs::remove(agents);
fs::create_symlink("AGENTS.template.md", agents);
Check(!UpdateAgentsMd(cfg, projectFile) && Read(templateFile) == templateText, "an AGENTS.md that links to the template is not written");
fs::remove(agents);
// A template the notes cannot be placed in leaves no AGENTS.md behind,
// not even an empty one.
Write(templateFile, std::format("# app\n\n{}\n", BeginMarker));
Check(!UpdateAgentsMd(cfg, projectFile) && !fs::exists(agents), "a template with a begin marker and no end marker writes no AGENTS.md");
// A CRLF template makes a CRLF AGENTS.md, the line a build adds included.
Write(templateFile, "# app\r\n\r\nText.\r\n");
Check(UpdateAgentsMd(cfg, projectFile), "a CRLF template is written");
text = Read(agents);
Check(text.starts_with(std::format("{}\r\n# app\r\n", GeneratedLine)) && Count(text, "\n") == Count(text, "\r\n"), "AGENTS.md from a CRLF template ends every line in CRLF");
Write(templateFile, templateText);
fs::remove(agents);
// A layout that keeps the file elsewhere and links it, which a write that
// replaced the link with a copy would quietly undo.
fs::create_directories(app / "docs");
Write(app / "docs" / "agents.md", "");
fs::create_symlink("docs/agents.md", agents);
Check(UpdateAgentsMd(cfg, projectFile), "an AGENTS.md that is a symlink is written");
Check(fs::is_symlink(agents) && Read(app / "docs" / "agents.md").contains(toolNote), "the write goes through the link, which stays a link");
fs::remove(agents);
// Without a template, AGENTS.md is someone's own: never created, and
// never written, not even to take out a section an earlier crafter-build
// kept in it, which only gets a warning.
fs::remove(templateFile);
Check(!UpdateAgentsMd(cfg, projectFile) && !fs::exists(agents), "a project without a template gets no AGENTS.md");
const std::vector<DependencyNote> oldNotes = { { .name = "lib", .text = "## lib\n\nNotes a committed AGENTS.md still holds." } };
const std::string committed = Merge(templateText, oldNotes);
Write(agents, committed);
Check(!UpdateAgentsMd(cfg, projectFile) && Read(agents) == committed, "without a template, an AGENTS.md holding a section is left alone");
fs::remove(agents);
Write(templateFile, templateText);
// The CLI: only a build or a test run writes it. // The CLI: only a build or a test run writes it.
Write(agents, agentsOriginal);
(void)RunWith({"crafter-build", "-q", "lint"}); (void)RunWith({"crafter-build", "-q", "lint"});
Check(Read(agents) == agentsOriginal, "lint leaves AGENTS.md alone"); Check(!fs::exists(agents), "lint writes no AGENTS.md");
(void)RunWith({"crafter-build", "-q", "run"}); (void)RunWith({"crafter-build", "-q", "run"});
Check(Read(agents) == agentsOriginal, "run leaves AGENTS.md alone"); Check(!fs::exists(agents), "run writes no AGENTS.md");
(void)RunWith({"crafter-build", "-q", "test", "--list"}); (void)RunWith({"crafter-build", "-q", "test", "--list"});
Check(Read(agents) == agentsOriginal, "test --list leaves AGENTS.md alone"); Check(!fs::exists(agents), "test --list writes no AGENTS.md");
(void)RunWith({"crafter-build", "-q", "test"}); (void)RunWith({"crafter-build", "-q", "test"});
Check(Read(agents).contains(toolNote), "a test run refreshes AGENTS.md"); Check(Read(agents).contains(toolNote), "a test run writes AGENTS.md");
// app has no sources, so this build fails at the link, after the update. // app has no sources, so this build fails at the link, after the update.
Write(agents, agentsOriginal); fs::remove(agents);
(void)RunWith({"crafter-build", "-q"}); (void)RunWith({"crafter-build", "-q"});
Check(Read(agents).contains(toolNote), "a build refreshes AGENTS.md before compiling, so one that fails still does"); Check(Read(agents).contains(toolNote), "a build writes AGENTS.md before compiling, so one that fails still does");
// The builds above leave a std PCM per configuration in a tmpfs; a run // The builds above leave a std PCM per configuration in a tmpfs; a run
// that failed keeps them for a look. // that failed keeps them for a look.