diff --git a/.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md b/.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md new file mode 100644 index 00000000..f30d1978 --- /dev/null +++ b/.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md @@ -0,0 +1,598 @@ +--- +subject: build-program +status: active +--- + +# Four upstream asks from a UI framework: what each one is under mcpp's design, and the combined plan + +**Status:** proposed and reviewed (2026-09-13). Nothing here is implemented. +The first ask was analysed in an earlier draft of this record (the action +buffer, §2); this revision reads all four against the rules the 2026-09-11 +and 2026-09-12 records state, and replaces that draft. The review accepted +the four conclusions and added four things, recorded in §8 and folded into +the sections they touch. Engine facts were read at `baa89d2b` (mcpp +2026.9.12.4), mcpp-plugins at `db16e27` (0.8.0). `mcpp::deploy` and +`mcpp::min_platform_version()` are landed engine facts (2026.9.12.3, +protocol 11), not pending ones; an earlier draft read them from a branch. + +## 0. The asks, as HuxerUI states them + +| # | repository | ask | blocks | HuxerUI's workaround | +|---|---|---|---|---| +| 1 | mcpp | `mcpp::action`'s six fixed `char[]` become heap buffers | stage 0, resources via `mcpp::deploy` | one bridging action per payload file, each declaration under the bound | +| 2 | mcpp-plugins | `dist-apk`: a project manifest template with tokens; `java_sources` becomes an array, one `javac`, one `d8` | the whole Android stage | overwrite the member's manifest after `plan_for()`; merge Java into one directory at prepare | +| 3 | mcpp-plugins | `dist-web`: the per-file `cp` becomes a portable copy | nothing (Windows hosts pack Web elsewhere) | pack on Linux or macOS | +| ½ | xim-pkgindex | `cubism-sdk-native`, `cubism-sdk-web` at tier 2 | nothing (a downstream library, not HuxerUI) | Lib-Live2D's fetch script | + +Web and iOS need nothing upstream; the wasm row is measured configuring. +Android is blocked by 2 alone. The framework's stage 0 is blocked by 1 +alone. + +Each ask is read against the same questions: which rule of the design it +touches, whether the precedent already exists in the collection, what the +member or engine alone knows and therefore must own, and what the criterion +is in both directions (2026-09-12 record, rules 7 and 8). The readings +change two of the four: ask 3's answer is an engine token plus a member +edit, not a member edit; ask 2's manifest check is a construction rule, not +an XML parse. + +## 1. The rules the four are read against + +From the 2026-09-12 record (§1), the ones that bind here: + +- **Rule 1, per project / per target / per shape.** A per-shape knob is a + member option; a per-project value is a manifest key; a per-target value is + the engine's. +- **Rule 3, declare unconditionally, submit conditionally.** +- **Rule 4, a name is cache-safe, a path is not.** +- **Rule 7, each item has its own criterion.** A requirement folded into a + neighbour's fix disappears when the neighbour ships. +- **Rule 8, the negative direction is part of the criterion** wherever a + check can pass while measuring nothing. +- **Rule 9, the package, not the host path.** + +From the 2026-09-11 record (§6) and the mcpp-plugins README: a dist member +exposes a plan/submit pair so a project edit is an edit and not a +reimplementation; it names its inputs rather than harvesting a directory; a +member drives what the ecosystem resolved and reads nothing from the host; +a member that runs a program says where the program comes from. + +From the engine: the graph fixes the output set at prepare (docs/30, "You +must name the output files"); an action's command is an argv with no shell +assumed; "the engine is the portable wrapper. It is already on disk on every +platform mcpp runs on" (`cli.cppm:920`, the reason `__action-stamp` exists). + +## 2. Ask 1: the action declaration is bounded by a fixed buffer + +### 2.1 The bound + +The bundled `mcpp` module every build.mcpp imports is a string literal in +`src/build/hostprogram.cppm`, compiled per build.mcpp with `` and +`` in its global module fragment and no `import std;`. Its +`mcpp::action` collects six list fields into six fixed arrays +(`hostprogram.cppm:252`): `inputs_` 8192, `outputs_` 8192, `command_` 16384, +`provides_` 2048, `imports_` 2048, `targets_` 1024. Capacity is bytes of +serialised JSON, not entries. When a literal does not fit, `add()` drops it +and `submit()` writes `"overflow":true`; the engine refuses with "build.mcpp +declared an action whose arguments did not fit" and recommends a response +file or a directory input (`directives.cppm:1095`). + +HuxerUI#130 measured the same 44 files under three unpack prefixes: + +| prefix | `inputs_` bytes | result | +|---|---|---| +| 149 chars | 8131 + ~106 | refused | +| 109 chars | 6371 | builds | +| in-tree, relative | 1531 | builds | + +The margin is about 45 bytes, and which row a consumer gets is decided by +where their checkout sits. No other limit in the action pipeline has that +property. + +### 2.2 Why fixed, and which half of the reason holds + +The comment at line 248 says the module "must stay buildable BEFORE a std +BMI exists", so no `std::string`. Two constraints are folded into that: + +- The module must not `import std;`. True: it is compiled with the build + program's base flags and no module references, and the engine stages a std + BMI only when build.mcpp asks for one. This does not forbid `#include` in + the global module fragment, which is how `` is already there. +- The module's exported interface must name no std type. Not written in the + comment, and the stronger reason: a build.mcpp with `import std;` and + `import mcpp;` would see `std::string` by two routes, and the repository + has measured what std types in a widely imported interface do to + downstream BMIs (`2026-08-11-source-kind-table-and-build-program-timeout.md` + §7.2). Every public member of `action` is `const char*` or `bool`; that + stays. + +Neither constraint concerns the heap. `std::realloc` and `std::free` come +from the `` already included, exist on GCC, Clang and MSVC, and +appear in no exported signature. The fixed arrays were the simplest std-free +storage, not the only one, and the sizes were a guess about protoc command +lines. + +### 2.3 Why the outputs side has no way around it + +Inputs were worked around correctly: the action emits a Make-style +dependency file (`action::depfile`, #587, 2026-09-08) and `inputs` lists the +tool. `mcpp::deploy(from, to)` (#622 A4, landed in 2026.9.12.3, protocol 11) moves the same list to +the other side. Its `from` is a copy edge's input in the ninja graph, so it +must exist at prepare or be a declared action output; an `hrc` product is +the second kind, so every deployed file must be in `outputs_`. Three rules +the engine states and is right about close every exit: + +1. Names may not arrive later (`prepare_actions` fixes the set before + anything runs). There is no depfile for outputs and cannot be one. +2. A directory is not an output (the #622 record, §2.4: dirty on the wrong + events, members unknown to the graph). +3. A response file is the tool's flag. `hrc --root ` already takes a + directory; that shortens the command, which was never the field that + overflowed. + +The framework's 44 files with the `out_dir` prefix serialise to about 5 KB. +An application's resources have no upper count. + +### 2.4 What bounds an action once the buffer is gone + +| stage | bound | +|---|---| +| `submit()` → stdout | none (`std::printf` into the capture pipe) | +| engine capture (`build_program.cppm:757`) | none (`std::getline`) | +| cache record | none; stored verbatim, replayed byte-identical | +| `decode_action` | none | +| `build.ninja` edge lists (`ninja_backend.cppm:2414`) | none; never an argv | +| the action's **command** at run time | OS argv: Linux 128 KiB per argument, Windows 32767 (`CreateProcess`), 8191 (`cmd.exe`) | + +The last row is real, is the operating system's, bounds the tool's own argv, +and is answered only by the tool taking a response file or a directory. The +fixed array is the only artificial bound on `inputs` and `outputs`. + +### 2.5 Three answers, and the recommendation + +**A. A growable buffer inside the module.** A small owning type in the +module's private section over `realloc`, replacing the six arrays. No +exported signature changes, no new directive, no protocol bump; the payload +of every action that fit before is byte-for-byte what it was, so the cache +key `apply()` stores is untouched. `add()` loses its `while (buf[o]) ++o;` +scan (linear per call, quadratic per declaration) and its capacity +parameter. `overflow_` keeps its wire form and now means allocation failure; +the engine's message says so and drops the sentence about fixed buffers. +`action` gains a destructor and deep copies; every use read in the ecosystem +(`mcpp-plugins` rules and dist members, `mcpp-accel` examples, HuxerUI) +declares a local and calls `submit()`, and `action` was never an aggregate. +About thirty lines. Recommended. + +**B. A response file for the declaration.** The module writes the lists to +a file under `out_dir`; the engine reads it at parse and on every replay; +the cache record must carry its contents or hash. Three moving parts and one +new way for a cache hit to be wrong. Rejected. + +**C. Streaming directives**, one line per entry. Changes the wire shape, the +record, the decoder and the same-bytes invariant, and needs a protocol bump. +Rejected. + +The response file the current message recommends is the right answer for +the one row of §2.4 the engine cannot remove and the wrong one for the row +it can. + +### 2.6 The workaround, read + +One bridging action per payload file, its input the index and its output +that file, is legal under the graph's rules and keeps every declaration +under the bound. It is N edges declared to satisfy a buffer, each an edge +whose command does nothing the producing action did not already do; the +graph carries it, ninja schedules it, and the log names it. It is the +correct interim shape if the release is not waited for, and it is the shape +the engine should make unnecessary. + +## 3. Ask 2: `dist-apk` takes a manifest template and several Java roots + +### 3.1 What the member does today + +`dist-apk` 0.8.0 generates `AndroidManifest.xml` at plan time from four +values (`manifest_xml`, `apk.cppm:314`): the application id, the label, the +SDK levels, and either `android.app.NativeActivity` with `lib_name` (level +0) or `options::activity` (level 1). It also writes `assets/mcpp-run.json` +from the same id and activity, which `adb-run` reads to start the +application without `aapt2` on the running machine. `java_sources` is one +directory; `javac` compiles every `.java` under it, `d8` dexes the result. + +What HuxerUI's host needs in the manifest, read from the framework's own +Gradle-side files (`platform/android/huxerui/src/main/AndroidManifest.xml` +and the `local_notification` example): `configChanges` on the activity, +`uses-permission` entries, a `receiver` and `meta-data` for notifications, +`android:icon`, an `activity-alias` for a URL scheme. Gradle merges the +library's manifest into the application's; mcpp has no merger and should +not grow one, for the reason `dist-web` gives for its page: which shape the +host needs is the program's contract, not the member's guess. The Java host +is in two places: 25 files in the framework package and the application's +own `Activity` in the project. + +### 3.2 The manifest: a template with tokens, and the precedent on both sides + +The collection already has both shapes. `dist-web` renders a project +`template_file` with `{{name}}` and `{{title}}`; `dist-wix` takes a whole +`.wxs` and passes the one value it cannot bake in (`$(Executable)`) through +`-d`. The question is which values are the member's, because those are the +ones a project file cannot spell as literal text: + +| token | who knows it | why the project cannot write it | +|---|---|---| +| `{{application_id}}` | member, from `[package]` or `options::application_id` | the run sidecar is written from the same value | +| `{{activity}}` | member, from `options::activity` or the level-0 constant | same | +| `{{lib_name}}` | member, from the `app` target's name | the link output's stem is the engine's | +| `{{min_sdk}}` | member, from `mcpp::min_platform_version()` (A11, landed 2026.9.12.3) | a manifest key the engine resolves, not the member | +| `{{target_sdk}}` | member, from the platform payload's directory name | read back from `xpkg_dir`, so it cannot disagree with `-I android.jar` | +| `{{label}}` | member, from `options::label` | convenience; a project may write the literal | + +The rule that follows, and where it differs from the ask: **the tokens that +carry a value the member also writes elsewhere are required, not merely +substituted.** A template without `{{application_id}}`, `{{activity}}`, and +at level 0 `{{lib_name}}`, is refused at plan time; the message names the +missing token and the reader downstream of it (`assets/mcpp-run.json`, which +`adb-run` starts the application from), so the author learns what the value +is for and not only that it is missing. The default template's comments mark +the three required tokens. + +What the rule guarantees is stated exactly: **the token appears**. It does +not guarantee that the token lands in the `package=` attribute; an author +who writes `{{application_id}}` into a comment and a different literal into +`package=` has defeated it, and the member cannot see that without reading +the XML. That is a deliberate act and not the member's to prevent. The +ordinary case, an author who forgets, is caught. The ask's alternative, +rendering and then checking +`package=`, the launcher activity and `lib_name` in the result, needs a +second reader of the XML inside the member, and a second parser of one file +is the shape that reports the whole file wrongly +(`second-parser-reports-the-whole-file`); a substring check is the shape +that goes quiet when the spelling changes. Requiring the token is the same +guarantee with no parser. A project that wants a fixed package name sets +`options::application_id` and keeps the token. + +An unknown `{{...}}` in the template is refused at plan time, naming it. +`dist-web` does not do this today and leaves an unknown token literal; for a +manifest that literal would reach `aapt2` and fail there with a worse +message, and the refusal belongs where the name is known. + +Everything else in the file is the project's, verbatim: permissions, +receivers, meta-data, icon, aliases, `configChanges`. The member adds +nothing to it. The default template is the file `manifest_xml` generates +today, expressed with the same tokens, so level 0 with no template is +byte-identical to 0.8.0's manifest. + +The rendered manifest is written at plan time to the side file the link step +already takes as input (`/dist-apk/AndroidManifest.xml`), through +`write_if_different`, so a template edit reaches the graph. The template +itself is declared with `rerun_if_changed`, which is the part the ask's +workaround (overwrite after `plan_for()`) cannot do: it depends on the +member's internal path and leaves the sidecar written from the member's +values while the manifest says something else. + +### 3.3 `java_sources` as an array + +One `javac` over every root and one `d8` over its output is the shape; the +member compiles what it is given (#622 §3.2) and a second root is more of +the same input, not a second step. `options::java_sources` becomes +`std::vector`; a single string stays accepted in a +`build.mcpp` because the member is C++ and a one-element initialiser list +is the same spelling. + +The re-run question is the one that needs stating. Today the member +declares `rerun_if_changed_glob("/**/*.java")` so that a file +appearing re-runs the program. `glob_fingerprint` walks the **package +root** (`directives.cppm:805`) and matches paths relative to it; a root +under a dependency's unpack directory is outside that walk, matches +nothing, and the fingerprint is the same as "no files", which is a +criterion whose "no" reads as silence. So: + +- a project root (under the manifest directory) is declared with the glob, + as today; +- a dependency root is not: its file set changes only with the dependency's + version, which is already in the build's fingerprint, and each file's + content is already an input of the `javac` action. Declaring the glob for + it would be a line that measures nothing. + +The member decides which of the two a root is by whether it lies under +`mcpp::manifest_dir()`, and says nothing for the other case rather than +warning about a normal one. + +One corner is the consumer's and HuxerUI meets it daily: a **path +dependency**. Its three examples declare `huxerui = { path = "../../.." }`, +so during development a `.java` added to the framework changes the file set +under a root the glob does not walk, with no version change to carry it into +the fingerprint. The build program does not re-run, and the new file is not +compiled until something else triggers a re-run. The member's rule is still +right; the answer is the dependency's, and it is the engine's own "declare +instead of discover": the rule package carries a list of its Java sources +(`android/java-sources.txt`, maintained by `huxerui-build-check`, so drift +is red in CI) and declares `rerun_if_changed` on that one file. A file +appearing is then an edit to the list, which is a content change the +fingerprint sees. + +### 3.4 What the ask does not list, noted and not folded in + +`options::resources` is one directory, and the compile step declares that +directory as its input (`apk.cppm:788`), which is the "dirty on the wrong +events" shape §2.3 refuses for outputs. The framework's `res/` and the +application's are two roots for the same reason the Java is. Rule 7: this +is its own item with its own criterion and is not part of ask 2. Recorded +so it is not lost. + +### 3.5 Criteria + +- A template that omits `{{application_id}}` is refused at plan time and the + message names the token; the same template with it renders, and + `aapt2 dump badging` on the linked `base.apk` reports the id and the + launcher activity that `assets/mcpp-run.json` carries, compared as two + values from two files. +- A template with a `uses-permission` and a `receiver` produces an APK whose + `aapt2 dump xmltree` lists both; the default template on the same project + produces neither, and the level-0 manifest with no template is + byte-identical to 0.8.0's. +- Two Java roots, one in the project and one under a dependency, produce one + `classes.dex` containing classes from both (`dexdump` or `d8`'s own + listing); a `.java` added to the project root re-runs the build program on + the next build, and one added to the dependency root does not, which is + the intended reading, not a gap. + +## 4. Ask 3: `dist-web` copies with something that exists on Windows + +### 4.1 What the member says and what the engine already has + +`dist-web` declares one `cp SRC DST` action per staged file, argv only, and +its header says why it is POSIX-only: neither precedent (`appimagetool`'s +own argument list; `ditto`, macOS only) is a portable multi-file copier, and +"lifting it needs either a `copy`-argv branch on the host OS or a small +copier this member carries itself" (`web.cppm:48-59`). + +Both of those are the wrong shape. `cmd /c copy` is a shell, is the 8191 +limit, and is the switch-quoting the repository has already been bitten by +twice. A copier carried by the member is a host tool sub-build (#355) for +one `cp`. + +The engine has the copier. `mcpp stage --output ` is the +subcommand every `stage_file` edge in `build.ninja` already runs +(`ninja_backend.cppm:841`): it creates the destination's parent, compares +content and writes only on difference, which is what makes `restat = 1` +worth having, and it is on disk wherever mcpp runs. The `check` role is +wrapped with `mcpp __action-stamp` for exactly this argument +(`cli.cppm:920`). + +What is missing is the way for an action to **name the engine**. `$mcpp` is +a ninja variable and an action's tokens are ninja-escaped, so a member +cannot spell it. The substitution family an action's argv already has +(`${mcpp.out_dir}`, `${mcpp.bin_dir}`, `${mcpp.compile_db}`, +`${mcpp.stage_dir}`, `${mcpp.target_file:NAME}`, `prepare.cppm:11138`) is +where the answer goes: `${mcpp.self}`, replaced by the engine's own absolute +path, the same `mcpp_exe_path()` the check wrapper bakes in and with the +same caveat (a version change regenerates `build.ninja`; moving the binary +without changing its version leaves a stale path, exactly as for the +compiler). The review confirmed the precedent from an existing artifact: the +`build.ninja` of the wasm probe already carries `mcpp __action-stamp` as the +engine's absolute path, so the token adds a second reader of a value the +graph already holds and no new kind of value. + +The name: `self` is the word the engine already uses for itself in the +`mcpp self version` / `mcpp self doctor` family, so a reader knows what it +names. `${mcpp.bin}` collides with `${mcpp.bin_dir}`; `${mcpp.engine}` has no +precedent. + +### 4.2 The split + +- **mcpp:** the token, one `rep(...)` line and a row in docs/30's + substitution table (both languages). The path-check skips for tokens + containing `${mcpp.` (`directives.cppm:1141`) already cover it. + **`mcpp stage` becomes a contract in the same change.** The subcommand is + marked internal today ("invoked by ninja", `cli.cppm:844`); the first + member that names it turns its argument shape into something the engine + may not change under a published plugin, or the plugin fails on the old + graph with no message that says why. So `stage --output `, + with `--verify content` spelled out, is written into the same docs/30 + table beside `${mcpp.self}`, and the release that carries both is the + floor the member declares. Spelled out rather than defaulted because the + help text says the default is `size` and the code's default is content + (`stage.cppm:60`), and a contract must not depend on which of the two a + reader believes. +- **mcpp-plugins:** `dist-web`'s two copy steps become + `{ "${mcpp.self}", "stage", "--verify", "content", "--output", dst, src }`; + the POSIX-only note + leaves the header and the README row; the member's floor rises to the + engine release carrying the token. `dist-web` also stops creating + destination directories at plan time, since `stage` does. + +An older engine leaves `${mcpp.self}` literal and the action fails at run +time with a not-found for a path that reads as a token; rule 6's +"compatibility by ignoring" does not reach argv, which is why the floor is +raised rather than the token guarded. + +### 4.3 Criteria + +- The e2e that packs the verified Web row on the Windows shard produces the + same file set the Linux shard does, listed and compared; it runs without + `# requires:` gating. +- The negative direction: a `${mcpp.self}` in an action under the engine one + release earlier appears literally in `build.ninja`'s command line, which + is what the floor exists to prevent; checked once, at merge. +- A second `mcpp pack --format web` with nothing changed copies nothing + (`ninja -n` lists no `WEB FILE` edge), which `cp` could never give and + `stage` gives for free. + +## 5. Ask ½: the Cubism SDKs at tier 2 + +The 2026-09-11 record §9.8 states the ladder: redistribute if the licence +permits, otherwise fetch from upstream with no CN mirror, otherwise locate +what the machine has. The #622 record (§4.4, C5) already places Cubism at +tier 2 by `iphoneos-sdk.lua`'s shape: one anonymous upstream URL, `sha256` +pinned, no CN entry, `licenses` recording both the Open Software License and +the Core's proprietary one. Nothing in the ask departs from that. + +Two things the recipe author measures rather than assumes, because each has +a silent failure mode: + +- The URL is anonymous. Cubism's download page gates on a licence + acceptance; if the archive URL is not fetchable without it, tier 2 is + not reachable and the honest recipe is tier 3. Lib-Live2D's + `fetch_cubism.py` already opens that URL with no credential and checks + the `sha256`, which is a measurement and belongs in the recipe's comment + as its evidence; the rule still asks for one install in the sandbox from a + clean home, because a script that works on the author's machine is the + reading the sandbox exists to separate from the general case. +- `licenses` is a set and a wrong member is worse than none. Both licences + are named; the Core's is not "proprietary" as a placeholder but the + licence's own name. + +Native and Web are two packages with two payloads and two PRs, by the index's +one-package-one-identity rule. Neither blocks a HuxerUI row; Lib-Live2D +consumes them when they exist and its fetch script is the interim. + +## 6. Order and repositories + +| step | repository | what lands | unblocks | +|---|---|---|---| +| 1 | mcpp | §2.5 A, the buffer; §4.1's `${mcpp.self}` and the `stage` contract (§4.2) in the same release | HuxerUI stage 0; `dist-web` on Windows | +| 2 | mcpp-plugins | `dist-apk` template and array (§3), `dist-web` on `stage` (§4.2); one release, floor raised to step 1's engine | the Android stage | +| 3 | xim-pkgindex | the two Cubism recipes | Lib-Live2D | +| 4 | HuxerUI | `hrc:builtin` / `hrc:app` enumerate outputs and emit one `deploy` per file; the rule adapter passes both Java roots, its manifest template and its `java-sources.txt` (§3.3); engine floor raised | its own stage 0 and Android | + +Step 2 depends on 1; 3 depends on nothing; 4 depends on 1 and 2. Nothing +waits on an engine branch: `mcpp::deploy` and the platform floor are in +2026.9.12.3. Two of the asks were filed against mcpp-plugins and one of +those (ask 3) is answered in mcpp first; the table is what the issues +should say. + +**What HuxerUI does while it waits.** The bridging action of §2.6 is not +built; it stays in this record as the fallback. The parts that depend on +none of the four proceed: closing its PR #6, everything in stage 0 except +the `deploy` step, Web, and the iOS simulator row. + +## 7. What this record does not propose + +- A manifest merger. The project writes the whole file; the member fills the + values only it knows. +- A bound on the growable buffer. The engine bounds no other directive. +- Guarding `${mcpp.self}` for older engines. The floor is the mechanism. +- Changing `options::resources` (§3.4), the command-axis OS limits (§2.4), or + a general "copy" action role. Each would be its own record. + +## 8. Review (2026-09-13) + +The four conclusions were accepted as written. Three decisions were put to +the reviewer and answered: + +1. **Required tokens** (§3.2): accepted over render-then-check, with the + wording corrected from "by construction" to "the token appears", and the + refusal message and default template extended as §3.2 now says. +2. **Ask 3 in two steps, the issue moved to mcpp** (§4): accepted, with the + addition that `mcpp stage`'s argument shape enters the contract and the + floor with the token, not after it. +3. **The name `${mcpp.self}`**: accepted, for the reason §4.1 now records. + +Four additions came from the review and are folded in above: + +- The engine's absolute path is already in `build.ninja` (`__action-stamp`), + checked on the wasm probe's graph; the byte-identity claim of §2.5 was + checked against existing artifacts and holds (§4.1). +- The path-dependency corner of the Java re-run rule, and HuxerUI's answer + to it (§3.3). +- Lib-Live2D's fetch script as recorded evidence for the Cubism URL (§5). +- A11 was already landed; the earlier draft's step that waited for it is + removed (§6). + +### 8.1 What implementation changed (mcpp side, 2026-09-13) + +- **The count criterion of M5 read the whole file and reported 2N.** A + source action's outputs appear twice in `build.ninja`: on the action's own + edge and again as the inputs of the package's ordering phony. The fixture + now reads only the `build ...: mcpp_action_` line; the first run of the + fixture is what surfaced the second listing. +- **`add()` now escapes control characters as `esc()` does.** The fixed-array + revision passed them through, which produced a payload that was not JSON + and was refused as malformed; so no payload the engine ever accepted + contained one, and the byte-identity claim of §2.5 holds for every + accepted payload. Measured: the cache record of a three-entry action is + byte-identical (1088 bytes) under 2026.9.12.4 and under the change. +- **The reverse legs.** Under 2026.9.12.4, e2e 659 is refused with "arguments + did not fit" and e2e 660 fails with `${mcpp.self}: not found` from the + shell ninja handed the literal token to. Both recorded in the PR. +- **`mcpp stage`'s help said the default was `size`; the code's default is + content.** The help now states the shape the docs/30 row quotes. The + subcommand stays out of the top-level usage list, since nobody types it; + the contract is the argument shape, not its place in `--help`. +- **The long-command leg of M5 runs on POSIX hosts only**, because its + command is `true`, a program that accepts any argv, and Windows has none. + An earlier draft of this bullet said ninja runs every command through + `cmd /c` on Windows; it does not (#261 removed the last rule that needed a + shell), and a plain argv goes through `CreateProcess` with its 32767 + characters. The wide-list leg runs on every shard. +- **The engine's command-length guard refused the wide action on the Windows + shard.** `check_inline_command_lengths` read each `build` line as a proxy + for the command, which is right for a rule that expands `$in` and `$out` + and wrong for an action rule, whose command is a literal argv; 200 outputs + and 200 inputs of Windows temp paths put the line over 32767. That is + §2.4's claim ("edge lists, never an argv") not holding inside one guard. + The guard now measures a literal command's own text; e2e 659's N was + raised to 600 so that the edge line also crosses the POSIX 128 KiB limit, + and the fixture holds the guard on every shard. Reverse leg: the raised + fixture is refused by the engine before the guard fix on Linux too. + +One observation stands as recorded and not acted on: `options::resources` +as a single directory declared as an input (§3.4). It affects HuxerUI +little today, since the framework's Android library carries no `res/` and +only the application has one root, and it is its own item. + +## 9. The task list + +One pull request per repository carries everything that repository owes; a +split is made only where a release boundary forces it (mcpp-plugins can only +be green against a released engine). Each task names its files, its +criterion in both directions, and what it waits on. "Done" for the whole is +§9.5, not any row. + +### 9.1 mcpp, one PR (release 2026.9.13.1) + +| id | task | files | criterion | waits on | +|---|---|---|---|---| +| M1 | `mcpp::action`: six arrays become one growable std-free buffer type; `add()` O(1) amortised; `overflow_` means allocation failure; comment at line 248 restated as the two constraints of §2.2 | `src/build/hostprogram.cppm` | e2e M5; byte identity M6 | — | +| M2 | `action_error`'s overflow message rewritten for allocation failure | `modules/buildmcpp/src/directives.cppm` | unit test: the marker still refuses, the message no longer names a buffer size | — | +| M3 | `${mcpp.self}` in the argv substitution family, replaced by `mcpp_exe_path()` | `src/build/prepare.cppm` | e2e M7 | — | +| M4 | `mcpp stage` promoted from internal to contract: CLI help states the argument shape and the content default correctly (`--verify` help text says `size (default)`; the code default is content, `stage.cppm:60`) | `src/cli.cppm` | `mcpp stage --help` names `--verify content` as the default; the docs row of M8 quotes the same shape | — | +| M5 | e2e: an action with 200 outputs and a command past 16 KiB builds; `build.ninja` lists exactly 200 outputs on that edge; all 200 exist; a program-cache replay after an unrelated source is added keeps the edge complete; no `# requires:` gate | `tests/e2e/659_*.sh` | reverse leg: the same fixture at `baa89d2b` is refused with the overflow diagnostic, run once and recorded in the PR | M1 | +| M6 | byte identity: the `mcpp:action=` line of e2e 188's fixture, captured under `baa89d2b` and under M1, compared with `cmp` | PR body | equal | M1 | +| M7 | e2e: an `artifact` action `${mcpp.self} stage --verify content --output ` copies a file on every shard; a second build copies nothing; no `# requires:` gate | `tests/e2e/660_*.sh` | reverse leg: under `baa89d2b` the token stays literal in `build.ninja` | M3, M4 | +| M8 | docs/30 en and zh: the action section states the list fields have no declared limit and the command is bounded by the OS at run time; the substitution table gains `${mcpp.self}` with the `stage` shape beside it; the "did not fit" advice is removed | `docs/30-build-mcpp.md`, `docs/zh/30-build-mcpp.md` | `check_docs_structure.sh`, `check_docs_style.sh` | M1, M3, M4 | +| M9 | CHANGELOG entry; version bump to 2026.9.13.1 in `mcpp.toml` and `modules/versioning/src/version.cppm` (same commit); `check_version_pins.sh` | as named | `01_help_and_version.sh` | M1 to M8 | +| M10 | this record: status to landed, §8 extended with what implementation changed | this file | index regenerated | M9 | + +### 9.2 mcpp-plugins, one PR (release 0.9.0) + +| id | task | files | criterion | waits on | +|---|---|---|---|---| +| P1 | `dist-apk`: `options::manifest_template`; six tokens; the three required ones refused by name with their downstream reader; unknown token refused; default template is the 0.8.0 manifest in token form with the required tokens marked | `dist/apk.cppm` | §3.5 first two bullets, in `tests/apk-consumer` (level 0 default: byte-identical manifest; a template with a permission and a receiver: `aapt2 dump xmltree` lists both; a template missing `{{application_id}}`: refused, message names the token and `mcpp-run.json`) | — | +| P2 | `dist-apk`: `java_sources` becomes a vector; one `javac`, one `d8`; `rerun_if_changed_glob` only for roots under `manifest_dir()` | `dist/apk.cppm` | §3.5 third bullet: two roots, one dex with classes from both; a `.java` added under the project root re-runs the program | — | +| P3 | `dist-web`: copy steps use `${mcpp.self} stage --verify content --output`; plan-time `create_directories` removed; the POSIX-only note leaves header and README; floor raised | `dist/web.cppm`, `README.md` | `tests/web-consumer/check-web-plan.sh` unchanged and green; second `pack` copies nothing (`ninja -n` lists no `WEB FILE` edge) | M-release | +| P4 | README rows for `dist-apk` and `dist-web`; `mcpp.toml` version 0.9.0; CI `MCPP_VERSION` to 2026.9.13.1 | `README.md`, `mcpp.toml`, `.github/workflows/ci.yml` | CI green on all three runners | M-release | +| P5 | tag `v0.9.0`; source archive to `mcpp-res/mcpp-plugins` on GitCode; sha256 recomputed from both ends and equal | release | GET on both URLs returns the archive; `sha256` equal | P4 merged | +| P6 | mcpp-index: `pkgs/m/mcpp.plugins.lua` gains 0.9.0 and `latest` moves | mcpp-index PR | index artifact published; `mcpp add` resolves 0.9.0 | P5 | + +### 9.3 xim-pkgindex, one PR + +| id | task | files | criterion | waits on | +|---|---|---|---|---| +| X1 | `cubism-sdk-native` 5-r.5 and `cubism-sdk-web` 5-r.5 at tier 2: the upstream URL, `sha256` from Lib-Live2D's pinned values recomputed after an anonymous download, no CN entry, `licenses` naming both licences by their own names read from the archive | `pkgs/c/cubism-sdk-native.lua`, `pkgs/c/cubism-sdk-web.lua`, `tests/c/` | the index's own recipe tests; an install in the sandbox from a clean home; the header states the tier and why | — | + +### 9.4 Verification, in the sandbox + +| id | task | criterion | +|---|---|---| +| V1 | `xlings subos new eco-2026-9-13 --sandbox`, CN mirror set for mcpp and xlings; `xim:mcpp@2026.9.13.1` from the index reports its version | the version line, from the store path | +| V2 | `mcpp:plugins@0.9.0` from the index: `tests/web-consumer` packs and the copy steps carry the engine path; `tests/apk-consumer` at level 0 and with a template | listings, not "it ran" | +| V3 | a consumer with 200 declared outputs builds through the released engine | M5's fixture, against the installed engine | +| V4 | `xim add cubism-sdk-native` and `-web` install from the anonymous URL | the extracted tree's `LICENSE` files exist | + +### 9.5 Done + +The index's `main` names 2026.9.13.1 as `latest` for `xim:mcpp` and 0.9.0 for +`mcpp:plugins`; V1 to V4 pass in the sandbox; this record and the two +repositories' READMEs say what shipped. HuxerUI's step is theirs and is not +in this list. diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 536e8c4c..989b7f15 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,12 +18,16 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -282 records. +283 records. ## By subject Records that declare one. Everything else is listed by date below. +### build-program + +- [Four upstream asks from a UI framework: what each one is under mcpp's design, and the combined plan](2026-09-13-four-upstream-asks-from-a-ui-framework.md) — active + ### docs - [The documentation as a book: a chapter-by-chapter design](2026-09-08-the-documentation-as-a-book.md) — active @@ -61,6 +65,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-09 +- [Four upstream asks from a UI framework: what each one is under mcpp's design, and the combined plan](2026-09-13-four-upstream-asks-from-a-ui-framework.md) — active - [The engine gaps left open after the SDK batch](2026-09-12-engine-gaps-after-the-sdk-batch.md) — landed - [A verified Web run that asked the host for node](2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md) — landed - [Implementation plan: a UI framework on Android, iOS and Web (#622)](2026-09-12-622-implementation-plan.md) — landed diff --git a/.github/workflows/openkal-cross.yml b/.github/workflows/openkal-cross.yml index fe6981b4..323dbdef 100644 --- a/.github/workflows/openkal-cross.yml +++ b/.github/workflows/openkal-cross.yml @@ -187,6 +187,27 @@ jobs: "$MCPP_UNDER_TEST" toolchain install llvm 22.1.8 "$MCPP_UNDER_TEST" toolchain default 'llvm@22.1.8' + # WHAT THIS RUNNER ACTUALLY PROVIDES FOR THE mingw TARGET. On a Windows + # host `openkal-windows`'s build program generates no import libraries + # ("the system's own are present"), so `-lntdll` and its neighbours are + # found by lld only where the host, the sandbox or the payload puts + # them. This job was green with one sandbox lineage and red with the + # next (2026-09-13, PR #629, same sources, same image), which is the + # signature of a dependency on cached state nobody declared. The lines + # below say where the libraries come from, so the next such reading is + # diagnosed from the log rather than from a bisect over caches. + - name: What this host provides for x86_64-w64-windows-gnu + if: matrix.host == 'windows' + run: | + echo "PATH=$PATH" | tr ':' '\n' | head -40 + ls "${MCPP_HOME:-$HOME/.mcpp}/registry/data/xpkgs" 2>/dev/null || echo "(no xpkgs dir)" + CLANG=$(ls "${MCPP_HOME:-$HOME/.mcpp}"/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++.exe 2>/dev/null | head -1) + echo "clang=$CLANG" + [ -n "$CLANG" ] && "$CLANG" --target=x86_64-w64-windows-gnu -print-search-dirs + [ -n "$CLANG" ] && "$CLANG" --target=x86_64-w64-windows-gnu -print-file-name=libntdll.a + [ -n "$CLANG" ] && "$CLANG" --target=x86_64-w64-windows-gnu -print-file-name=libkernel32.a + command -v x86_64-w64-mingw32-gcc gcc 2>/dev/null || true + - name: The program — one source, three targets run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index df8becb8..c96783c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ ## [Unreleased] +### `mcpp::action` 的列表不再有长度上限;`${mcpp.self}` 让 action 叫出引擎自己 + +内置的 `mcpp` 模块此前把一个 action 的 `inputs`、`outputs`、`command` 等六个列表 +放在定长数组里(`inputs` 与 `outputs` 各 8192 字节的序列化 JSON),放不下的声明 +被拒绝为「arguments did not fit」。上限按字节计,于是一个消费者的 checkout 深度 +决定了 44 个资源文件的列表能否通过(HuxerUI#130 量得的余量是 45 字节);而 +`outputs` 是作者无法缩短的那一个列表:没有点名的产物无法构建,也不存在面向 +outputs 的 depfile。六个数组换成模块内部基于 `realloc` 增长的 std-free 缓冲, +导出接口、协议版本与原本放得下的 action 的序列化字节全部不变(缓存键因此不变)。 +`"overflow":true` 标记保留,含义改为分配失败,引擎侧消息随之重写。运行期的命令 +仍受操作系统 argv 上限约束,那是工具自己命令行的事。 + +`${mcpp.self}` 加入 action argv 的替换家族,替换为引擎自己的绝对路径。action 的 +命令是没有 shell 的 argv,构建程序此前没有可移植的拷贝手段;`${mcpp.self} stage +--verify content --output ` 即每条 `stage_file` 边已在执行的那次拷贝。 +`mcpp stage` 的参数形状自此成为契约,其帮助文本改为陈述真实的默认值(content)。 + +顺带修正引擎的命令长度守卫:它此前把每条 `build` 行读成命令的代理,这对展开 +`$in`/`$out` 的规则成立,对命令是字面 argv 的 action 规则不成立 —— 一个 200 个 +输出的 action 在 Windows 分片上被整条边行当 argv 计数而拒绝。守卫现在对字面命令 +量它自己的文本。 + +- 判据:`tests/e2e/659`(600 个输入与 600 个输出的 action 整体进入 build.ninja, + 边行越过每个宿主的 argv 上限而不被守卫误拒,缓存回放后仍完整;在 2026.9.12.4 + 上同一夹具被拒绝),`tests/e2e/660` + (`${mcpp.self}` 在每个分片上完成一次拷贝,空转重建不再拷贝;在 2026.9.12.4 + 上 token 原样落入 build.ninja)。 +- 设计记录:`.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md`。 + ### wasm 产物契约:启动器改名为 `.js`,`.wasm` 是隐式输出(#622 A5) `wasm32-emscripten` 行此前用的是宿主借来的裸名 —— `bin/`(Linux 宿主)或 diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index fb096f39..35db532b 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -553,6 +553,20 @@ and the module graph during prepare, so an output whose *name* is unknown cannot be built. Content may arrive later; names may not. A malformed action is a hard error, never a silent skip. +**The lists have no declared size limit** (2026.9.13.1+). `inputs`, `outputs` +and `command` grow with what is declared; a generated tree of two hundred files +is two hundred `output` calls. Until 2026.9.13.1 the bundled module held each +list in a fixed array (8192 bytes of serialised JSON for `inputs` and +`outputs`) and refused a declaration that did not fit, so a consumer's +checkout depth decided whether a list of forty files was accepted. What +remains bounded is the **command at run time**, by the operating system's +limit on a process's arguments (128 KiB per argument on Linux, 32767 +characters for a Windows `CreateProcess`); that is a limit on the tool's own +command line, and a tool that takes hundreds of files takes them through a +response file or a directory argument of its own. The engine's own guard +against it measures the command, not the edge: an action's inputs and +outputs are graph edges, never argv. + For a generated **module interface**, declare its interface too: ```cpp @@ -705,6 +719,7 @@ none to rely on), and the only interpolations are a closed set: | `${mcpp.compile_db}` | path to `compile_commands.json` (what clang-tidy's `-p` wants) | | `${mcpp.target_file:}` | the built file of target `` | | `${mcpp.stage_dir}` *(2026.9.11.1+)* | the tree `mcpp pack` staged, absolute. `artifact` role only, and only under `mcpp pack --format ` | +| `${mcpp.self}` *(2026.9.13.1+)* | the engine's own executable, absolute. An action's command is an argv with no shell, so a build program has no portable way to copy a file; the engine is present wherever a build runs, and `${mcpp.self} stage --verify content --output ` copies one file, creates the destination's parent, and writes only when the bytes differ. That argument shape is a contract from 2026.9.13.1 on; a build program that names it declares that release as its floor | The raw stdout protocol above remains the low-level substrate; `import mcpp;` is the typed layer over it. diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index d017b5b7..062d4d57 100644 --- a/docs/zh/30-build-mcpp.md +++ b/docs/zh/30-build-mcpp.md @@ -470,6 +470,16 @@ mcpp 为那条边写出 `depfile =` 与 `deps = gcc`,ninja 读取该文件并把 所以名字未知的产物无法构建。内容可以晚到,名字不行。畸形 action 是**硬错误**, 绝不静默跳过。 +**列表没有声明上的长度上限**(2026.9.13.1+)。`inputs`、`outputs` 与 `command` +随声明增长;一棵两百个文件的生成树就是两百次 `output` 调用。2026.9.13.1 之前, +内置模块把每个列表放在定长数组里(`inputs` 与 `outputs` 各 8192 字节的序列化 +JSON),放不下的声明会被拒绝,于是一个消费者的 checkout 深度决定了四十个文件的 +列表能否被接受。仍然有上限的是**运行期的命令**,由操作系统对进程参数的限制决定 +(Linux 上每个参数 128 KiB,Windows 的 `CreateProcess` 是 32767 个字符);那是 +工具自己命令行的上限,一个要接收几百个文件的工具用它自己的 response file 或目录 +参数来接收。引擎自己针对它的守卫量的是命令而不是边:action 的输入与输出是图上的 +边,从不进入 argv。 + 生成**模块接口**时,把它的接口也声明出来: ```cpp @@ -606,6 +616,7 @@ mcpp 会写出 `<暂存树>.stage-manifest` —— 一个兄弟文件,永不是 | `${mcpp.compile_db}` | `compile_commands.json` 的路径(clang-tidy 的 `-p` 要的就是它) | | `${mcpp.target_file:}` | target `` 构建出的文件 | | `${mcpp.stage_dir}` *(2026.9.11.1+)* | `mcpp pack` 暂存出的那棵树,绝对路径。仅 `artifact` role 可用,且仅在 `mcpp pack --format ` 下可用 | +| `${mcpp.self}` *(2026.9.13.1+)* | 引擎自己的可执行文件,绝对路径。action 的命令是没有 shell 的 argv,构建程序因此没有可移植的拷贝手段;引擎在构建运行的每台机器上都在,`${mcpp.self} stage --verify content --output ` 拷贝一个文件、创建目标的父目录、只在字节不同时写入。这个参数形状自 2026.9.13.1 起是契约;写下它的构建程序即以该版本为下限 | 上面的裸 stdout 协议仍是底层基底;`import mcpp;` 是其上的类型化层。 diff --git a/mcpp.toml b/mcpp.toml index d2b09b4e..bf2ddfa9 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.12.4" +version = "2026.9.13.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/buildmcpp/src/directives.cppm b/modules/buildmcpp/src/directives.cppm index 9888893b..a63e74d1 100644 --- a/modules/buildmcpp/src/directives.cppm +++ b/modules/buildmcpp/src/directives.cppm @@ -1088,20 +1088,21 @@ std::string deploy_directive_error(const mcpp::manifest::Manifest& m, const Dire std::string action_error(const Directives& d) { for (auto const& payload : d.at(Slot::Actions)) { - // The typed API sets this when an argv did not fit its fixed buffer. - // Diagnosed separately because "malformed action" would send the - // author looking for a typo in something that was actually correct - // and merely too long. + // The typed API sets this when the build program could not allocate + // memory for one of the action's lists. Diagnosed separately because + // "malformed action" would send the author looking for a typo in a + // declaration that was correct and merely cut short. The lists have + // no declared size limit (they had one, 8192 bytes of serialised + // JSON, until 2026.9.13.1); the OS bounds the COMMAND's argv at run + // time, and that is a limit of the tool's own command line, which a + // response file or a directory argument shortens. if (payload.find("\"overflow\":true") != std::string::npos) { return std::format( - "build.mcpp declared an action whose arguments did not fit.\n" - " The typed `mcpp::action` builder uses fixed buffers " - "(the bundled module has to stay\n" - " buildable before a std module exists, so it cannot use " - "std::string).\n" - " Shorten the command — e.g. pass a response file, or a " - "directory instead of\n" - " enumerating its files.\n" + "build.mcpp declared an action whose lists could not be stored.\n" + " The build program ran out of memory while collecting the " + "action's inputs,\n" + " outputs or command, so the declaration is incomplete " + "and cannot be used.\n" " payload: {}", payload); } if (decode_action(payload)) continue; diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 9b20a4d6..9ac984c3 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.9.12.4"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.13.1"; } // namespace mcpp diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 6ac6c67c..31a6b955 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -204,21 +204,21 @@ struct action { // field existed. See BuildAction::depfile (modules/manifest/src/types.cppm) // for why `inputs` alone cannot express what this covers. const char* depfile = ""; - action& input(const char* p) { add(inputs_, sizeof inputs_, p); return *this; } - action& output(const char* p) { add(outputs_, sizeof outputs_, p); return *this; } - action& arg(const char* a) { add(command_, sizeof command_, a); return *this; } + action& input(const char* p) { add(inputs_, p); return *this; } + action& output(const char* p) { add(outputs_, p); return *this; } + action& arg(const char* a) { add(command_, a); return *this; } // Declare what a generated MODULE INTERFACE provides/imports. Same // "declare instead of discover" trade [modules].scan_overrides makes, and // what lets a generated .cppm exist as a graph node at all. - action& provides(const char* n) { add(provides_, sizeof provides_, n); return *this; } - action& imports(const char* n) { add(imports_, sizeof imports_, n); return *this; } + action& provides(const char* n) { add(provides_, n); return *this; } + action& imports(const char* n) { add(imports_, n); return *this; } // Object only: which link unit receives the outputs. Omit for "every image // this package produces" — which INCLUDES test binaries, and is what you // want: their names come from tests/*.cpp, so spelling one here breaks // plain `mcpp build`, where that link unit does not exist. An Artifact reads // its target out of ${mcpp.target_file:NAME}; an Object runs before the link // and has no such handle, so it has to say the name. - action& target(const char* n) { add(targets_, sizeof targets_, n); return *this; } + action& target(const char* n) { add(targets_, n); return *this; } void submit() const { std::printf("mcpp:action={\"id\":"); esc(id); std::printf(",\"role\":"); esc(role); @@ -232,25 +232,69 @@ struct action { // has nothing to do with depfiles. The decoder's default (empty // string) is identical either way, so omission costs nothing on read. if (depfile[0]) { std::printf(",\"depfile\":"); esc(depfile); } - // A truncated argv would otherwise be INVALID rather than obviously - // wrong — the engine turns this marker into a diagnostic that names - // the limit, instead of a generic "malformed action". + // Set only when the process could not allocate memory for a list. + // A declaration cut short would otherwise be INVALID rather than + // obviously wrong -- the engine turns this marker into a diagnostic + // that names the cause, instead of a generic "malformed action". if (overflow_) std::printf(",\"overflow\":true"); - std::printf(",\"inputs\":[%s]", inputs_); - std::printf(",\"outputs\":[%s]", outputs_); - std::printf(",\"command\":[%s]", command_); - std::printf(",\"provides\":[%s]", provides_); - std::printf(",\"imports\":[%s]", imports_); - std::printf(",\"targets\":[%s]", targets_); + std::printf(",\"inputs\":[%s]", inputs_.c_str()); + std::printf(",\"outputs\":[%s]", outputs_.c_str()); + std::printf(",\"command\":[%s]", command_.c_str()); + std::printf(",\"provides\":[%s]", provides_.c_str()); + std::printf(",\"imports\":[%s]", imports_.c_str()); + std::printf(",\"targets\":[%s]", targets_.c_str()); std::printf("}\n"); } private: - // Fixed buffers because this module must stay buildable BEFORE a std BMI - // exists (it is what a build.mcpp imports, and it may be compiled first) — - // so no std::string. Sizes chosen for real generator invocations: a protoc - // command line with many -I paths runs long. - char inputs_[8192]{}, outputs_[8192]{}, command_[16384]{}, - provides_[2048]{}, imports_[2048]{}, targets_[1024]{}; + // One list field, held already serialised (`"a","b"`) so submit() prints + // it as it is. Owning and std-free, and both words are constraints this + // module carries: it may be compiled before a std BMI exists, so it must + // not `import std;`, and its exported interface must name no std type, so + // `std::string` may not appear in a signature. Neither forbids the heap: + // storage is `realloc` from the `` already in the global module + // fragment, and no exported signature mentions this type. + // + // An earlier revision held six fixed arrays (8192 bytes for `inputs` and + // `outputs`, chosen for a protoc command line) and a declaration that did + // not fit was refused. The bound was in bytes of serialised JSON, so a + // consumer's checkout depth decided whether a resource list of 44 files + // fit (HuxerUI#130 measured the margin at 45 bytes), and `outputs` is the + // one list an author cannot shorten: an output the program does not name + // cannot be built, and there is no depfile for outputs. See + // .agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md. + struct list { + char* p = nullptr; + unsigned long len = 0, cap = 0; + list() = default; + list(const list& o) { take(o); } + list& operator=(const list& o) { if (this != &o) { len = 0; take(o); } return *this; } + ~list() { std::free(p); } + const char* c_str() const { return p ? p : ""; } + // Grows by doubling. False only when the allocator refuses. + bool reserve(unsigned long need) { + if (need <= cap) return true; + unsigned long c = cap ? cap : 256; + while (c < need) c *= 2; + void* q = std::realloc(p, c); + if (!q) return false; + p = static_cast(q); + cap = c; + return true; + } + bool put(char c) { + if (!reserve(len + 2)) return false; + p[len++] = c; + p[len] = 0; + return true; + } + void take(const list& o) { + if (!o.len) { if (p) p[0] = 0; return; } + if (!reserve(o.len + 1)) return; + for (unsigned long i = 0; i <= o.len; ++i) p[i] = o.p[i]; + len = o.len; + } + }; + list inputs_, outputs_, command_, provides_, imports_, targets_; mutable bool overflow_ = false; static void esc(const char* s) { std::putchar('"'); @@ -265,22 +309,30 @@ private: } std::putchar('"'); } - // Capacity is a PARAMETER. The previous revision hardcoded 4096 while the - // smallest buffer here was 1024 — a bound living somewhere other than next - // to the array it bounds is exactly the shape that overflows. - bool add(char* buf, unsigned long cap, const char* s) { - unsigned long o = 0; while (buf[o]) ++o; - if (o + 4 >= cap) { overflow_ = true; return false; } - if (o) buf[o++] = ','; - buf[o++] = '"'; - for (const char* p = s; *p; ++p) { - if (o + 3 >= cap) { buf[o] = 0; overflow_ = true; return false; } - if (*p == '"' || *p == '\\') buf[o++] = '\\'; - buf[o++] = *p; + // Appends one JSON string literal, with the escaping `esc` applies, so a + // list entry and a scalar field are encoded by one rule. A payload that + // decoded under the fixed-array revision is encoded to the same bytes + // here: that revision escaped `"` and `\\` and passed control characters + // through, and a control character passed through was not JSON, so no + // payload the engine accepted contained one. + bool add(list& l, const char* s) { + bool ok = true; + if (l.len) ok = ok && l.put(','); + ok = ok && l.put('"'); + for (const char* p = s; ok && *p; ++p) { + unsigned char c = (unsigned char)*p; + if (c == '"' || c == '\\') { ok = l.put('\\') && l.put((char)c); continue; } + if (c < 0x20) { + static const char hex[] = "0123456789abcdef"; + ok = l.put('\\') && l.put('u') && l.put('0') && l.put('0') + && l.put(hex[c >> 4]) && l.put(hex[c & 0xf]); + continue; + } + ok = l.put((char)c); } - buf[o++] = '"'; - buf[o] = 0; - return true; + ok = ok && l.put('"'); + if (!ok) overflow_ = true; + return ok; } }; inline void rerun_if_changed(const char* path) { std::printf("mcpp:rerun-if-changed=%s\n", path); } diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 45f06003..8d2ade09 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -2838,6 +2838,15 @@ std::optional check_rule_commands_name_a_program( std::optional check_inline_command_lengths(const std::string& manifest) { std::set rspRules; + // A rule whose command names neither `$in` nor `$out` puts a FIXED string + // on the command line, however many files its edges list: the declared + // action rules (`mcpp_action_`) are built this way, their argv baked + // into the rule and their inputs and outputs present only so that ninja + // can order and re-run them. For those the edge line is not a proxy for + // the command -- an action with two hundred outputs was refused on + // Windows with the whole list counted as argv (e2e 659, 2026-09-13) -- + // so the command text itself is what gets measured. + std::map literalCommand; std::string current; for (auto line : manifest | std::views::split('\n')) { std::string_view l{line.begin(), line.end()}; @@ -2846,6 +2855,11 @@ std::optional check_inline_command_lengths(const std::string& manif } else if (!current.empty() && l.find("rspfile") != std::string_view::npos && l.find("rspfile_content") == std::string_view::npos) { rspRules.insert(current); + } else if (!current.empty() && l.starts_with(" command = ")) { + auto cmd = l.substr(std::string_view(" command = ").size()); + if (cmd.find("$in") == std::string_view::npos + && cmd.find("$out") == std::string_view::npos) + literalCommand[current] = std::string(cmd); } else if (l.empty()) { current.clear(); } @@ -2867,13 +2881,26 @@ std::optional check_inline_command_lengths(const std::string& manif if (rule == "phony") continue; // `sh -c` on POSIX; on windows nothing needs a shell since #261. + std::string_view measured = l; + if (auto lit = literalCommand.find(rule); lit != literalCommand.end()) + measured = lit->second; auto over = mcpp::build::cmdlimits::check_inline( - l, mcpp::platform::is_windows, /*needsShell=*/!mcpp::platform::is_windows); + measured, mcpp::platform::is_windows, /*needsShell=*/!mcpp::platform::is_windows); if (!over) continue; + // The edge is named by its FIRST output and the count of the rest: + // an edge with hundreds of outputs would otherwise print every one + // of them into a diagnostic whose point is to name the edge. auto out = l.substr(6, colon - 6); + std::size_t nOut = 0; + for (auto tok : out | std::views::split(' ')) + if (!std::string_view{tok.begin(), tok.end()}.empty()) ++nOut; + std::string_view first = out.substr(0, out.find(' ')); + std::string named = nOut > 1 + ? std::format("{} (and {} more outputs)", first, nOut - 1) + : std::string(first); return mcpp::build::cmdlimits::explain( - std::format("build edge '{}' (rule {})", out, rule), *over); + std::format("build edge '{}' (rule {})", named, rule), *over); } return std::nullopt; } diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 9b20fa89..9e7dca67 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -11138,6 +11138,18 @@ prepare_build(bool print_fingerprint, rep("${mcpp.out_dir}", ctx.plan.outputDir.string()); rep("${mcpp.bin_dir}", (ctx.plan.outputDir / "bin").string()); rep("${mcpp.compile_db}", ctx.plan.compileDbPath.string()); + // The engine's own executable, absolute (2026.9.13.1+). An action + // whose command is an argv with no shell has no portable way to + // copy, touch or compare a file, and the engine is the one + // program present wherever a build runs -- the reason a `check` + // is wrapped with `mcpp __action-stamp` (ninja_backend.cppm). This + // token lets a build program say the same thing: `${mcpp.self} + // stage --verify content --output ` is the copy every + // `stage_file` edge already performs. The same caveat as the + // wrapper's: a version change regenerates build.ninja, and a + // binary moved under an unchanged version leaves a stale path, + // exactly as it would for the compiler. + rep("${mcpp.self}", mcpp::platform::fs::self_exe_path().string()); // ABSOLUTE, unlike `${mcpp.target_file:}` and for the same reason // stated the other way round: the staged tree lives outside the // build directory and no ninja edge produces it, so there is no diff --git a/src/cli.cppm b/src/cli.cppm index ba1149f9..e2d8e010 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -854,12 +854,17 @@ int run(int argc, char** argv) { .option(cl::Option("expect-none") .help("(verification) planner assumed no provides/imports")) .action(wrap_rc(cmd_dyndep))) + // Named by generated build.ninja edges and, since 2026.9.13.1, by a + // build program's own actions through `${mcpp.self}` (docs/30), so + // the argument shape below is a contract: `stage --verify content + // --output ` copies one file, creates the destination's + // parent, and writes only when the bytes differ. .subcommand(cl::App("stage") - .description("(internal: invoked by ninja) Stage a cached artifact into the build dir") + .description("Copy one file into place: create the destination's parent, write only when the content differs (invoked by build.ninja and by ${mcpp.self} actions)") .option(cl::Option("output").short_name('o').takes_value().value_name("PATH") - .help("Destination path inside the build directory")) + .help("Destination path; its parent directory is created")) .option(cl::Option("verify").takes_value().value_name("MODE") - .help("Already-staged check: size (default) | content")) + .help("How an existing destination is judged up to date: content (default) | size")) .action(wrap_rc(cmd_stage))) .subcommand(cl::App("coff-def") .description("(internal: invoked by ninja) Write a .def of every exportable symbol in the given COFF objects") diff --git a/tests/e2e/659_an_action_declaration_has_no_size_limit.sh b/tests/e2e/659_an_action_declaration_has_no_size_limit.sh new file mode 100755 index 00000000..3953155d --- /dev/null +++ b/tests/e2e/659_an_action_declaration_has_no_size_limit.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# 659_an_action_declaration_has_no_size_limit.sh -- `mcpp::action`'s list +# fields have no declared size limit (2026.9.13.1). +# +# Until this release the bundled `mcpp` module collected an action's inputs, +# outputs and command into fixed arrays (8192 bytes of serialised JSON for +# `inputs` and `outputs`, 16384 for `command`) and refused a declaration that +# did not fit with "arguments did not fit". The bound was in bytes, so a +# consumer's checkout depth decided whether a list of 44 resource files fit +# (HuxerUI#130 measured the margin at 45 bytes), and `outputs` is the one list +# an author cannot shorten: an output the program does not name cannot be +# built, and there is no depfile for outputs. +# +# This fixture declares one action whose serialised `inputs` and `outputs` each +# exceed the old bound by construction, and asserts that the whole declaration +# reaches the graph: `build.ninja` carries an edge with exactly N outputs and N +# inputs, N being this file's constant and not a number read back from the +# engine. The reverse leg -- the same fixture on 2026.9.12.4 is refused with +# the overflow diagnostic -- was run once before merge and is recorded in the +# pull request; CI cannot run two engines on one fixture. +# +# The command is the engine itself, which is present on every shard, exits 0 +# and writes nothing, so the fixture runs everywhere the suite runs (no +# `# requires:` line, as 313 does). The outputs are therefore not produced; +# ninja does not fail on an unproduced output (313 states the measurement), and +# producing files is the command's business, covered by 188. What is under +# test here is the declaration channel. +# +# N is chosen so that the action's EDGE LINE in build.ninja (outputs, rule, +# inputs) is longer than the POSIX per-argument limit of 128 KiB as well as +# Windows's 32767-character CreateProcess limit. That is the second thing this +# fixture measures: the engine's command-length guard used to read the edge +# line as a proxy for the command, which is right for a rule that expands +# `$in` and `$out` and wrong for an action rule, whose command is a literal +# argv and whose inputs and outputs exist only so that ninja can order and +# re-run it. Its first run on the Windows shard refused this fixture with the +# whole list counted as argv; the guard now measures a literal command's own +# text, and the same fixture is what holds that on every shard. +# +# A second action carries a command past 16384 bytes, the old bound of the +# command list, on the POSIX hosts: its command is `true`, a program that +# accepts any argv and exits 0, which Windows does not have. 19200 bytes is +# under every operating system's limit on a process's arguments; what remains +# bounded at run time is the tool's own argv, by the OS (the design record's +# section 2.4), and that is not the engine's bound on the declaration. +set -e + +source "$(dirname "$0")/_host_path.sh" + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +readonly N=600 + +mkdir -p app/src app/in +cd app + +cat > mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[targets.app] +kind = "bin" +main = "src/main.cpp" +EOF + +cat > src/main.cpp <<'EOF' +#include +int main() { std::puts("LARGE_DECLARATION_OK"); } +EOF + +# N input files, so that ninja finds every declared input on disk. Each path +# is padded to a fixed width with a long directory name, so the serialised +# lists cross the old 8192-byte bound and the edge line crosses 128 KiB +# regardless of where this fixture runs: with a 100-byte directory name each +# path is at least 115 bytes, and 600 inputs plus 600 outputs put more than +# 138000 bytes on the one edge line. +PAD="a-directory-name-long-enough-to-make-the-edge-line-cross-the-per-argument-limit-of-the-host-os-xxxx" +mkdir -p "in/$PAD" +for i in $(seq 1 $N); do : > "in/$PAD/input-$i.txt"; done + +ROOT_HOST=$(host_path "$PWD") +MCPP_HOST=$(host_path "$(cd "$(dirname "$MCPP")" && pwd)/$(basename "$MCPP")") + +# The long-command leg: a program that accepts any argv and exits 0. POSIX +# hosts only (see the header). +TRUE_HOST="" +case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) ;; + *) TRUE_HOST=$(host_path "$(type -P true)") ;; +esac + +cat > build.mcpp < b1.log 2>&1 || { cat b1.log; echo "FAIL: a build with a wide action was refused"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == *LARGE_DECLARATION_OK* ]] || { echo "FAIL: the program did not run: '$out'"; exit 1; } + +NINJA=$(find target -name build.ninja -print -quit) +[ -n "$NINJA" ] || { echo "FAIL: no build.ninja"; exit 1; } + +# THE criterion: the edge in the graph names every declared output and every +# declared input. Counted against N, not against anything the engine reports. +# Only the action's own edge is read (`build : mcpp_action_ +# `): the engine also lists a source action's outputs a second time, +# as the inputs of the package's ordering phony, and counting the whole file +# would report 2N. +count_in_edge() { + # $1 = the substring that identifies the file kind + grep ': mcpp_action_' "$NINJA" | tr -s ' ' '\n' | grep -c "$1" || true +} +outs=$(count_in_edge "output-[0-9]*\.txt") +ins=$(count_in_edge "input-[0-9]*\.txt") +[ "$outs" = "$N" ] || { echo "FAIL: build.ninja names $outs of $N declared outputs"; exit 1; } +[ "$ins" = "$N" ] || { echo "FAIL: build.ninja names $ins of $N declared inputs"; exit 1; } +echo "ok: the edge carries $N outputs and $N inputs" + +if [ -n "$TRUE_HOST" ]; then + stamp=$(find target -name 'long-command.stamp' | wc -l | tr -d '[:space:]') + [ "$stamp" = "1" ] || { cat b1.log; echo "FAIL: the long-command check did not run (stamps: $stamp)"; exit 1; } + echo "ok: a command of 19200 bytes ran" +fi + +# The overflow marker must be absent: the refusal path of the old bound is the +# only thing that ever wrote it, and the message it now carries is about +# allocation failure, which this build did not have. +if grep -q '"overflow":true' b1.log; then cat b1.log; echo "FAIL: the overflow marker appeared"; exit 1; fi + +# Replay: an unrelated source is added, so the project fast path is off and +# the build program's cache record is replayed rather than re-run. The edge +# must come back complete from the record, not only from a live run. +cat > src/extra.cpp <<'EOF' +int extra() { return 1; } +EOF +"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: rebuild after an unrelated source failed"; exit 1; } +NINJA=$(find target -name build.ninja -print -quit) +outs=$(count_in_edge "output-[0-9]*\.txt") +[ "$outs" = "$N" ] || { echo "FAIL: after a replay build.ninja names $outs of $N declared outputs"; exit 1; } +echo "ok: the replayed record carries all $N outputs" + +# The negative direction of the guard: a LITERAL command that really is too +# long for the host is still refused, before anything is compiled, and the +# refusal names the edge by its first output rather than by every output. +# 2200 arguments of 64 bytes is 140800 bytes, over the POSIX 128 KiB +# per-argument limit and over Windows's 32767 alike, so this leg runs on +# every shard. The program named does not have to exist: the guard fires +# while build.ninja is being written. +mkdir -p ../too-long/src +cp mcpp.toml ../too-long/ && cp src/main.cpp ../too-long/src/ +cd ../too-long +cat > build.mcpp < b4.log 2>&1; then + cat b4.log; echo "FAIL: a 140800-byte literal command was accepted"; exit 1 +fi +grep -q "over the .* byte limit" b4.log || { cat b4.log; echo "FAIL: the refusal is not the command-length guard's"; exit 1; } +grep -q "too-long.stamp" b4.log || { cat b4.log; echo "FAIL: the refusal does not name the edge"; exit 1; } +# One output, so no "(and N more outputs)" suffix; the whole message stays +# short enough to read. +[ "$(wc -c < b4.log)" -lt 4000 ] || { wc -c b4.log; echo "FAIL: the refusal printed the whole edge line"; exit 1; } +echo "ok: a command over the host limit is refused, naming the edge" + +echo "OK" diff --git a/tests/e2e/660_an_action_names_the_engine_through_mcpp_self.sh b/tests/e2e/660_an_action_names_the_engine_through_mcpp_self.sh new file mode 100755 index 00000000..efeaf3f8 --- /dev/null +++ b/tests/e2e/660_an_action_names_the_engine_through_mcpp_self.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# 660_an_action_names_the_engine_through_mcpp_self.sh -- `${mcpp.self}` in an +# action's argv is the engine's own executable, and `mcpp stage` through it is +# a portable copy (2026.9.13.1). +# +# An action's command is an argv with no shell assumed, so a build program had +# no portable way to copy a file: `cp` is absent on Windows, `cmd /c copy` is a +# shell and an 8191-character limit, and a copier carried by a package is a +# host-tool sub-build for one copy. The engine is the one program present +# wherever a build runs, and `mcpp stage --verify content --output ` +# is the copy every `stage_file` edge in build.ninja already performs: it +# creates the destination's parent and writes only when the bytes differ. +# `${mcpp.self}` is how an action names it, in the same substitution family as +# `${mcpp.out_dir}` and `${mcpp.target_file:}`. +# +# No `# requires:` line: the point is that this works on every shard, and the +# Windows one is the shard it exists for. The reverse leg -- under 2026.9.12.4 +# the token stays literal in build.ninja and the edge fails -- was run once +# before merge and is recorded in the pull request. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +mkdir -p app/src +cd app + +cat > mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[targets.app] +kind = "bin" +main = "src/main.cpp" +EOF + +cat > src/main.cpp <<'EOF' +#include +int main() { std::puts("SELF_COPY_OK"); } +EOF + +cat > build.mcpp <<'EOF' +import std; +import mcpp; +int main() { + mcpp::action a; + a.id = "copy"; + a.role = "artifact"; + a.description = "the linked program, copied by the engine"; + a.arg("${mcpp.self}").arg("stage").arg("--verify").arg("content") + .arg("--output").arg("${mcpp.out_dir}/copied/deeper/app-copy") + .arg("${mcpp.target_file:app}") + .input("${mcpp.target_file:app}") + .output("${mcpp.out_dir}/copied/deeper/app-copy") + .submit(); +} +EOF + +"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: build failed"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == *SELF_COPY_OK* ]] || { echo "FAIL: the program did not run: '$out'"; exit 1; } + +NINJA=$(find target -name build.ninja -print -quit) +[ -n "$NINJA" ] || { echo "FAIL: no build.ninja"; exit 1; } + +# The token is substituted, never emitted literally. +if grep -q 'mcpp\.self' "$NINJA"; then + grep -n 'mcpp\.self' "$NINJA" | head -3 + echo "FAIL: \${mcpp.self} reached build.ninja unsubstituted"; exit 1 +fi + +# The copy exists, in a directory the action never created itself, and is the +# program byte for byte: the only way it got there is the engine's `stage`. +COPY=$(find target -path '*/copied/deeper/app-copy' -print -quit) +[ -n "$COPY" ] || { cat b1.log; echo "FAIL: the copy action produced nothing"; exit 1; } +BIN=$(find target -path '*/bin/app' -print -quit) +[ -n "$BIN" ] || BIN=$(find target -path '*/bin/app.exe' -print -quit) +[ -n "$BIN" ] || { echo "FAIL: no linked program under target/"; exit 1; } +cmp -s "$BIN" "$COPY" || { echo "FAIL: the copy differs from the program"; exit 1; } +echo "ok: the engine copied the program through \${mcpp.self}" + +# A no-op rebuild leaves the copy alone: the edge is satisfied by its output, +# and `stage --verify content` would write nothing even if it ran. +before=$(stat -c %Y "$COPY" 2>/dev/null || stat -f %m "$COPY") +sleep 1 +"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: no-op rebuild failed"; exit 1; } +after=$(stat -c %Y "$COPY" 2>/dev/null || stat -f %m "$COPY") +[ "$before" = "$after" ] || { echo "FAIL: the copy was rewritten on a no-op rebuild ($before -> $after)"; exit 1; } +echo "ok: a no-op rebuild copied nothing" + +echo "OK" diff --git a/tests/unit/test_build_directives.cpp b/tests/unit/test_build_directives.cpp index dada6cee..ac837f84 100644 --- a/tests/unit/test_build_directives.cpp +++ b/tests/unit/test_build_directives.cpp @@ -721,6 +721,27 @@ TEST(BuildDirectives, DecodeActionDefaultsDepfileToEmptyWhenAbsent) { EXPECT_EQ(a->depfile, ""); } +// The overflow marker. Until 2026.9.13.1 `mcpp::action` held its lists in +// fixed arrays and set the marker when a declaration did not fit; the lists +// now grow, and the marker means the build program could not allocate. The +// refusal stays -- an incomplete declaration must never be used -- and the +// message has to say what is now true: nothing about a buffer size, nothing +// recommending a response file for a list that no longer needs one. +TEST(BuildDirectives, OverflowMarkerIsRefusedAsAllocationFailure) { + auto d = parse( + "mcpp:action={\"id\":\"wide\",\"role\":\"source\"," + "\"description\":\"\",\"blocking\":false,\"overflow\":true," + "\"inputs\":[],\"outputs\":[\"out/a.txt\"]," + "\"command\":[\"gen\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); + auto err = dirs::action_error(d); + ASSERT_FALSE(err.empty()); + EXPECT_NE(err.find("could not be stored"), std::string::npos); + EXPECT_NE(err.find("out of memory"), std::string::npos); + EXPECT_EQ(err.find("fixed buffer"), std::string::npos); + EXPECT_EQ(err.find("response file"), std::string::npos); + EXPECT_EQ(err.find("did not fit"), std::string::npos); +} + // ── #618: a named executable's subsystem and entry ────────────────────────── // // `windows-subsystem` and `windows-entry` name a target of the package being