From 9c5ba12ed134de5b8c3bf10b0198c0ba97bfc7ba Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:32:33 +0800 Subject: [PATCH 01/17] a lock records one entry per identity, spelled as 2026.9.28.3 spelled it, and a planned build of a workspace leaves a committed lock unchanged In a workspace plan state.m is the virtual root, so the lock name of a root-declared index dependency fell through to the qualified name (cmdline) instead of the root's map key (mcpplibs.cmdline), and the hash, taken over the spelled name, changed with it. The partial-workspace merge keyed prior entries by the spelled name and kept the old entry beside the new one. The name is now looked up in the workspace root's and the selected members' manifests, the merge and the --locked comparison work on (namespace, short name), and a lock that holds one identity twice keeps its first entry. Adds e2e 844. --- src/build/prepare/records.cpp | 71 ++++++- ...build_keeps_one_lock_entry_per_identity.sh | 173 ++++++++++++++++++ 2 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/844_a_planned_build_keeps_one_lock_entry_per_identity.sh diff --git a/src/build/prepare/records.cpp b/src/build/prepare/records.cpp index e3a41e15..d15f36cc 100644 --- a/src/build/prepare/records.cpp +++ b/src/build/prepare/records.cpp @@ -81,6 +81,26 @@ namespace mcpp::build { // runs, called from it in the same order, and are declared in the `:state` // partition (state.cppm) as the phases are. +// The short name of a lock entry. An index entry is written under the map key +// its declaring manifest used (`mcpplibs.cmdline` under `[dependencies.mcpplibs]`, +// `cmdline` under a bare key), so the spelled name may carry its namespace as a +// prefix; the identity is (namespace, short name) whichever way it was spelled. +// An entry without a namespace (a git entry, keyed by the root's map key) is its +// own short name. +static std::string lock_short_name(const mcpp::lockfile::LockedPackage& p) { + if (!p.namespace_.empty()) { + const std::string prefix = p.namespace_ + "."; + if (p.name.size() > prefix.size() && p.name.starts_with(prefix)) + return p.name.substr(prefix.size()); + } + return p.name; +} + +// The identity two entries of one lock are compared by. +static std::string lock_identity(const mcpp::lockfile::LockedPackage& p) { + return p.namespace_ + "\x1f" + lock_short_name(p); +} + std::expected step13_lockfile(PrepareState& state, BuildContext& ctx) { // Write/update mcpp.lock for any version-based deps that succeeded. // Path deps are intentionally NOT locked — their source is local filesystem. @@ -102,10 +122,33 @@ std::expected step13_lockfile(PrepareState& state, BuildConte // lookup uses, and changing it would silently unpin every branch dep. // A dep reached only transitively has no such key, so it is written // under its fully-qualified identity. + // + // In a workspace plan `state.m` is the virtual root, which declares + // nothing; the manifests that declare are the workspace root's own + // package and the selected members. Reading only `state.m` there made + // every root-declared dependency fall through to the qualified name, so + // 2026.9.29.1 wrote `cmdline` (and a different hash, which is taken + // over the spelled name) where 2026.9.28.3 had written the root's map + // key `mcpplibs.cmdline`, and the merge below kept both. The map key is + // the spelling that existing committed locks hold, and it is the one + // #329 already fixes for git entries, so it stays; the merge and the + // `--locked` comparison work on the identity, not on the spelling. auto lock_name_for = [&](const ResolvedKey& k) -> std::string { - for (auto const& [n, s] : state.m->dependencies) { - const std::string sn = s.shortName.empty() ? n : s.shortName; - if (s.namespace_ == k.ns && sn == k.shortName) return n; + auto declared = [&](const mcpp::manifest::Manifest& mf) + -> std::optional { + for (auto const& [n, s] : mf.dependencies) { + const std::string sn = s.shortName.empty() ? n : s.shortName; + if (s.namespace_ == k.ns && sn == k.shortName) return n; + } + return std::nullopt; + }; + if (auto n = declared(*state.m)) return *n; + if (state.workspacePlan()) { + if (state.wsManifest) + if (auto n = declared(*state.wsManifest)) return *n; + for (std::size_t i = 1; i < state.packages.size(); ++i) + if (state.packages[i].selectedMember) + if (auto n = declared(state.packages[i].manifest)) return *n; } return mcpp::pm::compat::qualified_name(k.ns, k.shortName); }; @@ -151,6 +194,7 @@ std::expected step13_lockfile(PrepareState& state, BuildConte // Version deps: the whole resolved graph, at the versions actually // chosen. `resolved` is an ordered map, so the file is deterministic. + std::set versionLocked; for (auto const& [key, rec] : state.resolved) { if (rec.source != "version") continue; // path / git handled elsewhere if (rec.version.empty()) continue; @@ -178,6 +222,9 @@ std::expected step13_lockfile(PrepareState& state, BuildConte // prefix claimed otherwise. `index_package_digest` is FNV-1a on // every host. lp.hash = mcpp::pm::index_package_digest(sourceIndex, lp.name, lp.version); + // One entry per identity: a dependency declared by the root and by + // a member under different spellings is one package. + if (!versionLocked.insert(lock_identity(lp)).second) continue; lock.packages.push_back(std::move(lp)); } // A workspace's lock is at its root and records every member's @@ -196,13 +243,16 @@ std::expected step13_lockfile(PrepareState& state, BuildConte } if (partialWorkspace) { if (auto prior = mcpp::lockfile::load(state.workRoot / "mcpp.lock")) { - auto keyOf = [](const mcpp::lockfile::LockedPackage& p) { - return p.namespace_ + "\x1f" + p.name; - }; + // By identity, not by spelling: a prior entry that names a + // package resolved here under another spelling is that + // package's older record and is dropped, and a prior lock that + // holds one identity twice (written by 2026.9.29.1 to .5) keeps + // its first entry only. std::set resolvedHere; - for (auto const& p : lock.packages) resolvedHere.insert(keyOf(p)); + for (auto const& p : lock.packages) resolvedHere.insert(lock_identity(p)); for (auto const& p : prior->packages) - if (!resolvedHere.contains(keyOf(p))) lock.packages.push_back(p); + if (resolvedHere.insert(lock_identity(p)).second) + lock.packages.push_back(p); std::set indicesHere; for (auto const& i : lock.indices) indicesHere.insert(i.name); for (auto const& i : prior->indices) @@ -234,8 +284,9 @@ std::expected step13_lockfile(PrepareState& state, BuildConte lockPath.string())); } auto key = [](const mcpp::lockfile::LockedPackage& p) { - return p.namespace_.empty() ? p.name - : p.namespace_ + "." + p.name; + const std::string sn = lock_short_name(p); + return p.namespace_.empty() ? sn + : p.namespace_ + "." + sn; }; std::map was, now; for (auto const& p : prior->packages) was[key(p)] = p.version; diff --git a/tests/e2e/844_a_planned_build_keeps_one_lock_entry_per_identity.sh b/tests/e2e/844_a_planned_build_keeps_one_lock_entry_per_identity.sh new file mode 100644 index 00000000..bb006b8d --- /dev/null +++ b/tests/e2e/844_a_planned_build_keeps_one_lock_entry_per_identity.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# requires: gcc python3 fresh-sandbox +# 844 -- a planned build of a workspace root keeps ONE mcpp.lock entry per +# identity, spelled as the root's manifest spells it (defect F11). +# +# A workspace root that also has a [package] and declares an index dependency +# under `[dependencies.mcpplibs]` has the map key `mcpplibs.cmdline`, and +# 2026.9.28.3 locked it under that name. Since 2026.9.29.1 a build of the root +# is a plan of a workspace under a virtual root whose own dependency table is +# empty, so the lock name fell through to the qualified name `cmdline` (with a +# hash taken over that spelling), and the partial-workspace merge, which keyed +# prior entries by the spelled name, kept the old entry beside it: two entries +# for one identity, and a committed lock modified by every build. +# +# (a) a lock in the 2026.9.28.3 spelling is left byte-identical; +# (b) a lock that holds both spellings is reduced to one entry; +# (c) a second planned build leaves the result byte-identical. +# +# The dependency is served by a project-local index and pre-extracted into the +# private xlings data directory, so the build touches no network. +set -e + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && cat "$2"; exit 1; } + +export MCPP_HOME="$TMP/mcpp-home" +source "$(dirname "$0")/_inherit_toolchain.sh" + +mkdir -p "$TMP/ws" && cd "$TMP/ws" + +mkdir -p local-index/pkgs/c +cat > local-index/pkgs/c/cmdline.lua <<'EOF' +package = { + spec = "1", + namespace = "mcpplibs", + name = "cmdline", + description = "fixture for the one-entry-per-identity lock (F11)", + licenses = {"MIT"}, + type = "package", + xpm = { linux = { ["1.0.0"] = { + url = "https://example.invalid/cmdline-1.0.0.tar.gz", + sha256 = "0000000000000000000000000000000000000000000000000000000000000000", + } } }, + mcpp = { + language = "c++23", + import_std = false, + sources = { "src/cmdline.cppm" }, + targets = { ["cmdline"] = { kind = "lib" } }, + deps = {}, + }, +} +EOF +XP=".mcpp/.xlings/data/xpkgs/mcpplibs-x-cmdline/1.0.0" +mkdir -p "$XP/src" +cat > "$XP/src/cmdline.cppm" <<'EOF' +export module fixture.cmdline; +export int cmdline_value() { return 7; } +EOF +printf 'ok\n' > "$XP/.mcpp_ok" + +mkdir -p src m/src +cat > mcpp.toml < src/main.cpp <<'EOF' +import fixture.cmdline; +int main() { return cmdline_value() == 7 ? 0 : 1; } +EOF +cat > m/mcpp.toml <<'EOF' +[package] +name = "m" +version = "0.1.0" + +[targets.m] +kind = "lib" + +[build] +sources = ["src/m.cppm"] +EOF +cat > m/src/m.cppm <<'EOF' +export module m; +export int m_value() { return 1; } +EOF + +# The hash 2026.9.28.3 recorded: FNV-1a over ":@", +# name spelled as the root's map key. +digest() { + python3 - "$1" <<'EOF' +import sys +h = 0xcbf29ce484222325 +for b in sys.argv[1].encode(): + h ^= b + h = (h * 0x100000001b3) & 0xffffffffffffffff +print("fnv1a:%016x" % h) +EOF +} +OLD_HASH=$(digest "mcpplibs:mcpplibs.cmdline@1.0.0") +NEW_HASH=$(digest "mcpplibs:cmdline@1.0.0") + +write_old_lock() { + cat > mcpp.lock < build.log 2>&1 || fail "the planned build did not succeed" build.log +cmp -s lock.before mcpp.lock || { + diff lock.before mcpp.lock || true + fail "(a) a planned build rewrote a lock in the 2026.9.28.3 spelling" +} + +# (b) a lock holding both spellings is reduced to one entry per identity. +cat >> mcpp.lock <> src/main.cpp +"$MCPP" build > build2.log 2>&1 || fail "the second planned build did not succeed" build2.log +[ "$(entries)" = 1 ] || fail "(b) the lock still holds $(entries) entries for one identity" mcpp.lock +cmp -s lock.before mcpp.lock || { + diff lock.before mcpp.lock || true + fail "(b) the surviving entry is not the one 2026.9.28.3 wrote" +} + +# (c) another planned build leaves it byte-identical. +echo "// touched again" >> src/main.cpp +"$MCPP" build > build3.log 2>&1 || fail "the third planned build did not succeed" build3.log +cmp -s lock.before mcpp.lock || fail "(c) a repeated planned build changed the lock" mcpp.lock + +# --locked reads the same identities. +echo "// and again" >> src/main.cpp +MCPP_LOCKED=1 "$MCPP" build > build4.log 2>&1 || fail "--locked reported drift on an unchanged lock" build4.log + +echo "PASS: 844_a_planned_build_keeps_one_lock_entry_per_identity" From 8f63e6b1985358a25aacf127ab20159ad5e3e755 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:35:55 +0800 Subject: [PATCH 02/17] a bare key of a path or git dependency states only the short name, and a key that contradicts the declared namespace is reported once per consumer manifest Adopting the namespace a path or git manifest declares corrects nothing when the key wrote no namespace, so it is no longer reported. A key that writes a namespace (a namespace table or a dotted key) which the manifest contradicts is still reported; the reports are collected during the graph walk and emitted after resolution, one per consumer manifest and declared namespace, naming the manifest by its path relative to the project root, listing the keys, with a hint in TOML. Adoption itself is unchanged. package-identity.md 4.2 gains the scope clause; e2e 679 and 713 are revised and 679 gains a grouped-warning leg. --- docs/05-dependencies.md | 26 +++++-- docs/specs/package-identity.md | 13 +++- docs/zh/05-dependencies.md | 25 +++++-- src/build/prepare/graph.cpp | 6 +- src/build/prepare/graph_load.cpp | 69 +++++++++++++++---- src/build/prepare/state.cppm | 16 ++++- ...dependency_takes_its_manifests_identity.sh | 58 +++++++++++++--- ..._dependency_selects_a_repository_member.sh | 2 +- 8 files changed, 170 insertions(+), 45 deletions(-) diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index b54a01f3..64bac73b 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -177,20 +177,32 @@ settled by guessing which one was declared first — that is exactly the ### The identity of a `path` or `git` dependency (mcpp 2026.9.14.2+) A `path` or `git` dependency is the package its manifest declares, whatever key -reaches it. A key that normalises to another identity than the manifest's -`[package] namespace` and `name` takes the declared identity, and mcpp warns -once for each declaring edge, naming the requester, the key, the identity the -key names and the identity the manifest declares: +reaches it. The source fixes the package, so a bare key states only the short +name: it does not mean `mcpplibs`, the declared namespace is adopted, and +nothing is reported. ```toml # comp/mcpp.toml; fw/mcpp.toml declares namespace = "huxdemo" [dependencies] -fw = { path = "../fw" } # names mcpplibs.fw; huxdemo.fw is used +fw = { path = "../fw" } # huxdemo.fw is used; no warning +``` + +A key that writes a namespace (a `[dependencies.]` table or a dotted key) +states an identity, and a manifest that declares another one contradicts it. +The declared identity is still used, and mcpp warns once for each consumer +manifest and declared namespace, after resolution. The warning names the +manifest by its path relative to the project root and lists the keys: + +```toml +# mcpp.toml; libs/fw and libs/comp declare namespace = "huxdemo" +[dependencies.acme] +fw = { path = "libs/fw" } +comp = { path = "libs/comp" } ``` ``` -warning: 'huxdemo.comp@path' declares the dependency 'fw', which names mcpplibs.fw; the manifest '.../fw/mcpp.toml' declares huxdemo.fw, and that identity is used. - hint: write 'huxdemo.fw' in 'huxdemo.comp@path' to state the identity the manifest declares. +warning: mcpp.toml names 2 dependencies in namespace acme, and the manifests they reach declare huxdemo; the declared identity is used: acme.fw, acme.comp + hint: write them in a [dependencies.huxdemo] table, for example `fw = { path = "libs/fw" }` ``` Two edges written `fw` and `huxdemo.fw` over one directory are therefore one diff --git a/docs/specs/package-identity.md b/docs/specs/package-identity.md index 35c4130a..64bb1a60 100644 --- a/docs/specs/package-identity.md +++ b/docs/specs/package-identity.md @@ -172,6 +172,10 @@ e2e `163_identity_first_resolution.sh` 锁住:身份为 `(acme, widget)` 的描 例如 gtest 必须写成 `compat.gtest` 或 `[dependencies.compat] gtest = ...`;裸 `gtest` 请求的是不同身份 `(mcpplibs, gtest)`。 +**适用范围**:本条约束由索引解析的选择器(`version` 依赖)。对 `path` 或 `git` 来源,包由声明行固定, +其清单声明自己的命名空间(§5.4);此时裸键**只表示短名**,不表示 `mcpplibs`,采用清单声明的命名空间不是对键的 +纠正,也不产生告警。写出命名空间的键(`[dependencies.ns]` 表或带点的键)则声明了一个身份,清单与之矛盾时仍告警(§5.4)。 + **已实现**(2026.8.10.1)。默认 namespace 的依赖身份门禁不再接纳 `compat` 或无 namespace descriptor。 **设计理由**:全域按短名搜索会让解析结果取决于「本机装了哪些索引」—— @@ -302,10 +306,15 @@ warning,规范列举命令是 `mcpp new --list-templates pkg`。 `path` 与 `git` 依赖没有描述符查找,来源由声明行直接给定。它的身份**必须**是其清单 `[package]` 声明的 `(namespace, name)`,与指向它的键无关;键规范化后的身份与之不同时, -实现**必须**采用清单声明的身份,并对每条声明边给出一次告警,点名请求方、键、键规范化 -后的身份与清单声明的身份。同一来源(规范化后的目录,或仓库与引用)经不同的键到达时 +实现**必须**采用清单声明的身份。同一来源(规范化后的目录,或仓库与引用)经不同的键到达时 **必须**解析为同一个包。 +告警只针对写出了命名空间的键。裸键(不在 `[dependencies.]` 表内、不带点)对这类来源只表示短名 +(§4.2),采用清单声明的命名空间**不得**告警。写出命名空间而与清单矛盾的键,实现**必须**告警, +且**按声明它的清单与清单声明的命名空间分组**,每组一条:以相对项目根的路径点名声明清单,列出各个键, +提示以 TOML 写法给出(例如 `write them in a [dependencies.xlings] table, for example +cancellation = { path = "modules/cancellation" }`)。告警在解析完成之后发出。 + 采用而非拒绝:来源已由声明行固定,§4.2 反对候选搜索的理由(索引状态改变依赖的指向) 在这里不成立。 diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 0c002009..6b667990 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -162,20 +162,31 @@ sdk = { version = "1.0", visibility = "private" } ### `path` 或 `git` 依赖的身份(mcpp 2026.9.14.2+) -`path` 或 `git` 依赖就是它的 manifest 所声明的那个包,与指向它的键无关。一个 -键规范化后的身份,若不同于 manifest `[package]` 的 `namespace` 与 `name`, -就采用 manifest 声明的身份;mcpp 对每条声明边告警一次,点名请求方、键、键所指 -的身份,以及 manifest 声明的身份: +`path` 或 `git` 依赖就是它的 manifest 所声明的那个包,与指向它的键无关。来源 +已经固定了包,所以裸键只表示短名:它不表示 `mcpplibs`,采用 manifest 声明的 +命名空间,也不告警。 ```toml # comp/mcpp.toml; fw/mcpp.toml declares namespace = "huxdemo" [dependencies] -fw = { path = "../fw" } # names mcpplibs.fw; huxdemo.fw is used +fw = { path = "../fw" } # huxdemo.fw is used; no warning +``` + +写出命名空间的键(`[dependencies.]` 表或带点的键)声明了一个身份,而声明了另一个 +身份的 manifest 与之矛盾。此时仍采用 manifest 声明的身份,并在解析完成之后按声明它的 +manifest 与被声明的命名空间分组,每组告警一次;告警以相对项目根的路径点名 manifest, +并列出各个键: + +```toml +# mcpp.toml; libs/fw and libs/comp declare namespace = "huxdemo" +[dependencies.acme] +fw = { path = "libs/fw" } +comp = { path = "libs/comp" } ``` ``` -warning: 'huxdemo.comp@path' declares the dependency 'fw', which names mcpplibs.fw; the manifest '.../fw/mcpp.toml' declares huxdemo.fw, and that identity is used. - hint: write 'huxdemo.fw' in 'huxdemo.comp@path' to state the identity the manifest declares. +warning: mcpp.toml names 2 dependencies in namespace acme, and the manifests they reach declare huxdemo; the declared identity is used: acme.fw, acme.comp + hint: write them in a [dependencies.huxdemo] table, for example `fw = { path = "libs/fw" }` ``` 因此,同一目录上分别写作 `fw` 与 `huxdemo.fw` 的两条边是同一个包,只编译 diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index 39bcb698..dd42ef91 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -855,8 +855,7 @@ step4b_resolve_identity(PrepareState& state, WorklistItemCtx& ctx) { item.requestedBy, state.qualifiedKey(key), declaring.path, declaring.path)); } - state.reportAdoption(item.requestedBy, name, key, bySource->second, - declaring.path); + state.reportAdoption(item, key, bySource->second); key = bySource->second; state.stateAdoptedIdentity(item, key); } @@ -1690,7 +1689,7 @@ step4b_finalize_dependency(PrepareState& state, WorklistItemCtx& ctx) { ResolvedKey declared{ ctx.dep_manifest->package.namespace_, declaredName.shortName }; if (!(declared == key)) { - state.reportAdoption(item.requestedBy, name, key, declared, manifestPath); + state.reportAdoption(item, key, declared); state.stateAdoptedIdentity(item, declared); if (state.resolved.contains(declared)) { // Another source already resolved the declared identity, @@ -2472,6 +2471,7 @@ std::expected phase4b_graph_worklist(PrepareState& state) { if (auto r = step4b_cycle_check(state); !r) return std::unexpected(r.error()); + state.emitAdoptionWarnings(); state.computeUsageRequirements(); return {}; diff --git a/src/build/prepare/graph_load.cpp b/src/build/prepare/graph_load.cpp index 4e5104be..2b932452 100644 --- a/src/build/prepare/graph_load.cpp +++ b/src/build/prepare/graph_load.cpp @@ -205,19 +205,62 @@ static void step4a_define_split_and_identity_closures(PrepareState& state) { it->second.shortName = declared.shortName; } }; - // One warning per declaring edge: each names a line someone can correct. - state.reportAdoption = [&](const std::string& requestedBy, const std::string& written, - const ResolvedKey& normalised, const ResolvedKey& declared, - const std::string& manifestPath) { - if (!state.adoptionsReported.emplace(requestedBy, written).second) return; - mcpp::diag::warning("dependency/identity", std::format( - "'{}' declares the dependency '{}', which names {}; the manifest " - "'{}' declares {}, and that identity is used.", - requestedBy, written, state.qualifiedKey(normalised), manifestPath, - state.qualifiedKey(declared)), - std::format("write '{}' in '{}' to state the identity the " - "manifest declares.", - state.qualifiedKey(declared), requestedBy)); + // A key that names an identity other than the one its `path` or `git` + // manifest declares is a statement the manifest contradicts, and is + // reported (#719). A BARE key states no namespace for such a source: the + // source fixes the package, and the manifest there names its namespace, so + // reading the key as `mcpplibs.` and then "correcting" it was the + // engine holding the author to a reading the author never wrote (rule W-b, + // docs/specs/package-identity.md 4.2). Adoption itself is unchanged; only + // the report is. `namespaceOmitted` is true exactly for a bare key: a + // namespace table (`[dependencies.ns]`) and a dotted key both state one. + // + // The reports are collected here and written once after resolution + // (emitAdoptionWarnings), one per consumer manifest and declared + // namespace, so a manifest that lists eight such keys is one warning. + state.reportAdoption = [&](const WorkItem& item, const ResolvedKey& normalised, + const ResolvedKey& declared) { + if (item.spec.namespaceOmitted) return; + if (!state.adoptionsReported.emplace(item.requestedBy, item.name).second) return; + const auto base = state.workRoot.empty() ? *state.root : state.workRoot; + const auto consumerFile = ((item.resolveRoot.empty() ? *state.root + : item.resolveRoot) + / "mcpp.toml").lexically_normal(); + auto rel = consumerFile.lexically_relative(base.lexically_normal()); + const bool inside = !rel.empty() && !rel.generic_string().starts_with(".."); + auto& g = state.adoptionGroups[{ consumerFile.generic_string(), declared.ns }]; + if (g.keys.empty()) { + g.consumer = inside ? rel.generic_string() : consumerFile.generic_string(); + g.declaredNs = declared.ns; + g.example = item.spec.isGit() + ? std::format("{} = {{ git = \"{}\", {} = \"{}\" }}", + declared.shortName, item.spec.git, + item.spec.gitRefKind, item.spec.gitRev) + : std::format("{} = {{ path = \"{}\" }}", + declared.shortName, + std::filesystem::path(item.spec.path).generic_string()); + } + g.writtenNs.insert(normalised.ns); + g.keys.push_back(item.name); + }; + state.emitAdoptionWarnings = [&] { + for (auto const& [id, g] : state.adoptionGroups) { + std::string written; + for (auto const& ns : g.writtenNs) written += (written.empty() ? "" : ", ") + ns; + std::string keys; + for (auto const& k : g.keys) keys += (keys.empty() ? "" : ", ") + k; + mcpp::diag::warning("dependency/identity", std::format( + "{} names {} {} in namespace {}, and the {} {} {} {}; " + "the declared identity is used: {}", + g.consumer, g.keys.size(), + g.keys.size() == 1 ? "dependency" : "dependencies", written, + g.keys.size() == 1 ? "manifest" : "manifests", + g.keys.size() == 1 ? "it reaches" : "they reach", + g.keys.size() == 1 ? "declares" : "declare", g.declaredNs, keys), + std::format("write {} in a [dependencies.{}] table, for example `{}`", + g.keys.size() == 1 ? "it" : "them", g.declaredNs, g.example)); + } + state.adoptionGroups.clear(); }; diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index a7d02601..5353a7d6 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -455,6 +455,17 @@ struct PrepareState { std::map identityBySource; std::map declaringManifest; std::set> adoptionsReported; + // The adoptions that are corrections (rule W-b), collected while the graph + // is walked and reported once after it, grouped by the consumer manifest + // and the namespace its dependencies declare. + struct AdoptionGroup { + std::string consumer; // manifest path, relative to the project root + std::string declaredNs; + std::set writtenNs; // the namespaces the keys stated + std::vector keys; // as written, in walk order + std::string example; // a TOML line for the hint + }; + std::map, AdoptionGroup> adoptionGroups; std::map gitCloneBySource; std::set selectorMigrationWarnings; std::set preinstallStack; @@ -464,8 +475,9 @@ struct PrepareState { const ResolvedKey&)> gitMemberDeclaring; std::function qualifiedKey; std::function stateAdoptedIdentity; - std::function reportAdoption; + std::function reportAdoption; + std::function emitAdoptionWarnings; std::function index_route; std::function findIndexForNs; std::function(mcpp::manifest::DependencySpec&, diff --git a/tests/e2e/679_a_path_dependency_takes_its_manifests_identity.sh b/tests/e2e/679_a_path_dependency_takes_its_manifests_identity.sh index 205d0633..7cc2b118 100755 --- a/tests/e2e/679_a_path_dependency_takes_its_manifests_identity.sh +++ b/tests/e2e/679_a_path_dependency_takes_its_manifests_identity.sh @@ -7,14 +7,23 @@ # `huxdemo.fw` over the same directory put the same module into the build # twice, which the scanner then refused naming one file twice. # +# A bare key of a `path` or `git` dependency states only the short name (rule +# W-b, package-identity.md 4.2): the source fixes the package, so adopting the +# namespace its manifest declares corrects nothing and is not reported. A key +# that WRITES a namespace the manifest contradicts is still reported, once per +# consumer manifest and declared namespace. +# # Legs: # A. Two edges over one directory, keyed `huxdemo.fw` (the application) and -# `fw` (a component): the build succeeds, the unit is compiled once, one -# warning names the component's key, its normalisation and the declared -# identity, and the resolution record shows one package with both keys. +# `fw` (a component): the build succeeds, the unit is compiled once, no +# identity warning is written (the bare key states no namespace), and the +# resolution record shows one package with both keys. # B. Keys that match the declaration warn nothing. # C. Two keys over a manifest that declares no namespace are two identities # over one source, and are refused before scanning. +# D. Keys that state a namespace the manifests contradict are reported in one +# warning that names the consumer manifest by its path relative to the +# project root, lists the keys, and gives a hint in TOML. set -e TMP=$(mktemp -d) @@ -56,12 +65,9 @@ printf 'import fw;\nimport comp;\nint main() { return fw_anchor() + comp_anchor( # ── A ────────────────────────────────────────────────────────────────────── ( cd "$TMP/app" && "$MCPP" build > build.log 2>&1 ) || fail "A: build failed" "$TMP/app/build.log" ( cd "$TMP/app" && "$MCPP" run > run.log 2>&1 ) || fail "A: the program did not exit 0" "$TMP/app/run.log" -n=$(grep -c "that identity is used" "$TMP/app/build.log" || true) -[ "$n" = "1" ] || fail "A: expected one identity warning, saw $n" "$TMP/app/build.log" -grep "that identity is used" "$TMP/app/build.log" | grep -q "'fw'" \ - && grep "that identity is used" "$TMP/app/build.log" | grep -q "mcpplibs.fw" \ - && grep "that identity is used" "$TMP/app/build.log" | grep -q "huxdemo.fw" \ - || fail "A: the warning does not name the key, its normalisation and the declaration" "$TMP/app/build.log" +if grep -q "declared identity is used\|that identity is used" "$TMP/app/build.log"; then + fail "A: a bare key produced an identity warning" "$TMP/app/build.log" +fi units=$(find "$TMP/app/target" -path '*/obj/*' -name 'fw.m.o' | wc -l | tr -d ' ') [ "$units" = "1" ] || fail "A: fw.cppm compiled $units times" json=$(find "$TMP/app/target" -name resolution.json | head -1) @@ -79,7 +85,7 @@ sed 's/^fw = { path/huxdemo.fw = { path/' "$TMP/comp/mcpp.toml" > "$TMP/comp/mcp mv "$TMP/comp/mcpp.toml.new" "$TMP/comp/mcpp.toml" rm -rf "$TMP/app/target" ( cd "$TMP/app" && "$MCPP" build > build2.log 2>&1 ) || fail "B: build failed" "$TMP/app/build2.log" -if grep -q "that identity is used" "$TMP/app/build2.log"; then +if grep -q "declared identity is used" "$TMP/app/build2.log"; then fail "B: matching keys produced an identity warning" "$TMP/app/build2.log" fi @@ -98,4 +104,36 @@ if grep -q "already provided\|is provided by package" "$TMP/nons/app/build.log"; fail "C: the refusal came from the scanner, not before scanning" "$TMP/nons/app/build.log" fi +# ── D ────────────────────────────────────────────────────────────────────── +mkdir -p "$TMP/grp/app/src" "$TMP/grp/app/libs/fw/src" "$TMP/grp/app/libs/comp/src" +cp "$TMP/fw/mcpp.toml" "$TMP/grp/app/libs/fw/mcpp.toml" +cp "$TMP/fw/src/fw.cppm" "$TMP/grp/app/libs/fw/src/fw.cppm" +cp "$TMP/comp/mcpp.toml" "$TMP/grp/app/libs/comp/mcpp.toml" +cp "$TMP/comp/src/comp.cppm" "$TMP/grp/app/libs/comp/src/comp.cppm" +sed -i.bak 's|^huxdemo.fw = { path = "../fw" }|fw = { path = "../fw" }|' "$TMP/grp/app/libs/comp/mcpp.toml" +cat > "$TMP/grp/app/mcpp.toml" <<'TOML' +[package] +name = "app" +version = "0.1.0" +[dependencies] +acme.fw = { path = "libs/fw" } +acme.comp = { path = "libs/comp" } +TOML +cp "$TMP/app/src/main.cpp" "$TMP/grp/app/src/main.cpp" +( cd "$TMP/grp/app" && "$MCPP" build > build.log 2>&1 ) || fail "D: build failed" "$TMP/grp/app/build.log" +n=$(grep -c "declared identity is used" "$TMP/grp/app/build.log" || true) +[ "$n" = "1" ] || fail "D: expected one grouped warning, saw $n" "$TMP/grp/app/build.log" +grep "declared identity is used" "$TMP/grp/app/build.log" | grep -q "mcpp.toml names 2 dependencies in namespace acme" \ + || fail "D: the warning does not name the manifest by its relative path and the count" "$TMP/grp/app/build.log" +grep "declared identity is used" "$TMP/grp/app/build.log" | grep -q "declare huxdemo" \ + || fail "D: the warning does not name the declared namespace" "$TMP/grp/app/build.log" +grep "declared identity is used" "$TMP/grp/app/build.log" | grep -q "acme.fw" \ + && grep "declared identity is used" "$TMP/grp/app/build.log" | grep -q "acme.comp" \ + || fail "D: the warning does not list the keys" "$TMP/grp/app/build.log" +grep -q "\[dependencies.huxdemo\] table, for example" "$TMP/grp/app/build.log" \ + || fail "D: the hint is not written in TOML" "$TMP/grp/app/build.log" +if grep -q "libs/comp/mcpp.toml names" "$TMP/grp/app/build.log"; then + fail "D: the bare key inside libs/comp was reported" "$TMP/grp/app/build.log" +fi + echo "OK" diff --git a/tests/e2e/713_a_git_dependency_selects_a_repository_member.sh b/tests/e2e/713_a_git_dependency_selects_a_repository_member.sh index 6863a834..274bc1ad 100755 --- a/tests/e2e/713_a_git_dependency_selects_a_repository_member.sh +++ b/tests/e2e/713_a_git_dependency_selects_a_repository_member.sh @@ -133,7 +133,7 @@ int main() { return fw_answer() == 42 ? 0 : 1; } EOF cd "$TMP/both" "$MCPP" build -v > b2.log 2>&1 || fail "the root and a member by one revision did not build" b2.log -grep -q "that identity is used" b2.log \ +grep -q "declared identity is used" b2.log \ && fail "the member's key adopted the root's identity" b2.log grep -q 'DEPBIN tool=\[[^]]' b2.log || fail "the member's tools request was lost" b2.log grep -qE "Compiling spike\.fw v( |$)" b2.log && fail "the git banner printed an empty version" b2.log From a62de2839493f451bfcd6711b05a9156ed26e5f6 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:39:08 +0800 Subject: [PATCH 03/17] docs: the build output refinement design (revision 3 of the build progress design), under review The design records the measurements of a report on mcpp build in the xlings repository with 2026.9.29.5 (a frame reaches the terminal in two or three writes; the status line is drawn before anything is known; cache-served dependencies never complete, so the folded dependency line is late; the phase stays on the build programs), the review rounds that settled the identity rule W-b, one line per package that does work, and one status row aligned with the verbs, and the open decisions on the status row's display. --- ...26-09-30-build-output-refinement-design.md | 1362 +++++++++++++++++ .agents/docs/README.md | 4 +- 2 files changed, 1365 insertions(+), 1 deletion(-) create mode 100644 .agents/docs/2026-09-30-build-output-refinement-design.md diff --git a/.agents/docs/2026-09-30-build-output-refinement-design.md b/.agents/docs/2026-09-30-build-output-refinement-design.md new file mode 100644 index 00000000..feee3d8f --- /dev/null +++ b/.agents/docs/2026-09-30-build-output-refinement-design.md @@ -0,0 +1,1362 @@ +--- +subject: design +status: active +--- + +# Build output, revision 3: every package that does work is named, the live display is one line drawn in one write, and a repeated warning is stated once per file + +- Status: active (a proposal for review; draft 3, after review rounds 1 + and 2 of section 5). It refines `2026-09-29-build-progress-display-design.md` + (revision 2, landed in #742, released as 2026.9.29.5) and replaces the + parts listed in section 6. +- Date: 2026-09-30 +- Origin: a report on `mcpp build` in the xlings repository with mcpp + 2026.9.29.5. It raised three questions: why the build prints warnings; + why the first line flickers before the output appears; and why the + dependencies, which the previous release listed, are no longer visible. + The report summarised the result as a display that shakes and conveys + less than before. + +## 0. Scope + +The human output of the commands that build (`build`, `run`, `test`, `pack`), +from the first line to `Finished`. Machine output (`--message-format json`) +is unchanged, except that the identity warnings of section 8 are grouped. +The cost of planning is outside the scope. Every edit of a source file takes +the planned path by the rule of mcpp#225, and planning took 3.06 s of the +reported 33.63 s. Section 11 records this. + +## 1. The report + +The reported output, with eight of its nine warnings elided (section 3, F9): + +``` + Workspace building member 'xlings' +warning: 'mcpplibs.xlings@path' declares the dependency 'cancellation', which names mcpplibs.cancellation; the manifest '/home/…/xlings/modules/cancellation/mcpp.toml' declares xlings.cancellation, and that identity is used. + hint: write 'xlings.cancellation' in 'mcpplibs.xlings@path' to state the identity the manifest declares. + … eight more warnings of the same form … + Resolving toolchain + Resolved gcc@16.1.0 → @mcpp/registry/data/xpkgs/xim-x-gcc/16.1.0/bin/g++ + Target x86_64-linux-gnu → x86_64-unknown-linux-gnu + Inferred sources [src/**/*.{cppm,cpp,cc,c,S,s,asm}] + build.mcpp 1 dependency ran 0.64s + Compiling xlings (.) done 29.65s + Compiling 21 dependencies done 8.10s · 13 cached + + Finished dev [unoptimized + debuginfo] in 33.63s · plan 3.06s · programs 0.64s · build 29.94s · longest xlings: obj/xlings/src/core/xself/doctor.o 8.38s +``` + +## 2. Method + +- The xlings tree at c4b6cef (`git archive`, so the report's working tree was + not touched) was built on a pseudo-terminal of 120 × 40 under + `strace -f -y --trace-fds=1,2 -e trace=write,writev`. The trace gives every + write to the terminal, with its time and thread. +- Each recording was replayed through the terminal emulator of e2e 843. After + every write the replay records whether the status line is on the screen. +- Three builds were recorded: + - **B1**: the first build of the tree; + - **B2**: a build after touching one source file of the root package; + - **B3**: a build after editing a module of the path dependency + `modules/platform`. +- 2026.9.29.4 built the same tree for comparison. +- The recorder and the replay are in the session's scratch directory. + Section 13 proposes keeping the replay as a test helper. + +## 3. Findings + +**F1. A frame reaches the terminal in two or three writes.** B1 made 440 +writes to the terminal. 202 of them began a frame by erasing the region: + +- In 184 of those 202, the status line followed in a second write. The cause + is the stdio buffer: `mcpp.platform.terminal::write` uses `std::fwrite` + (`terminal.cppm:207`), stdout on a terminal is line-buffered, and a frame + whose last row, the status line, has no newline is therefore flushed in two + parts. +- The other 18 were lines written above the region. `emit_locked` + (`ui.cppm:489`) erases the region, writes the line and redraws, and flushes + after each of the three. + +Each frame erases first (`\r ESC[nA ESC[J`) and draws afterwards. Between +the writes the screen shows the region blank, for 0.1 ms at the median and +1.0 ms at most. A local terminal usually receives both writes in one read. +The blank state becomes visible when they arrive separately: in a remote +session, through ConPTY on Windows, or in a terminal that is busy. + +**F2. The status line is drawn before anything is known, and every early +line moves it.** `progress::open` opens the region at once, and the status +line `Resolving · 0:00` was on the screen 0.6 ms after the command started. +The first warning arrived 0.37 s later. It took the status line's row, and +the status line was drawn again below it. Each of the 18 lines written during +planning moved the status line down: by one row per line, and by three rows +for a warning that wraps. This is the first-line flicker of the report. + +**F3. The region changes height.** It is two rows (a blank row and the status +line) during planning. During the build it is four: two live package rows, a +blank row and the status line. It shrinks again as packages settle. In B1 the +region was erased at heights 2, 4 and 3, 37, 164 and 1 times. Each change of +height moves the status line. + +**F4. The dependencies are folded.** Revision 2 (R4) lists the requested +packages and folds every dependency into one line: +`Compiling 21 dependencies done 8.10s · 13 cached`. The line does not say +which dependencies compiled, or which the cache supplied. + +2026.9.29.4 printed a line for the root and for each of its 12 direct +dependencies, as `Compiling` or as `Cached … (N units)`. It printed these +lines whether or not the dependency compiled, and it never named transitive +dependencies. + +**F5. The dependency line is late, out of order, and states a fact that +belongs to the plan.** A dependency served by the global cache receives its +units through `stage_file` steps. These run in a ninja pass of their own +(`ninja_backend.cppm:4214`), which runs with `--quiet` and without the +progress model, so the steps are never counted. Under revision 2's +completion rule (§3.2), a package is complete only when all its steps have +run. A cache-served dependency therefore never completes, and the folded line +waits for ninja to exit. The consequences in the three builds: + +- B1: the dependencies' live row, `Compiling 9 dependencies 97 steps`, + stopped changing at 28.1 s and stayed on the screen until 55.1 s. The + root's final line was written before the dependencies' line. +- The report: `Compiling xlings (.)` precedes `Compiling 21 dependencies`, + although the root cannot finish before its dependencies. +- B2: no dependency did anything, yet `Compiling 13 dependencies cached` was + written. This is a fact of the plan, stated as an event of the build, which + contradicts R7. + +**F6. The phase is wrong after the build programs.** `programs_done()` does +not return the phase to planning. In B1 the status line read +`Running build programs · 0:04` through `0:17`, 13 s after the only program +finished, while the plan continued. + +**F7. The subjects are inconsistent.** + +- The root reads `xlings (.)` without a version, because a workspace member's + subject is its directory. A single package reads `bad v0.1.0 (.)`. +- A path dependency is named by the consumer's key: `cancellation (path)`, + for the package that declares itself `xlings.cancellation` and lives in + `modules/cancellation`. +- `build.mcpp 1 dependency ran 0.64s` reads as if `1 dependency` were a + package. + +**F8. `Finished` is long, names an object file, and differs between the two +paths.** + +- The reported line is 156 columns, so it wraps at 120. +- `longest` names `obj/xlings/src/core/xself/doctor.o`. The reader edits + `src/core/xself/doctor.cpp`. +- The fast path writes `Finished dev in 0.04s`, without the descriptor that + the planned path writes: `Finished dev [unoptimized + debuginfo] in 9.59s`. + +**F9. Nine warnings state one fact nine times.** Each warning takes two lines. +The first line is about 256 columns, because it carries the absolute manifest +path, so the nine take about 36 rows at 120 columns before the build starts. +The consumer is named `mcpplibs.xlings@path`, an identity that nobody wrote. +The hint, "write 'xlings.cancellation' in 'mcpplibs.xlings@path'", names +neither the file nor the table. Section 4 explains why the warnings appear. + +**F10. Four configuration lines on every planned build.** +`Resolving toolchain`, `Resolved gcc@16.1.0 → `, +`Target x86_64-linux-gnu → x86_64-unknown-linux-gnu` and +`Inferred sources [...]` are printed each time the plan runs, which is after +every edit (section 0). None of them states a change. + +**F11. An adjacent defect, found during the measurement: the lock file is +rewritten.** Since 2026.9.29.1, a planned build rewrites xlings's +`mcpp.lock`: + +- the entries of `[dependencies.mcpplibs]` (`cmdline`, `xpkg`) are recorded + under their bare keys, with a different hash; +- the committed `mcpplibs.cmdline` and `mcpplibs.xpkg` entries are kept. + +The lock therefore holds two entries for one identity, and the working tree +is always modified. 2026.9.28.3 leaves the file unchanged; 2026.9.29.1, +2026.9.29.4 and 2026.9.29.5 rewrite it. + +## 4. Why the warnings appear + +A dependency key without a namespace names the default namespace `mcpplibs` +(`docs/specs/package-identity.md` §4.2). xlings writes its path dependencies +as bare keys in three manifests: + +- `mcpp.toml` lists `cancellation`, `i18n`, `json`, `platform`, `sha256`, + `theme` and `xhttp`; +- `modules/i18n/mcpp.toml` lists `platform`; +- `modules/platform/mcpp.toml` lists `cancellation`. + +The manifests at those paths declare `namespace = "xlings"`. + +Since #634 (A2), the identity a path or git manifest declares is the +package's identity, whatever key reached it. mcpp therefore builds +`xlings.cancellation` and reports that the key named another identity. Since +2026.9.27.1 (#719) it reports this once per declaring edge. The build is +correct. The warning asks for the key to state the identity it reaches. + +The correction in xlings is a namespace table in each of the three manifests: + +```toml +[dependencies.xlings] +cancellation = { path = "modules/cancellation" } +platform = { path = "modules/platform" } +# ... and the other five in mcpp.toml +``` + +It removes the warnings with every mcpp release since #634. It needs no mcpp +change and is proposed as a separate xlings pull request (section 15). + +## 5. Review round 1 and the options + +### 5.1 The review + +The first review returned four points: + +1. A path dependency inside the project should be exempt from the identity + warning, or its identity should be settled without one. +2. The terminal writer of section 9 is accepted. +3. A line, once written, should not move or change. +4. The line format of 2026.9.29.4 was clear. Its one defect is `(path)`, + which should state the directory relative to the project. + +Point 2 is settled. Points 1, 3 and 4 admit several designs, listed below +with what each costs. Sections 6 to 16 describe the recommended +combination. + +### 5.2 The identity warning (point 1) + +Adoption does not depend on the warning. The resolver already records the +identity each source declares, and builds that identity whatever key reached +it (#634 A2). #634 chose adoption with a warning rather than refusal, because +nine working builds wrote such keys. The warning therefore only asks for a +spelling to change. Three designs follow from this: + +- **W-a. Exempt a path dependency inside the project.** A bare key whose + `path` lies inside the project adopts the declared identity without a + warning. "Inside the project" means inside the root of the workspace, or of + the package when there is no workspace. A path outside the project and a + git dependency keep the warning, grouped as in section 8. +- **W-b. A bare key of a path or git dependency states only the short name.** + A bare key never names a namespace for a `path` or `git` source: the source + fixes the package, and the manifest there names its namespace. Adoption is + then not a correction, and nothing is reported. A key that writes a + namespace (`[dependencies.foo]`, `foo.cancellation`) that the manifest + contradicts still warns: the author stated an identity that is false. + Section 4.2 of `package-identity.md` gains one clause: "bare means + `mcpplibs`" applies to selectors that an index resolves, not to a source + the manifest line fixes. The code already compares only the short name in + the git-member search, for the same reason. +- **W-c. Rewrite the key in the manifest automatically.** A build that edits + the user's manifest changes files the user did not ask it to change, and + the rewrite would be a second writer of `mcpp.toml`. Not proposed. + +The recommendation is W-b. The reason W-a gives for a path inside the +project, that the source rather than the key determines the package, holds +for every path and git source. W-b states that reason as a rule and has no +boundary to define. A git dependency whose upstream renames its namespace +changes identity under W-b without a word. The lock file records the new +identity, so the change appears in the lock's diff. + +### 5.3 The package lines (points 3 and 4) + +All three designs use the line format of 2026.9.29.4, with two corrections. +The origin `(path)` becomes the directory relative to the project root, and a +path dependency states its version. Round 3 (section 5.8) settled the name: +the short name inside the project, the full identity outside it. + +``` + Compiling xlings v2026.9.29.1 (.) + Compiling cancellation v0.1.0 (modules/cancellation) + Cached compat.ftxui v6.1.9 (73 units) +``` + +The designs differ in when a line is written and which packages have one. + +- **A. The plan's list, as in 2026.9.29.4.** When the plan ends, a line is + written for the root and each of its direct dependencies (for a workspace, + each member's), in manifest order. The verb is `Cached` when the cache + serves the package, and `Compiling` otherwise. +- **B. The plan's list, reduced to the packages ninja will run.** Before + ninja starts, `ninja -n` lists the steps this build runs. This takes 10 ms + on xlings's graph of 1,200 steps; the cache pass has no dyndep file, so its + dry run always completes. A line is written, in graph order, for every + package with steps in either list, including transitive dependencies. Two + cases fall outside the dry run: + - On a first build the dry run stops at the first dyndep file that does not + exist yet (section 13, A). In a directory without a ninja log, every + package with steps that the cache does not serve is listed; they all + compile. + - A package the dry run did not foresee (a module file no build has scanned + yet) receives its line when its first step finishes. +- **C. A line when the work happens.** A package's line is written when its + first step in the main pass finishes, or when its first `check` or + `prepare` action starts. A `Cached` line is written when the cache pass + ends, for each package whose units that pass placed. Packages with nothing + to do have no line; `-v` names them `Fresh`. + +| | A | B | C | +|---|---|---|---| +| Resemblance to 2026.9.29.4 | identical | same lines, and the whole list at the start | same lines, written during the first seconds of ninja | +| Every line true (R7) | no: an incremental build lists packages with nothing to do as `Compiling`, and `Cached` is stated when nothing was placed | nearly: every compile rule has `restat = 1`, so a dependent that the dry run lists is skipped when an edit leaves a module interface's BMI unchanged | yes, by construction | +| Transitive dependencies named | no | yes | yes | +| Lines after an edit of one root source (B2) | all 13 direct packages | the root | the root | +| Lines on a first build (B1) | 13 | 21, at the start | 21, as their first steps finish; in B1 all appeared within 1.5 s of ninja starting | +| Order | manifest order | graph order | the order in which work finishes | +| Work added | restore the old announcement, change the subject | a dry run, its parser (descriptions carry `$out`), the first-build rule, the late additions | an announcement at the first step, the cache pass reported | +| Cost per build | none | one more ninja invocation (a graph load) | none | + +The recommendation is C. It is the only design in which every line is true, +and it is the simplest. On a first build it lists what A lists and the +transitive dependencies besides. After an edit it names what the build +actually compiled, which is cargo's convention. B is the choice if the whole +list must be on the screen before the first compile ends. Its residual error +is bounded (it may name a dependent that `restat` skips, never omit one), and +it needs the most machinery. A is listed because it restores the reviewed +output exactly. Revision 2 replaced A because of its first row in the table. + +### 5.4 The live display (point 3) + +Under every design, a line, once written, is never rewritten, recoloured or +moved. It leaves the screen only by scrolling. Two designs remain for what +changes while the build runs: + +- **1. Nothing changes.** On a terminal, as in a log, a status line is + appended after 60 s of silence, naming the phase, the counts, the clock and + the longest-running step. Nothing on the screen is ever redrawn. The + terminal shows no activity until the first heartbeat; that silence was the + defect #742 set out to fix, although the heartbeat bounds it. +- **2. One status line below the output.** This is the design of sections 7 + and 9. The status line is the only row that changes. It is redrawn in place + in one write, and it first appears after 0.5 s. When a line is written + above it, the status line moves down one row, as the screen scrolls. With + design A or B no line is written while ninja runs, so the status line stays + where it is. + +The recommendation is 2. It shows that the build is progressing within half +a second, and apart from the status line it keeps the screen as static as +design 1. + +### 5.5 The recommended combination + +W-b, C and 2. Sections 6 to 16 describe this combination; section 8 then +covers only a key that states a namespace which the declaration +contradicts. Choosing B instead of C changes section 7.1 (when a line is +written) and section 10 (a dry run before the main pass). Choosing W-a +instead of W-b keeps section 8 for paths outside the project and for git +dependencies. + +### 5.6 Review round 2: the status line + +The second review accepted W-b, C and 2, and asked whether the status line +could be more engaging while staying useful, uncluttered and simple. + +**Constraints the answer keeps.** + +- The line stays one row. +- Every element states something mcpp knows (R7), or is recognisably + decoration that claims nothing. +- A frame is one write. +- Nothing above the line changes. +- A log (CI) receives none of it: the heartbeat line there stays plain text. + +**Candidate elements.** + +| Element | What it shows | Useful | Engaging | Cost | Verdict | +|---|---|---|---|---|---| +| A bar of the phase's fraction, `━━━━━╸━━━━`, the filled part coloured | `f/t` while building; programs finished / scheduled while the build programs run | yes: the fraction at a glance | yes | small | recommended | +| An indeterminate sweep: a short bright segment moving along the bar's track | the phase has no total yet (planning) | yes: planning takes 3 to 17 s in the recordings and has no count | yes: calm motion | small | recommended | +| A spinner that turns once per finished step, at most once a frame | the pace of the build: it spins fast during a burst of compiles and stops while the build waits on one long step | yes: a stopped spinner beside a ticking action clock says "one long step" | yes: the animation carries information | a counter | recommended | +| The cache's share of the bar, in a second tone | the steps the cache pass staged, beside those compiled | yes: what the cache saved | yes | small | recommended | +| Red on failure | a step failed while ninja finishes the running ones (`Stopping`) | yes | | none | recommended | +| Progress in the terminal's tab and taskbar (OSC 9;4) | the bar's fraction, red on failure, cleared at exit | yes: visible from another window | yes | small | recommended, for an allow-list of terminals | +| A sparkline of steps per second over the last 12 s, `▁▂▅▇▆▃` | the rhythm: bursts, stalls, the link at the end | partly | yes | small | optional (the "pulse" style) | +| Running jobs, `●●●●●○○○` (ninja's `%r` against the job count) | how parallel the build is at this moment | partly: shows why the tail of a build is slow | yes | `%r` added to the status format | optional (the "pulse" style) | +| An estimate of the time left | a guess: step times range from 0.01 s to 12 min in the validation project | misleading | | | not proposed (R7) | +| The terminal's window title | progress in the title | duplicates OSC 9;4; the previous title cannot be restored reliably | | | not proposed | +| A bell when a long build ends | attention | intrusive by default | | | not proposed | +| Emoji, colour cycling, scrolling text | nothing | no | | | not proposed | + +**Three styles, in the demonstration.** The script +`statusline_demo.py` (kept with the recordings) plays a simulated build in +each style, on the terminal it runs in. Flags: `--fail`, `--ascii`, `--osc`, +`--block`, `--speed`. + + quiet Building 612/707 · 0:35 · gpp.gui: CMAKE ElaWidgetTools 0:04 + bar ⠹ Building ━━━━━━━━━━━━━━━━━━━━╸━━━ 612/707 · 0:35 · gpp.gui: CMAKE ElaWidgetTools 0:04 + pulse ⠹ Building ━━━━━━━━━━━━━━━━━━━━╸━━━ 612/707 · 0:35 · ▁▁▂▅▇▆▅▃▂▁▁▁ 38/s · ●○○○○○○○ · gpp.gui: CMAKE … + +The recommendation is **bar**, with the tab and taskbar progress. Each +element in it states a fact, and together they fit in about 60 columns +before the running step's name. The motion (the sweep, and a spinner whose +pace is the build's) shows that the build is alive and how fast it moves. The +line does not become a dashboard. Pulse adds two readings that are +interesting at first and redundant with the counts afterwards, and it +doubles the line's length, which truncates the running step's name, the +element a long build most needs. + +**Details of bar.** + +- The bar is 24 columns. Its head has half-cell resolution (`╸`). When + dyndep adds steps and `t` grows, the bar shortens by that fraction. This + states the new total and is not smoothed away. +- The spinner has ten braille frames, `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`. While mcpp itself works + (planning, build programs) it turns once a frame. While ninja builds it + turns once per finished step, and never more than once a frame. +- Glyphs and colours: the filled part is `━` in bright cyan, and the + cache's share `━` in cyan. The track is `─` in dark grey, so that fill and + track differ by weight without colour, and on a theme whose dark grey is + the background colour (Solarized dark). After a failure the filled part + turns red and the phase yellow. +- **Fallbacks.** Under `NO_COLOR` the glyphs alone separate fill and + track. Where the glyphs are unavailable, the ASCII forms apply: `|/-\` + and `[=====> ]`. Section 5.7 states where. +- **Width.** The running step's name is truncated first, then the bar is + dropped (below 60 columns). Phase, counts and clock are always kept. +- **Tab and taskbar progress.** The sequence is + `ESC ] 9 ; 4 ; state ; percent ESC \`: 1 while building, 2 after a + failure, 3 while planning (indeterminate), 0 to clear. + - It is sent only to terminals that support it: Windows Terminal + (`WT_SESSION`), ConEmu (`ConEmuANSI=ON`), WezTerm and Ghostty + (`TERM_PROGRAM`). iTerm2 and kitty read `OSC 9` as a desktop + notification, so an unconditional sequence would post notifications + there. + - cargo emits the same sequence for the terminals it recognises. The + exact list is to be checked against cargo's source before + implementation. + - The sequence is cleared when the region closes, on success, on failure + and on interruption through the existing signal guard. +- **One setting.** `MCPP_PROGRESS` takes three values: + - `bar`, the default on a capable terminal; + - `plain`, the quiet style, for a screen reader or by preference; + - `off`, no status line: the heartbeat lines of a log, on the terminal + too. +- **Frame rate.** At most ten frames a second, as before. A frame is written + only when it differs from the previous one: while nothing finishes, the + spinner stops and only the clock changes, so one frame a second is written. + +### 5.7 Compatibility of the bar style + +The bar style is drawn only where the landed status line is drawn: stdout +is a terminal that can move the cursor. Everywhere else (a CI log, a pipe, a +redirected file, `TERM=dumb`, Emacs' `M-x compile`, a Windows console +without virtual-terminal processing, a native program under MSYS2 mintty +without ConPTY), the output is plain lines with the heartbeat, as today. + +| Terminal | Glyphs | Ambiguous-width characters | OSC 9;4 | +|---|---|---|---| +| Linux: GNOME Terminal and other VTE terminals, Konsole, xterm, Alacritty, foot | Unicode; fontconfig supplies missing glyphs | narrow by default; wide when so configured | not sent | +| WezTerm, Ghostty | Unicode | narrow by default | sent | +| kitty | Unicode | narrow | not sent: kitty reads `OSC 9` as a notification | +| macOS Terminal.app | Unicode; the system supplies missing glyphs | narrow | not sent | +| iTerm2 | Unicode | narrow by default; an option makes them wide | not sent: `OSC 9` posts a notification | +| Windows Terminal | Unicode; DirectWrite supplies missing glyphs | narrow | sent | +| Windows console host (conhost), Windows 10 1511 and later | ASCII: conhost has no glyph fallback, and Consolas, its long-standing default font, lacks the braille block | wide under code pages 932, 936, 949 and 950 | not sent | +| VS Code, JetBrains terminals | Unicode | narrow | not sent (not on the allow-list) | +| tmux, GNU screen | as the outer terminal | as the outer terminal | not sent: the multiplexer drops it, so `TMUX` or `STY` disables it even when `WT_SESSION` is inherited | +| SSH | as the local terminal | as the local terminal | as the local terminal | + +**The one real hazard is the width of ambiguous characters.** `━`, `─`, +`·` and `…` are East Asian Ambiguous. `╸` and the braille spinner are +narrow. A terminal that renders ambiguous characters wide is common among +Chinese, Japanese and Korean users. conhost does so under a CJK code page, +and GNOME Terminal, iTerm2, mintty and PuTTY do so on request. In such a +terminal the 24-cell bar takes 48 columns, the row overflows, and it wraps. +`\r` then returns only to the start of the wrapped part, so every frame +leaves a row behind. + +The demonstration was replayed through a terminal emulator 60 columns wide +that renders ambiguous characters wide. With autowrap on, 69 rows of stale +status fragments remained at the end. The landed status line has the same +latent defect through `·`, although it overflows only near the right +margin. Three measures follow: + +1. **Autowrap is off while the status row is drawn.** Each frame writes + `ESC[?7l`, the row, `ESC[K` and `ESC[?7h` in one write, so a row wider + than the terminal is clipped at the margin and never wraps. In the same + replay no stale row remained. Lines written above the status row wrap as + usual. DECAWM is part of VT100; Windows Terminal and conhost share the VT + parser that implements it (to be confirmed on a Windows machine). +2. **The width is budgeted conservatively under a CJK locale.** When + `LC_ALL`, `LC_CTYPE` or `LANG` names `zh`, `ja` or `ko`, or the Windows + console's output code page is 932, 936, 949 or 950, `fit` counts + ambiguous characters as two columns. The row then fits whichever way the + terminal renders them; in a terminal that renders them narrow it ends a + little short of the margin. +3. **conhost receives ASCII.** It has no glyph fallback, and Consolas lacks + the braille block. The glyphs are chosen from the environment: Unicode + where `WT_SESSION`, `TERM_PROGRAM` or a UTF-8 locale on POSIX says so, + ASCII otherwise. + +The other hazards are small: + +- A colour theme can hide the track. The fill and the track differ by glyph + weight, so the bar reads without colour. +- A missing glyph renders as a box. `MCPP_PROGRESS=plain` removes every + glyph beyond the landed line. +- The tab and taskbar sequence is sent only on the allow-list, never under a + multiplexer, and is cleared when the region closes. A process killed with + `SIGKILL` cannot clear it; the terminal clears it when the next program + sets its own progress, or when the tab closes. + +### 5.8 Review round 3: names, alignment, and what the bar can say + +The third review asked for three things: + +1. The project's own packages should be told apart by colour, and named + without the `xlings.` prefix. +2. The status row should align with the lines above it rather than start in + the first column. +3. A design that is inventive, compatible, and states real information. + +**Names and colour.** + +- A package inside the project has two properties that locate it: its + directory, which is always shown, and the project colour. It is named by + its short name (`platform v0.1.0 (modules/platform)`). +- Every other package keeps its full identity (`compat.ftxui v6.1.9`, + `mcpplibs.xpkg v0.0.59`), because nothing else on its line says where it + comes from. +- "Inside the project" is the root, the workspace members, and the path + dependencies whose directory lies under the project root: the boundary + that section 5.2 calls W-a. +- Two packages inside one project with one short name are told apart by + their directories. + +The colours: + +- the verb keeps its colour (bold green for `Compiling` and `Cached`); +- the name of a package inside the project is cyan, the colour of the + status row's phase; +- the name of any other package is in the default colour; +- the version, the origin and a unit count are dim. + +A line therefore carries at most three styles. Under `NO_COLOR` the two +forms still differ in text: a short name followed by a directory, or a full +identity. The same subject is used wherever a package is named: in the +package lines, the build-program lines, the error line, the `-v` summary, +and the label of a running action (`gui: CMAKE ElaWidgetTools`). + +``` + Cached compat.ftxui v6.1.9 (73 units) + Compiling cancellation v0.1.0 (modules/cancellation) + Compiling mcpplibs.xpkg v0.0.59 + Compiling xlings v2026.9.29.1 (.) +``` + +**Alignment.** The phase becomes a verb in the 12-column verb field, right +aligned like `Compiling`. Every phase is at most eight letters: `Planning`, +`Running`, `Building`, `Stopping`, `Checking`. In the style with a spinner, +the spinner stands in the gutter at column 2, where no verb reaches: + +``` + Compiling platform v0.1.0 (modules/platform) + ⠹ Building ━━━━━━━━━━━━━━━━━━━━╸─── 612/707 · 0:35 +``` + +**Three tests for an element.** An element earns its place when it passes +all three: + +- it states a fact (R7); +- it survives the terminals of section 5.7, or degrades to text; +- it says something the rest of the row does not. + +Two elements pass that the previous rounds did not have. + +- **The tail clause, `last N running`.** + - ninja reports `%u`, the steps not yet started. Measured with ninja + 1.12.1 through a pipe (six steps of 0.1 s to 1.5 s at `-j3`), `%u` + reached 0 when the last step started, and from then on the status lines + counted down `t − f` running steps exactly. + - Once `%u` is 0, the row states `last 7 running`, followed by the name + of a known action if one is among them: + `Building ⣿⣿…⣷ 706/707 · 0:10 · last 1 running · gui: CMAKE ElaWidgetTools 3:12`. + - The clause answers the question a long build raises most often ("why + is it still running at 99 %"). It needs one more placeholder in the + status format. +- **The LED bar.** + - The bar is made of braille cells: `⣿` for a filled cell, and `⣀` (the + bottom row of dots) for the track, which reads as a dotted rule. The + head cell fills dot by dot, `⣀⣄⣆⣇⣧⣷⣿`, six steps a cell. + - At 24 cells the bar has 144 positions. A build of 707 steps therefore + moves it every five steps, and the motion is the progress itself, so no + spinner is needed. + - Every braille cell is East Asian narrow, so the LED bar is immune to the + ambiguous-width hazard of section 5.7. Only the separator `·` remains + ambiguous, and the conservative budget covers it. + - The cache's share is the first run of cells, in cyan; failure turns the + filled cells red; planning shows three lit cells sweeping along the + track. + - Where braille is unavailable (conhost), the ASCII bar applies. + +``` + Compiling i18n v0.1.0 (modules/i18n) + Building ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⣀⣀⣀ 600/707 · 0:06 +``` + +Elements considered and not taken: + +- **A heat trail**: each filled cell coloured by the pace at which it was + filled, so that the finished bar shows where the build was slow. It is + accurate, but sixteen colours give it two or three tones, too few to read. +- **One cell per package**: package completion is not known (section 13, A). +- **The last finished file**: it states the past as if it were the present + (section 13, E). + +**The two candidates** are in the demonstration (`--style bar`, +`--style led`): + +| | bar | led | +|---|---|---| +| Look | a line, as in pip and rich | a dotted LED strip, as in btop | +| Motion | spinner in the gutter, turning once per finished step | the head fills dot by dot | +| Resolution | 48 positions (half cells) | 144 positions | +| Ambiguous-width terminals | needs autowrap off and the conservative budget | immune, apart from `·` | +| conhost | ASCII | ASCII | + +The recommendation is **led**. It is the more compatible of the two where +compatibility is weakest, namely CJK terminals, which are a large share of +mcpp's users. Its motion carries information without a separate element, +and its look is distinctive without extra elements. The tail clause and the +tab and taskbar progress apply to either candidate. + +### 5.9 Review round 4: what the dots can carry + +The fourth review asked three questions about the LED bar: + +- whether each dot can be controlled; +- whether the dots can be coloured by time spent or by library; +- whether the dots can carry simple animation. + +**What can be controlled.** + +- A braille cell is a matrix of 2 × 4 dots, each on or off (U+2800 plus + eight bits, 256 patterns), so every dot is addressable. At 24 cells the bar + is 48 columns of four dots. +- Colour applies to the whole cell: the eight dots of a cell share one + foreground colour. A background colour fills the cell's rectangle rather + than its dots, so it would draw blocks and is not used. +- The finest unit of colour is therefore a cell, two columns wide. The + finest unit of shape is a dot. + +**Colour by library.** One hue per package was considered and rejected: + +- a build has twenty or more packages; +- the sixteen-colour palette offers about four usable hues once red and + yellow are reserved for failure and warning, and blue is too dark on a + dark background; +- 256 colours are not available everywhere (conhost, many remote sessions); +- a reader would need a legend; +- colour-blind readers lose the distinction. + +What the palette can carry is three kinds, the same three the package lines +use: + +- the cache (grey); +- a dependency (the default colour); +- the project (cyan). + +Each filled cell takes the kind of the steps that filled it. The bar then +says how much of this build was the project's own code, how much the +dependencies', and how much the cache spared. The names above the bar +already carry the same colours, so they serve as its legend. + +**Time spent.** Colour cannot carry both kind and cost, since a cell has one +colour, but the height of a column can carry cost. In the `cost` form, a +filled column is as tall as its slowest step: two dots under 0.5 s, three +under 5 s, four otherwise. The measured facts are the durations that +ninja's log already gives the model. + +- The staging of 480 units from the cache lies flat, heavy units stand up, + and the long action at the end is a full column. +- The heights read without colour, so under `NO_COLOR` and for colour-blind + readers too. +- The axis is steps, not time. A 12-minute action occupies one column of + 48; the height marks it, and its duration is stated by the tail clause. +- The filled part is no longer solid, so the fraction is read at the head + and at the colour change to the grey track. + +**Animation.** Every motion is driven by an event, or states that no total +is known: + +| Motion | Driven by | Verdict | +|---|---|---| +| The head fills dot by dot (six steps a cell) | finished steps | kept | +| Sand: in each frame in which steps finished, one dot drops from the top row of the head cell and falls a row per frame onto the fill | finished steps: a steady fall means a busy build, none means a wait | proposed | +| Three lit cells sweep along the track | planning, which has no total | kept | +| The cell where the first failure landed turns red and stays red while the build stops | the failure | proposed | +| A shimmer running along the bar | nothing | not proposed: it claims activity that may not exist | +| A flourish when the bar is full | nothing, and `Finished` would have to wait for it | not proposed | + +**The four forms in the demonstration.** `dots_demo.py` plays each form, +and `--fail` adds a failure. Flags: `--style solid|kind|cost|sand`, +`--fail`, `--speed`. + + solid ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⣀⣀⣀ round 3: one colour, cache share in a second tone + kind ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⣀⣀⣀ each cell coloured cache / dependency / project + cost ⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣶⣶⣷⣷⣷⣾⣿⣆ height by the slowest step, colour by kind + sand ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇⣁⣀⣀ solid, with a grain falling into the head + +The recommendation is kind, with sand and the failure mark. It stays one +calm strip. Its colours repeat the package lines, so it needs no legend. +Its only motion beyond the head is one grain in one cell, and that motion +is the build's pace. Cost is the alternative if the shape of the build's +cost is wanted on the bar. It reads without colour, at the price of a +textured fill. + +All forms use braille cells only, which are East Asian narrow, and four +colours of the sixteen-colour palette: grey (90), default (39), cyan (96) +and red (31). conhost receives the ASCII bar. The data they need is already +in the model: the kind of each step (the step record's package, and the +cache pass), and its duration (ninja's log). + +### 5.10 Review round 5: colour by source, and the dots as a display + +The fifth review asked for two changes: + +1. The name's colour should state where the package comes from: the + official index, a path, a git repository, or another index. A package + inside the project should be plain. +2. The dot area need not be a bar filling from left to right. Since the + counts state the progress, the area could play simple pixel animations: + a few built in, one chosen at random. + +**Colour by source.** This replaces the project-cyan rule of section 5.8. + +| Source | Name | Origin in parentheses | Colour of the name | +|---|---|---|---| +| inside the project (root, members, path dependencies under the root) | short name | the directory: `(modules/platform)`, `(.)` | the default colour | +| the official index (the default index: `mcpplibs.*`, `compat.*`) | full identity | none | cyan | +| another index (a project `[indices]` entry) | full identity | `(index acme)` | magenta | +| a git repository | full identity | `(git tag v0.3.0)`, `(git rev 1a2b3c4d5e6f)` | blue | +| a path outside the project | full identity | `(../util)` | the default colour | + +- The version and the origin stay dim, and the verb keeps its colour. +- Every source is also distinct in text, by its origin, so the scheme reads + under `NO_COLOR`. +- Four colours are used: default, cyan, magenta and blue (the bright blue, + 94, which reads on dark and light backgrounds). Red and yellow stay + reserved for failure and warning. +- `compat.*` and `mcpplibs.*` share the official colour, because both come + from the official index. A fifth colour for upstream ports was considered + and not proposed: it would separate two namespaces of one source. +- The index a package came from is known to the plan: the index route that + resolved its namespace. + +**The dots as a display.** The display is 48 × 4 dots. Four dots of height +rule out text: a 3 × 4 font is unreadable. It fits small sprites, a trace, +or a cellular automaton. The forms considered: + +| Form | Fits 48 × 4 | Verdict | +|---|---|---| +| Pac-Man eating a row of pellets | yes: 4 × 4 sprites | built in | +| An ECG trace | yes: a trace is one dot per column | built in | +| Pong, self-playing | yes: 2-dot paddles, a 1-dot ball | built in | +| Conway's Game of Life on a 48 × 4 torus | yes: a glider needs 3 × 3 | built in | +| Snake | yes | built in | +| Breakout, Dino runner, Tetris lying on its side | yes, with more rules to write | not in the first set | +| Space Invaders | no: its sprites need 8 rows | not proposed | +| Elementary automata (Rule 30, Rule 110) | a column of four cells has 16 states, so it cycles within seconds | not proposed | +| Scrolling text | no: unreadable at four dots | not proposed | + +**One rule keeps the animations honest.** Time moves an animation slowly, +which shows that mcpp is alive. Finished steps move it further, which shows +that the build is busy. A failure changes it. A playful form therefore still +carries the build's pulse, and a wait looks like a wait: the counts and the +tail clause then say why. + +| Animation | Time moves | Finished steps move | A failure | +|---|---|---|---| +| pacman | a slow chomp | a chomp; Pac-Man's position is the fraction, and the pellets ahead of him are the work left | the ghost catches him; he turns red | +| heartbeat | the trace scrolls one column a frame; flat without work | a beat when steps finished since the last one; the busier the build, the faster the heart (up to one beat a second), and a large batch beats tall | the trace turns red | +| pong | the ball moves slowly | the ball speeds up with the pace | the ball stops, red | +| life | a generation every 0.6 s | a generation a frame; the world is seeded from the project's name and reseeded when it stills or cycles | it freezes, red | +| snake | it moves slowly | it moves faster; it grows with the fraction | it stops, red | +| bar | (the LED bar of round 4, each cell coloured by source) | fills | the failure's cell turns red | + +**Choosing an animation.** + +- One animation is chosen at random per command and kept until + `Finished`, since switching during a build would be noise. +- `MCPP_PROGRESS` names one (`pacman`, `heartbeat`, `pong`, `life`, + `snake`), or chooses `bar`, `plain` (text only), or `off` (no status row; + the heartbeat lines of a log). +- A default chosen per day instead of per command was considered: it is + less surprising, and less playful. + +**Structure.** + +- A dot canvas (48 × 4, one colour per lit dot) is rendered to braille with + one colour per cell: a sprite's colour where the cell holds one, the + background colour otherwise. +- An animation implements `update(elapsed, finished, fraction, failed)` and + `draw(canvas)`. Each is 30 to 60 lines, in a small module beside `mcpp.ui`, + with no dependency. +- Given a seed and a sequence of events, an animation is deterministic. Its + frames can therefore be compared with recorded ones in a unit test, and + the random choice is seeded in tests. +- The status row's budget is unchanged: 24 cells, one write per frame, at + most ten frames a second. + +**Compatibility.** The display uses braille cells (East Asian narrow) and +the sixteen-colour palette only. Under `NO_COLOR` the animations keep their +shapes. Where braille is unavailable (conhost) the display is replaced by +the ASCII bar, and in a log there is no display. + +**The demonstration.** `anim_demo.py` plays each form after a build's +package lines coloured by source (`--anim NAME`, `--random`, `--fail`, +`--speed`). + +### 5.11 Review rounds 6 and 7: a sign, the classics, and a mascot + +The sixth and seventh reviews proposed three things: + +- the display could spell `MC++`, or the project's name, lighting up from + left to right; +- the display could play animations that people recognise at a glance, even + blurred; +- mcpp could have a mascot, as Chrome has its runner, and the mascot could + be the progress animation. + +The review also judged the first animations: the chomper was liked but left +the left side empty; the snake needed work; the rally should become an +emitter that deposits the progress; and Tetris lying on its side was asked +for. + +**The sign.** + +- A four-dot font (letters, digits, `+` and `-`; `M`, `N` and `W` five dots + wide, the others three) draws `MC++` at double width in 34 of the 48 + columns. +- The part the build has reached is lit. The rest shows as a dim outline, + so the whole word is visible from the start and fills in. +- A lit column at the frontier, the print head, is steady while steps + finish and blinks while the build waits. +- The project's name is used instead when it fits: at double width (up to + about six letters: `XLINGS` takes exactly 48 columns), otherwise at single + width (up to about ten). Otherwise, and for a name outside the font, the + sign falls back to `MC++`. + +**The classics.** Ranked by how well they survive four dots: + +- the chomper and its ghosts; +- a light sweeping to and fro with a trail (the scanner of a 1980s + television car); +- an ECG trace; +- three blocks gliding along a track (a well-known operating system's boot + screen); +- the snake; +- the rally; +- Tetris on its side; +- a runner jumping cacti. + +Game of Life reads only to those who know it. In the product the +animations carry generic names (`chomp`, `scanner`, `pulse`, `glide`, +`snake`, `rally`, `stack`, `runner`, `life`), since several of the +originals are trademarks. + +**The revised four.** + +- **chomp** + - The chomper stays at column 16 and the maze scrolls past it, so both + sides of the display are occupied. + - Three ghosts chase from behind. + - The pellets ahead are the work left: the stream is three widths long and + ends at completion, so its end comes into view in the last fifth of the + build. + - A package's first step drops a power pellet ahead; eating it turns the + ghosts blue for two seconds. + - A failure: the ghosts close in. +- **snake** + - The snake follows the shortest free path (breadth-first search on a + 48 × 4 grid that wraps horizontally) to its food, instead of wandering. + - A package's first step drops a golden food. + - The snake grows with the fraction. Its head is bright, and the last + third of the body dims. +- **ions** (the rally, reworked) + - An emitter on the right edge scans the four rows and fires one to three + ions for each frame in which steps finished. + - The ions fly left and vanish at the deposit. + - The deposit is the progress bar. Its columns fill from the bottom, and + each dot is coloured by the source whose work it records: the cache + grey, the official index cyan, the project white. The deposit follows + the fraction exactly; the ions only show the pace. +- **stack** (Tetris on its side) + - Pieces fall leftward and rest against the stack. Each piece is placed, + over its rotations and rows, where it leaves the fewest holes. + - At most three pieces fly at once, and a burst of progress (the cache + pass) settles its pieces at once, so the stack's area follows the + fraction within four pieces. + +**A mascot.** A mascot works at this size when its silhouette survives four +dots, it tells the tool's story, and it is distinct from its neighbours. + +| Candidate | Story | At four dots | Neighbours | +|---|---|---|---| +| beaver | the builder: carries logs (modules) and builds a dam (the build) | the flat tail makes the silhouette | DBeaver, a database client; no build tool | +| ants | the parallel workers: the number of ants can be the number of running steps | a column of small dots in motion | Apache Ant, a Java build tool | +| weaver bird | weaves pieces into a nest, as linking joins units | a bird in flight | none known | +| pangolin | a body of scales (modules) that curls into one ball; native to China, where much of the community is | a scaled arc | none known | + +The prototype is the beaver: + +- the dam on the left is the progress, drawn as logs laid in courses; +- the pile on the right is the work left, and shrinks; +- a pond lies behind the dam; +- the beaver carries a log from the pile to the dam for each stretch of + finished steps, faster when the build is busy, and slaps its tail on the + water when a package starts; +- a failure opens a red breach in the dam. + +The sign and the mascot combine: the beaver's dam can be laid in the shape +of `MC++`, so the finished build shows the logo built of logs. A mascot +also needs a full-size drawing for the README, the documentation and an +icon. That is design work of its own, and the four-dot sprite is its +smallest rendition. + +**Demonstrations.** + +- `logo_demo.py`: the sign, the project's name, the scanner, the glide, the + runner and the first stack. +- `play_demo.py`: the revised chomp, snake, ions and stack, and the beaver. + Flags: `--anim NAME`, `--fail`, `--speed`. + +## 6. Requirements, revised + +| Revision 2 | Revision 3 | +|---|---| +| R1 each step's line carries its state, updated in place on a terminal | **R1'** A package is named once, when it does work in this build: `Compiling` when the first of its steps finishes, `Cached … (N units)` when the cache pass places its units (design C of section 5.3). A line, once written, is never rewritten, recoloured or moved; it leaves the screen only by scrolling. | +| R2 one status line, separated from the step lines by a blank line | **R2'** The live display is one status line, directly below the output, redrawn in place (design 2 of section 5.4). There are no live rows and no blank row. Download bars remain above it while they run. | +| R3 `Finished` after a blank line, with the total and its parts | kept. The parts are stated from 60 s, and `longest` names the source file (section 7.3). | +| R4 dependencies folded into one line | withdrawn: R1' names every package that does work. | +| R5 `-v` shows everything | kept, and extended by one summary line per package at the end (section 7.4). | +| R6 `done`, `ran`, `cached`, `waiting`, `failed`, `fresh` | `ran`, `cached` and `failed` state a build program's outcome. `waiting`, `compiling` and `running` appear in the status line. `fresh` appears in `-v`. | +| R7 no line states something mcpp does not know | kept. F5 violates it, and R1' is exact by construction. | +| | **R8** A frame reaches the terminal in one write, and no screen between two frames shows the region partly drawn. | +| | **R9** A bare key of a `path` or `git` dependency states only the short name, and its adoption of the declared identity is not reported (W-b of section 5.2). A key that states a namespace the declaration contradicts is reported once per file, with the file named relative to the project and a hint written in the file's syntax. | + +R1' follows the convention of cargo: a unit is named when it is compiled, and +units with nothing to do are not named. It needs no knowledge of which steps +ninja will run. Revision 2 needed that knowledge and could not obtain it +(its §3.1). + +## 7. The output + +### 7.1 Lines + +| Verb | Subject | Written when | Example | +|---|---|---|---| +| `Compiling` | the package | its first step in the main ninja pass finishes, or its first `check` or `prepare` action starts | ` Compiling platform v0.1.0 (modules/platform)` | +| `Cached` | the package, with the number of units placed | the cache pass ends, for each package whose units it placed; sorted by name | ` Cached compat.ftxui v6.1.9 (73 units)` | +| `build.mcpp` | the package | the program ran or failed | ` build.mcpp mcpplibs.xpkg v0.0.59 ran 0.71s` | +| `Downloading` | unchanged | unchanged | unchanged | + +- **The subject** has two forms (section 5.8). + - A package inside the project is named by its short name, its version and + its directory relative to the project root, in the default colour: + `platform v0.1.0 (modules/platform)`, `xlings v2026.9.29.1 (.)`. + Inside the project are the root, the workspace members, and the path + dependencies whose directory lies under the project root. + - Every other package is named by its full identity and version, and its + name is coloured by its source (section 5.10). A package from another + index adds the index, a git dependency its reference, and a path outside + the project its relative directory: `compat.ftxui v6.1.9`, + `acme.fmt v11.0.2 (index acme)`, `acme.fw v1.2.0 (git tag v1.2.0)`, + `util v0.2.0 (../util)`. + - The version, the origin and a unit count are dim. This replaces the + three forms of F7. +- **The standard library module** is named `Compiling std` when it compiles. + It is not named when it is staged, because every first build of every + project would then name it. +- **A build program** whose result is reused (`cached`) did no work and is + listed only with `-v`. The programs block keeps its state column, since + mcpp knows each program's outcome exactly. +- **A failed step** is followed by `error: build failed in ` and the + step's diagnostic. The prefix `error: build failed` is kept. + +### 7.2 The status line + + f/t · [ · last N running][ · ] + +The phase is aligned with the verbs above it. The bar, its fallbacks, the +tail clause `last N running`, the tab and taskbar progress and the setting +`MCPP_PROGRESS` are those of section 5.8. The quiet style is this line +without the bar. + +- The phases are verbs of at most eight letters: `Planning`, `Running` + (the build programs; the bar counts programs), `Building`, `Stopping` and + `Checking`. `Planning` replaces `Resolving`, which also opened the line + `Resolving toolchain`, and matches the `plan` part of `Finished`. The + phase returns to `Planning` when the build programs end (F6). +- `` is the longest-running step that mcpp knows to be running: a + build program with its state (`mcpplibs.xpkg compiling 0:02`), or a `check` + or `prepare` action (`gpp.gui: CMAKE ElaWidgetTools 6:10`). It is omitted + when no such step runs. The package of the step that finished last is not + shown (section 13, E). +- The line is first drawn when the command has run for 0.5 s. A command that + ends sooner never shows it, and the lines written in its first half-second + do not move it (F2). +- The cache pass counts in `Building f/t` like the main pass: its stage + steps are part of the build (F5). + +### 7.3 `Finished` + +- Below 60 s: `Finished [] in `. +- From 60 s: the parts and `longest`, as in revision 2. For a compile step, + `longest` names the step's source file (`xlings: src/core/xself/doctor.cpp`), + which the step record now carries (section 10). +- The fast path states the descriptor. The step record's header carries it, + and the fast path reads only that line (F8). + +### 7.4 Verbosity + +`-v` adds four things: + +- `Fresh ` for each package with nothing to do; +- the reused build programs (`cached`); +- each ninja step as `[f/t] `; +- before `Finished`, one line per package that did work: + ` Compiled xlings v2026.9.29.1 (.) · 610 steps · 37.09s`. + +The span in the last line runs from the package's first step to its last, +read from ninja's log. This is the `done ` of revision 2, stated when it +is exact: when ninja has exited. + +### 7.5 Examples + +The examples use the recordings' names and times. The order of the +`Compiling` lines is the order in which each package's first step finished, +which varies between runs. + +The first build of the xlings tree (B1), under W-b (so the xlings manifests +need no correction) and with F10 applied: + +``` + Workspace building member 'xlings' + build.mcpp mcpplibs.xpkg v0.0.59 ran 0.71s + Cached compat.bzip2 v1.0.8 (7 units) + Cached compat.ftxui v6.1.9 (73 units) + Cached compat.libarchive v3.8.7 (127 units) + Cached compat.lua v5.4.7 (32 units) + Cached compat.lz4 v1.10.0 (5 units) + Cached compat.mbedtls v3.6.1 (108 units) + Cached compat.xz v5.8.3 (74 units) + Cached compat.zlib v1.3.2 (15 units) + Cached compat.zstd v1.5.7 (26 units) + Cached mcpplibs.capi.lua v0.0.3 (2 units) + Cached mcpplibs.cmdline v0.0.2 (3 units) + Cached mcpplibs.tinyhttps v0.2.9 (8 units) + Compiling cancellation v0.1.0 (modules/cancellation) + Compiling json v0.1.0 (modules/json) + Compiling sha256 v0.1.0 (modules/sha256) + Compiling theme v0.1.0 (modules/theme) + Compiling platform v0.1.0 (modules/platform) + Compiling i18n v0.1.0 (modules/i18n) + Compiling xhttp v0.1.0 (modules/tinyhttps) + Compiling mcpplibs.xpkg v0.0.59 + Compiling xlings v2026.9.29.1 (.) + + Finished dev [unoptimized + debuginfo] in 55.38s +``` + +On a terminal during that build, the status line is the last row: + +``` + Compiling platform v0.1.0 (modules/platform) + Compiling i18n v0.1.0 (modules/i18n) + Building ⣿⣿⣿⣿⣿⣿⣿⣿⣷⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 247/707 · 0:19 +``` + +After an edit of one source file of the root (B2): + +``` + Workspace building member 'xlings' + Compiling xlings v2026.9.29.1 (.) + + Finished dev [unoptimized + debuginfo] in 9.59s +``` + +After an edit of a module of `modules/platform` (B3): + +``` + Workspace building member 'xlings' + Compiling platform v0.1.0 (modules/platform) + Compiling xlings v2026.9.29.1 (.) + + Finished dev [unoptimized + debuginfo] in 18.94s +``` + +A failed build: + +``` + Compiling bad v0.1.0 (.) +error: build failed in bad v0.1.0 (.) +src/main.cpp:1:2: error: #error SV_MARKER +``` + +A long build (the validation project's cross-verification of #742): + +``` + Finished … in 19m07s · plan 1m48s · programs 29.45s · build 16m49s · longest gpp.gui: CMAKE ElaWidgetTools 12m46s +``` + +## 8. The identity warning + +Under W-b (section 5.2) the xlings manifests of the report produce no +warning: every key there is bare. What remains reported is a key that +states a namespace which the manifest at its source contradicts. For +example, `[dependencies.acme] cancellation = { path = "modules/cancellation" }` +reaches a manifest that declares `xlings`. One warning is written per +consumer manifest. It names the file relative to the project root and every +key of the file that the rule concerns, and its hint is written in TOML: + +``` +warning: mcpp.toml names 2 dependencies in namespace acme, and the manifests they reach declare xlings; the declared identity is used: cancellation, platform + hint: write them in a [dependencies.xlings] table, for example `cancellation = { path = "modules/cancellation" }` +``` + +Under W-a the same form also applies to bare keys whose path lies outside the +project, and to bare keys of git dependencies. The xlings manifests produce +no warning under W-a either, since all their paths lie inside the project. + +- The resolver collects the adoptions while it walks the graph and reports + them once, after resolution. It groups them by consumer manifest and by + declared namespace. A consumer with keys in two declared namespaces + receives two warnings. +- The terminal rendering and the machine record are one record per group. + The record's `what` names the keys, so a machine reader still receives + every key. +- The once-per-process rule of `mcpp.diag` still applies, so a `--workspace` + build states each group once. + +## 9. The terminal writer + +### 9.1 One write per frame (R8) + +The renderer composes the whole byte sequence of a frame before it writes +anything. A frame consists of the lines leaving above the region and the +region's new rows. The renderer flushes stdio, then writes the sequence with +one call: `write(2)`, repeated only on a partial write or `EINTR`, on a +POSIX terminal, and `WriteConsoleW` on a Windows console. Nothing in the +frame passes through the line-buffered `stdout`. + +### 9.2 Overwrite instead of erase + +A frame first moves the cursor to the region's first row (`\r`, then +`ESC[{n-1}A` when the region has more than one row). It then writes each +leaving line and each new row, each followed by `ESC[K`, with rows separated +by `\n`. It ends with `ESC[J`, which clears the rows of a larger previous +region. No row is erased before it is written, so no screen between two +frames shows the region blank, even when the terminal paints in the middle +of a write. + +### 9.3 Standard error + +When standard error is the same terminal as standard output, a line written +to standard error is part of the stdout frame. On POSIX this is decided by +`isatty` on both descriptors and equal `st_rdev`; on Windows, by both handles +being console handles. Otherwise the line is written to standard error alone +and the region is not touched: the line does not reach the screen that holds +the region. + +### 9.4 Fewer frames + +A frame is written only when its bytes differ from the previous frame's. The +rate stays at ten frames a second at most. The clock changes once a second, +so a build that makes no progress writes one frame a second, and that frame +rewrites one row. + +### 9.5 What is removed + +The live package rows, the `… N more` row, the blank separator row, and the +height calculation `max_live_lines` are removed. The region holds the +download bars and the status line. + +### 9.6 Platforms + +The sequences used are `\r`, `\n`, `ESC[nA`, `ESC[K` and `ESC[J`. xterm, +VTE, iTerm2, Terminal.app and Windows Terminal support them, as does the +Windows console once virtual-terminal processing is enabled. Where +`can_move_cursor` answers no (`TERM=dumb`, a console without +virtual-terminal processing, a pipe), the log medium applies and is +unchanged. It writes lines as they become final, and a heartbeat after 60 s +of silence. + +## 10. The progress model + +- **Removed**: the folded lines (`dependencies_line`, + `folded_programs_line`), the completion rule as the trigger of a line + (`complete`, `dependencies_complete`, the commit loop of `settle`), + `package_column`, and the frame's live rows. +- **Added**: + - A package is announced at its first finished step. This replaces + `settle`, and is one set lookup per finished step. + - The cache pass runs through `run_ninja_reporting` as the build's first + pass (`pass_begin`/`pass_end` already sum passes). Its per-package counts + give the `Cached` lines when the pass ends. + - `programs_done()` returns the phase to `Planning`. +- **The step record becomes version 2.** Its header carries the profile + descriptor. An `S` line gives the source file of each compile step. A + version 1 record makes the fast path decline once, so the first build after + the upgrade plans and writes a version 2 record. Without this, the fast + path would name no packages until the next plan. +- **Unchanged**: the log reader and its recompaction handling, the + attribution in the generator, the action-start file, and the heartbeat. + +## 11. Compatibility + +- **Human output.** The package lines, the status line and `Finished` change + as stated. The prefix `error: build failed` is kept. Three kinds of + consumer read these lines: + - the e2e tests that read the lines of revision 2: 842, 843 and those + listed in the #742 commit; + - mcpp-plugins' `.github/scripts`; + - the validation project's workflow. + + Each is checked before the pull request is opened. +- **Machine output.** Unchanged, except that the identity warnings are + grouped (section 8). +- **Upgrade.** A build directory with a version 1 record is planned once + (section 10). No configuration is involved. +- **Downgrade.** An older mcpp reads a version 2 record as absent: its fast + path names no packages until its next plan. + +## 12. Not addressed here + +- **Planning after every edit.** Every source edit takes the planned path by + the rule of mcpp#225. This cost 3.06 s of the reported 33.63 s, and more in + a larger workspace. It deserves its own design. +- **The duplicated lock entries (F11).** The fix belongs in the resolver's + lock writer: one entry per identity, keyed by the qualified name. It is + proposed as a separate commit of the same pull request, with an e2e in + which a second planned build leaves `mcpp.lock` byte-identical. +- **A terminal resized during a build.** Rows drawn at the old width may + wrap. The next frame's `ESC[J` clears what lies below the cursor, but not + a wrapped row above it. cargo shares this limitation. It is recorded, not + solved. + +## 13. Alternatives considered + +- **A. Keep the outcome on each package line, and learn completion from a + dry run.** `ninja -n` takes 10 ms on xlings's graph of 1,200 steps and + lists the steps a build will run. On an empty build directory, however, it + stops at the first dyndep file that does not yet exist (measured: at step + 730 of 1,200 with + `loading 'obj/xlings_xhttp/src/tinyhttps.cppm.ddi.dd': No such file or + directory`). Every first build is such a case, and the first build is the + one a user watches. Any fallback would state completion early or late + (R7). Not taken; the exact spans move to `-v` (section 7.4). +- **B. Keep the live rows and repair only the writer.** This removes F1 but + keeps F3 (the region changes height) and F5 (the late dependency line). +- **C. Synchronised output (DEC private mode 2026).** It is unnecessary once + a frame is one write that overwrites in place (section 9). Terminals that + do not know the mode ignore it, so it can be added later without risk. +- **D. Keep the fold, and list the names in the folded line.** The line + would be truncated to the terminal's width, and would still be late (F5). +- **E. Show the package of the step that finished last in the status line.** + A reader takes it for the package being compiled, which mcpp does not know + (R7). + +## 14. Tests + +- **Unit, writer, ambiguous width.** The frames are replayed through the + emulator with ambiguous characters counted as two columns and a width of 60. + No stale status row remains, and under a CJK locale `fit` leaves the row + within the width. +- **Unit, writer.** + - A capture seam replaces the descriptor write. + - Each emitted line and each redraw is one write. + - Replaying the writes through the terminal emulator shows the status line + after every write between the first draw and `Finished`. + - No frame contains `ESC[J` before its last row. + - With an injected clock, nothing is drawn before 0.5 s. + - An unchanged frame is not written. +- **Unit, model.** + - A package is announced once, at its first finished step, and never + again after a recompaction of the log. + - The cache pass yields `Cached` lines with the counts of steps that ran. + - The phase returns to `Planning` after `programs_done()`. + - A record of version 2 round-trips its descriptor and source lines. + - A version 1 record makes the fast path decline. +- **Unit, identity warning.** A bare key of a path or git dependency is not + reported. A key that states a contradicted namespace is reported, and the + formatter groups such keys by manifest and namespace, uses relative paths, + and writes a hint that names the table. +- **e2e 842 (log medium).** It is revised to check that: + - every package that does work is named once, including path and index + dependencies; + - an incremental build names only the edited package and its dependents; + - no `Cached` line appears when the cache pass placed nothing; + - the fast path's `Finished` states the descriptor. +- **e2e 843 (terminal).** It is revised to check that no blank row lies + above the status line, and that the final screen holds the package lines + and `Finished`. The replay helper moves to `tests/e2e/_terminal_replay.py`, + beside the other shared helpers, so that 842, 843 and later tests share it. +- **e2e, new.** + - Three manifests with bare keys to path dependencies that declare a + namespace build with no warning, and the graph record names the declared + identities. + - Two keys that state a contradicted namespace yield one warning, with the + relative path and the table named. + - A second planned build with `[dependencies.mcpplibs]` keys leaves + `mcpp.lock` byte-identical (F11). + - e2e 679 (#634 A2) and 713 (a git member) assert the adoption warning + today. They are revised so that a bare key does not warn and a key that + states a contradicted namespace does. +- **After release.** The sandbox verification of the released binary + repeats B1 to B3 on the xlings tree. The validation project's CI accepts + the release, as for 2026.9.29.5. + +## 15. Tasks and order + +| # | Task | Repository | Depends on | +|---|---|---|---| +| T1 | The writer: one write, overwrite, standard error, delay, unchanged frames (section 9) | mcpp | | +| T2 | The model: announce at first step, the cache pass reported, the phase fix, removals (section 10) | mcpp | T1 | +| T3 | Subjects and the programs block (section 7.1) | mcpp | T2 | +| T4 | `Finished`: 60 s, the source of `longest`, the descriptor on the fast path; record version 2 | mcpp | T2 | +| T5 | The identity rule of W-b and the grouped warning (section 8); `package-identity.md` §4.2 and docs/05 | mcpp | | +| T6 | Configuration lines under `-v` unless they state a change (F10) | mcpp | | +| T7 | One lock entry per identity (F11) | mcpp | | +| T8 | Tests (section 14); docs/09 "What a build prints" in both languages; CHANGELOG | mcpp | T1–T7 | +| X1 | The lock restored; optionally `[dependencies.xlings]` in three manifests, which removes the warnings for users of earlier mcpp releases | xlings | T7 for the lock only | + +T1 to T8 form one mcpp pull request. T5, T6 and T7 are separate commits +within it, so that each can be reviewed alone. X1 does not wait for this +change. + +## 16. Decisions for the reviewer + +Settled in review round 1: the terminal writer (section 9); `Cached` lines +listed one per package, only when the cache pass placed units in this build; +the line format of 2026.9.29.4 with the directory relative to the project in +place of `(path)`; and no written line moves. + +- **D1. The identity warning (section 5.2).** W-a, W-b (recommended) or W-c. +- **D2. When a package line is written (section 5.3).** A, B, or C + (recommended). +- **D2'. The live display (section 5.4).** 1, or 2 (recommended). + +Settled in review round 2: W-b, C and 2. + +- **D3'. The status row (sections 5.6 to 5.10).** + - The display: the LED bar, or the pixel animations chosen at random + (section 5.10). If the animations, the set to build in. + - The tail clause, and the tab and taskbar progress. + - `MCPP_PROGRESS` and its values. +- **D3''. The animations (section 5.11).** The first set to build in, from + the sign (logo or project name), chomp, snake, ions, stack, scanner, + pulse, glide, runner and life; whether mcpp adopts a mascot (the beaver is + the prototype) and whether the mascot builds the sign. +- **D4'. Colour by source (section 5.10).** The five sources and four + colours as tabled; whether `compat.*` shares the official colour. + +Settled in review round 3: a package inside the project is named by its +short name and drawn in the project colour; the phase aligns with the verbs. +- **D3. The configuration lines (F10).** The recommendation is to move + `Resolving toolchain`, `Resolved`, `Target` and `Inferred` under `-v`, + except when the toolchain is installed in this command. This changes the + e2e tests that read those lines. `Workspace building member` is kept, + because it states a selection. +- **D4. `Finished`.** The recommendation is to state the parts from 60 s + rather than 10 s, and to name the source file in `longest`. +- **D5. The lock defect (F11).** The recommendation is to fix it in this + pull request, as its own commit. diff --git a/.agents/docs/README.md b/.agents/docs/README.md index fc89d3b2..008135e3 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -318 records. +319 records. ## By subject @@ -30,6 +30,7 @@ Records that declare one. Everything else is listed by date below. ### design +- [Build output, revision 3: every package that does work is named, the live display is one line drawn in one write, and a repeated warning is stated once per file](2026-09-30-build-output-refinement-design.md) — active - [The workspace as the unit of build: one graph per configuration, one scheduler, product directories, and a reusable graph module](2026-09-29-workspace-build-graph-design.md) — landed - [Build progress: each step's line states its outcome, and one status line states the build](2026-09-29-build-progress-display-design.md) — landed - [An ecosystem design for mcpp and xlings: one authority per fact, and the work that follows from it](2026-09-28-ecosystem-design-and-optimisation-plan.md) — landed @@ -109,6 +110,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-09 +- [Build output, revision 3: every package that does work is named, the live display is one line drawn in one write, and a repeated warning is stated once per file](2026-09-30-build-output-refinement-design.md) — active - [The workspace as the unit of build: one graph per configuration, one scheduler, product directories, and a reusable graph module](2026-09-29-workspace-build-graph-design.md) — landed - [Build progress: each step's line states its outcome, and one status line states the build](2026-09-29-build-progress-display-design.md) — landed - [Two days of mcpp and xlings: a review of what merged, what is known, and what is open](2026-09-28-ecosystem-review-of-two-days-of-mcpp-and-xlings.md) — active From 3cb6a3a3a0c009821fd80e568974adda4dcf0279 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:05:21 +0800 Subject: [PATCH 04/17] a build names each package when it does work, and the status row is drawn in one write, aligned with the verbs, beside a screen of dots Build output design revision 3 (.agents/docs/2026-09-30-build-output-refinement-design.md). A package's line is written when its first step finishes or its first check or prepare action starts: Compiling, or Cached with its units when the global cache serves it; nothing is folded, and the line never changes. The cache pass runs through the report as the build's first pass, so its steps count and cached packages are named as their units are placed. A package inside the project is named by its short name and directory, any other by its identity, the name coloured by its source. A build program has a line when it runs or fails. The phase returns to planning when the build programs end. A frame of the live region leaves in one write that overwrites the old rows in place, with autowrap off for the status row; the row is first drawn half a second into the command, aligned with the verbs, and states the last steps running once ninja has none left to start. The status row's screen (mcpp.ui.dots_screen, one partition per animation) plays the chomper, the snake, the stack or the ions, chosen per command; MCPP_PROGRESS names one, or plain, or off. The step record is version 2; a version 1 record is read as before. --- modules/platform/src/terminal.cppm | 122 +++++++ src/build/ninja_backend.cppm | 39 ++- src/build/plan.cppm | 6 + src/build/prepare/plan.cpp | 57 ++- src/build/progress.cppm | 522 ++++++++++++++-------------- src/ui.cppm | 186 +++++++--- src/ui/dots_screen/chomp.cppm | 40 +++ src/ui/dots_screen/core.cppm | 158 +++++++++ src/ui/dots_screen/dots_screen.cppm | 45 +++ src/ui/dots_screen/ions.cppm | 63 ++++ src/ui/dots_screen/snake.cppm | 118 +++++++ src/ui/dots_screen/stack.cppm | 110 ++++++ tests/unit/test_build_progress.cpp | 196 +++++++---- tests/unit/test_dots_screen.cpp | 140 ++++++++ 14 files changed, 1407 insertions(+), 395 deletions(-) create mode 100644 src/ui/dots_screen/chomp.cppm create mode 100644 src/ui/dots_screen/core.cppm create mode 100644 src/ui/dots_screen/dots_screen.cppm create mode 100644 src/ui/dots_screen/ions.cppm create mode 100644 src/ui/dots_screen/snake.cppm create mode 100644 src/ui/dots_screen/stack.cppm create mode 100644 tests/unit/test_dots_screen.cpp diff --git a/modules/platform/src/terminal.cppm b/modules/platform/src/terminal.cppm index 69ce2956..88e2b1ac 100644 --- a/modules/platform/src/terminal.cppm +++ b/modules/platform/src/terminal.cppm @@ -6,13 +6,17 @@ // can_move_cursor(s)— whether a live display may be drawn on it // cols(), rows() — the terminal's size // write(s, text) — UTF-8 text to a standard stream +// write_frame(s, b) — a frame of a live display, in one write +// same_terminal() — whether stdout and stderr reach one terminal module; +#include #include #include #if defined(__unix__) || defined(__APPLE__) #include #include +#include #endif #if defined(_WIN32) #include // _dup, _dup2, _close, _get_osfhandle, _fileno @@ -70,6 +74,39 @@ std::size_t rows(); // receives the bytes through stdio, unflushed. void write(Stream s, std::string_view text); +// Writes a frame of a live display in one operation. The stdio buffers of +// both streams are flushed first, so that everything written before arrives +// before; the frame then leaves in one write(2) on POSIX (repeated only for a +// partial write or an interruption) and in one WriteConsoleW on a Windows +// console. Written through stdio, a frame whose last row has no line end was +// flushed in two parts on a line-buffered terminal, and a terminal that +// painted between them showed the display half-drawn (measured: 184 of 202 +// frames of one build, .agents/docs/2026-09-30-build-output-refinement-design.md +// F1). +void write_frame(Stream s, std::string_view bytes); + +// Whether standard error reaches the terminal standard output reaches: both +// are terminals and, on POSIX, the same device; on Windows both are console +// handles, and a process has at most one console. A line for standard error +// can then travel in a frame written to standard output. +bool same_terminal(); + +// Whether the terminal is likely to draw East Asian ambiguous-width +// characters (`·`, `…`, `→`, the box-drawing block) two columns wide: a +// Windows console whose output code page is 932, 936, 949 or 950, or, on +// POSIX, a locale (LC_ALL, LC_CTYPE, then LANG) for Chinese, Japanese or +// Korean. The answer is a likelihood; a live display that budgets its width +// by it is never wider than the terminal either way. +bool ambiguous_wide(); + +// Whether the terminal can be expected to draw characters beyond ASCII from +// its font or a fallback, braille included: on POSIX a UTF-8 locale (LC_ALL, +// LC_CTYPE, then LANG); on Windows a terminal that names itself (Windows +// Terminal sets WT_SESSION, VS Code and others TERM_PROGRAM). The console +// host alone has no glyph fallback, and its long-standing default font lacks +// the braille block. +bool unicode_capable(); + // EVERYTHING WRITTEN TO STANDARD OUTPUT GOES TO STANDARD ERROR UNTIL THIS IS // DESTROYED. // @@ -207,6 +244,91 @@ void write(Stream s, std::string_view text) { std::fwrite(text.data(), 1, text.size(), file_of(s)); } +void write_frame(Stream s, std::string_view bytes) { + if (bytes.empty()) return; + std::fflush(stdout); + std::fflush(stderr); +#if defined(_WIN32) + if (HANDLE h; console_of(s, &h)) { + const int n = ::MultiByteToWideChar(CP_UTF8, 0, bytes.data(), + static_cast(bytes.size()), nullptr, 0); + if (n > 0) { + std::wstring wide(static_cast(n), L'\0'); + ::MultiByteToWideChar(CP_UTF8, 0, bytes.data(), static_cast(bytes.size()), + wide.data(), n); + DWORD written = 0; + if (::WriteConsoleW(h, wide.data(), static_cast(wide.size()), &written, nullptr)) + return; + } + } + std::fwrite(bytes.data(), 1, bytes.size(), file_of(s)); + std::fflush(file_of(s)); +#elif defined(__unix__) || defined(__APPLE__) + const int fd = ::fileno(file_of(s)); + const char* p = bytes.data(); + std::size_t left = bytes.size(); + while (left > 0) { + const auto n = ::write(fd, p, left); + if (n < 0) { + if (errno == EINTR) continue; + return; + } + p += n; + left -= static_cast(n); + } +#else + std::fwrite(bytes.data(), 1, bytes.size(), file_of(s)); + std::fflush(file_of(s)); +#endif +} + +bool same_terminal() { +#if defined(_WIN32) + return console_of(Stream::Out) && console_of(Stream::Err); +#elif defined(__unix__) || defined(__APPLE__) + const int out = ::fileno(stdout), err = ::fileno(stderr); + if (::isatty(out) == 0 || ::isatty(err) == 0) return false; + struct stat a{}, b{}; + if (::fstat(out, &a) != 0 || ::fstat(err, &b) != 0) return false; + return a.st_rdev == b.st_rdev; +#else + return false; +#endif +} + +bool ambiguous_wide() { +#if defined(_WIN32) + if (console_of(Stream::Out)) { + const UINT cp = ::GetConsoleOutputCP(); + if (cp == 932 || cp == 936 || cp == 949 || cp == 950) return true; + } +#endif + for (const char* name : {"LC_ALL", "LC_CTYPE", "LANG"}) { + const char* v = std::getenv(name); + if (!v || !*v) continue; + const std::string_view l(v); + return l.starts_with("zh") || l.starts_with("ja") || l.starts_with("ko"); + } + return false; +} + +bool unicode_capable() { +#if defined(_WIN32) + for (const char* name : {"WT_SESSION", "TERM_PROGRAM"}) + if (const char* v = std::getenv(name); v && *v) return true; + return false; +#else + for (const char* name : {"LC_ALL", "LC_CTYPE", "LANG"}) { + const char* v = std::getenv(name); + if (!v || !*v) continue; + std::string l(v); + for (auto& c : l) c = static_cast(std::tolower(static_cast(c))); + return l.find("utf-8") != std::string::npos || l.find("utf8") != std::string::npos; + } + return false; +#endif +} + StdoutToStderr::StdoutToStderr() { std::fflush(stdout); #if defined(_WIN32) diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 18a59418..ce7a9cb7 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -1199,8 +1199,9 @@ NinjaRun run_ninja_reporting(const std::vector& argv, trimmed.remove_prefix(1); if (trimmed.starts_with("FAILED:")) { inFailure = true; - if (progress.failed(trimmed.substr(std::string_view("FAILED:").size()))) - mcpp::ui::error("build failed"); + if (auto pkg = progress.failed(trimmed.substr(std::string_view("FAILED:").size()))) + mcpp::ui::error(pkg->empty() ? std::string("build failed") + : std::format("build failed in {}", *pkg)); run.reported = true; } if (inFailure) { @@ -1224,7 +1225,7 @@ mcpp::build::progress::Record step_record(const BuildPlan& plan, std::vector declared; declared.reserve(plan.packages.size()); for (auto const& p : plan.packages) - declared.push_back({p.name, p.requested, p.subject, p.cachedUnits, 0}); + declared.push_back({p.name, p.requested, p.subject, p.cachedUnits, 0, p.detail, p.source}); return attribution.record(declared); } @@ -4211,16 +4212,30 @@ std::expected NinjaBackend::build(const BuildPlan& plan // scans are current -- a dependency upgraded with its build served from the // cache -- crashed ninja (e2e 196). After this pass the staged nodes are // current, and the second pass loads those dyndep files when it starts. + // + // WITH A REPORT, THE PASS IS THE BUILD'S FIRST (build output design + // revision 3, §5.3). Run quietly and unread, as it was, its steps never + // counted: a package the cache serves never completed, and 2026.9.29.5 + // wrote its folded dependency line only when ninja exited. Read like the + // main pass, each such package is named `Cached` as its units are placed, + // and its steps count in `Building f/t`. if (manifest.find("\nbuild " + std::string(kStagedCacheGoal) + " : phony") != std::string::npos) { - std::vector pre{ninjaProgram, "--quiet", "-C", plan.outputDir.string(), - std::string(kStagedCacheGoal)}; - // A ninja whose progress nobody reads reports no action start, not - // even into the file of a build that runs this one (design §6.4). - auto preEnv = nenv; - preEnv.emplace_back(std::string(mcpp::build::progress::kStartsEnv), ""); - (void)mcpp::platform::process::capture_exec_deadline(pre, preEnv, - std::chrono::milliseconds(static_cast(opts.buildTimeoutSecs) * 1000), - nullptr); + const auto preDeadline = + std::chrono::milliseconds(static_cast(opts.buildTimeoutSecs) * 1000); + if (opts.progress) { + std::vector pre{ninjaProgram, "-C", plan.outputDir.string(), + std::string(kStagedCacheGoal)}; + (void)run_ninja_reporting(pre, nenv, preDeadline, *opts.progress, opts.verbose, + command_prefixes(flags, plan)); + } else { + std::vector pre{ninjaProgram, "--quiet", "-C", plan.outputDir.string(), + std::string(kStagedCacheGoal)}; + // A ninja whose progress nobody reads reports no action start, not + // even into the file of a build that runs this one (design §6.4). + auto preEnv = nenv; + preEnv.emplace_back(std::string(mcpp::build::progress::kStartsEnv), ""); + (void)mcpp::platform::process::capture_exec_deadline(pre, preEnv, preDeadline, nullptr); + } stage("ninja-staged-cache"); } diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 4a22362a..72773f47 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -300,8 +300,14 @@ std::optional recover_invocation( struct PlanPackage { std::string name; bool requested = false; + // As a package line names it (build output design revision 3, §5.10): the + // short name for a package inside the project, the full identity for any + // other; `detail` is its version and origin; `source` is where it comes + // from: project, official, index, git or path. std::string subject; std::size_t cachedUnits = 0; + std::string detail; + std::string source; }; struct BuildPlan { diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 20edaca1..9f7b53c1 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -2295,10 +2295,30 @@ std::expected phase13_finish(PrepareState& state) { // 2026-09-29, §4.2): as the requester wrote its key, with where it comes from. // A package is named after the edge from the root (or the virtual root) when // there is one, and after its first requester otherwise. +// HOW A PACKAGE LINE NAMES ITS PACKAGE (build output design revision 3, +// §5.8 and §5.10). A package inside the project -- the root, a member, a path +// dependency whose directory lies under the project root -- is named by its +// short name and located by its directory, which is always shown. Any other +// package keeps its full identity, since nothing else on its line says where +// it comes from: an index package (official when the default index serves its +// namespace, otherwise the index is named), a git dependency with its +// reference, a path outside the project with its relative directory. Before +// this, a path dependency was named by the consumer's key and `(path)`, and a +// workspace member by its directory without its version. static void step13_report_packages(PrepareState& state, BuildContext& ctx) { std::vector out; out.reserve(state.packages.size()); const auto base = ctx.projectRoot.lexically_normal(); + auto relative = [&](const std::filesystem::path& root) { + auto rel = root.lexically_normal().lexically_relative(base).generic_string(); + return rel.empty() ? std::string(".") : rel; + }; + auto versioned = [](const mcpp::manifest::Manifest& m, std::string_view origin) { + std::string d = m.package.version.empty() ? std::string{} + : std::format("v{}", m.package.version); + if (!origin.empty()) d += std::format("{}({})", d.empty() ? "" : " ", origin); + return d; + }; for (std::size_t i = 0; i < state.packages.size(); ++i) { const auto& pkg = state.packages[i]; const auto& m = pkg.manifest; @@ -2307,7 +2327,9 @@ static void step13_report_packages(PrepareState& state, BuildContext& ctx) { if (i == 0) { if (m.package.virtualRoot) continue; p.requested = true; - p.subject = std::format("{} v{} (.)", m.package.name, m.package.version); + p.subject = m.package.name; + p.detail = versioned(m, "."); + p.source = "project"; out.push_back(std::move(p)); continue; } @@ -2324,20 +2346,35 @@ static void step13_report_packages(PrepareState& state, BuildContext& ctx) { const auto& deps = state.packages[edge->consumerPackageIndex].manifest.dependencies; if (auto it = deps.find(key); it != deps.end()) spec = &it->second; } - std::string origin; - if (pkg.selectedMember || (spec && spec->workspaceMember)) { - auto rel = pkg.root.lexically_normal().lexically_relative(base).generic_string(); - origin = std::format("({})", rel.empty() ? std::string(".") : rel); + const auto dir = relative(pkg.root); + const bool insideProject = dir == "." || !dir.starts_with(".."); + if (pkg.selectedMember || (spec && spec->workspaceMember) + || (spec && spec->isPath() && insideProject)) { + p.subject = m.package.name; + p.detail = versioned(m, dir); + p.source = "project"; } else if (spec && spec->isPath()) { - origin = "(path)"; + p.subject = p.name; + p.detail = versioned(m, dir); + p.source = "path"; } else if (spec && spec->isGit()) { std::string ref = spec->gitRev; if (spec->gitRefKind == "rev" && ref.size() > 12) ref.resize(12); - origin = std::format("(git {} {})", spec->gitRefKind.empty() ? "rev" : spec->gitRefKind, ref); - } else if (!m.package.version.empty()) { - origin = "v" + m.package.version; + p.subject = p.name; + p.detail = versioned(m, std::format("git {} {}", + spec->gitRefKind.empty() ? "rev" : spec->gitRefKind, ref)); + p.source = "git"; + } else { + // An index package: official when the default index serves its + // namespace, otherwise the index that does is named. + const auto ns = m.package.namespace_.empty() + ? std::string(mcpp::pm::kDefaultNamespace) : m.package.namespace_; + const auto* index = state.findIndexForNs ? state.findIndexForNs(ns) : nullptr; + const bool official = index == nullptr || index->is_builtin(); + p.subject = p.name.empty() ? key : p.name; + p.detail = versioned(m, official ? std::string{} : std::format("index {}", index->name)); + p.source = official ? "official" : "index"; } - p.subject = origin.empty() ? key : std::format("{} {}", key, origin); for (auto const& c : ctx.cachedDeps) if (c.name == p.name) p.cachedUnits = c.units; out.push_back(std::move(p)); diff --git a/src/build/progress.cppm b/src/build/progress.cppm index e9e2e3ff..358d9ad2 100644 --- a/src/build/progress.cppm +++ b/src/build/progress.cppm @@ -1,5 +1,6 @@ // mcpp.build.progress — what a build reports while it runs, and how -// (.agents/docs/2026-09-29-build-progress-display-design.md). +// (.agents/docs/2026-09-29-build-progress-display-design.md, and its revision +// 3, .agents/docs/2026-09-30-build-output-refinement-design.md). // // Four sources feed one model, and mcpp.ui draws it: // @@ -13,9 +14,13 @@ // `check` or `prepare` action, the only steps whose start is observable. // // The step record, written beside build.ninja, names the package each step -// belongs to. A package is complete when every step the record assigns to it -// has finished in this build (§3.2): the rule can state completion late, -// never early. +// belongs to. A PACKAGE IS NAMED WHEN IT DOES WORK (revision 3, §5.3): its line +// is written when the first of its steps finishes, or when its first check or +// prepare action starts, and it never changes. Revision 2 wrote a package's +// line when every step the record assigns to it had finished; the steps of a +// package the cache serves run in a pass of their own and never counted, so +// such a package never completed and the folded dependency line waited for +// ninja to exit (measured: 27 s late in a first build of xlings). // // LOCKS. The model has one mutex. mcpp.ui asks the model for its frame while // holding its own lock, so the model never calls mcpp.ui while holding the @@ -29,6 +34,7 @@ export module mcpp.build.progress; import std; import mcpp.ui; +import mcpp.ui.dots_screen; import mcpp.log; import mcpp.platform; @@ -39,9 +45,11 @@ export namespace mcpp::build::progress { struct PackageInfo { std::string name; // qualified name: what the emitter records bool requested = false; - std::string subject; // as the package's line shows it + std::string subject; // the name as the package's line shows it std::size_t cachedUnits = 0; // units staged from the global cache std::size_t steps = 0; // steps the graph assigns to it + std::string detail; // its version and origin, shown dim + std::string source; // project, official, index, git or path }; struct Record { @@ -109,12 +117,18 @@ private: // terminal, as it is not here: only the outer ninja's lines keep the leading // `ESC [ 0 m`. The outer ninja runs with CLICOLOR_FORCE=0, which keeps that // stripping on. -inline constexpr std::string_view kStatusFormat = "\x1b[0m@@mcpp %f %t %e@@ "; +// +// `%u` is the number of steps not yet started. Measured with ninja 1.12.1 +// through a pipe, it reaches 0 when the last step starts, and from then on +// every one of the `t - f` remaining steps is running: the status row can say +// `last N running` at the end of a build and be exact. +inline constexpr std::string_view kStatusFormat = "\x1b[0m@@mcpp %f %t %e %u@@ "; struct StatusLine { std::size_t finished = 0; std::size_t total = 0; long long endMs = 0; + std::optional unstarted; // absent in a line without `%u` std::string_view text; // the description, or the command under -v }; std::optional parse_status(std::string_view line); @@ -154,7 +168,10 @@ void record_action_start(std::string_view stamp); enum class ProgramOutcome { Ran, Cached, Failed }; // Opens the report of this command: the region, its frame and its poll. -// `verbose` lists every package (design §4.3). Idempotent. +// `verbose` also names the packages with nothing to do and each package's +// steps and span at the end. The status row's display is chosen here +// (MCPP_PROGRESS: `random`, the default; an animation's name; `bar`; `plain`; +// `off`). Idempotent. void open(bool verbose); // Closes it: the region is erased. Idempotent. void close(); @@ -162,8 +179,8 @@ void close(); // package line names its configuration. void configurations(std::size_t n); -// Build programs (design §4.2). `requested` programs are listed, the others -// folded into one line when `programs_done` is called. +// Build programs (design §4.2). Every program that runs or fails has a line; +// a program whose result is reused has one under --verbose. void program_scheduled(std::string_view package, bool requested); void program_compiling(std::string_view package, bool requested); void program_running(std::string_view package, bool requested); @@ -205,12 +222,15 @@ public: // One ninja invocation. void pass_begin(); void status(const StatusLine& line); - // A `FAILED: ` line: the step's package is failed. Returns true - // for the command's first failure, after which the caller writes - // `error: build failed` and the step's diagnostics. - bool failed(std::string_view outputs); + // A `FAILED: ` line. For the command's first failure, returns + // the failed step's package as a line names it (empty when the step is + // the build's own), after which the caller writes + // `error: build failed in ` and the step's diagnostics; nothing + // for a later failure. + std::optional failed(std::string_view outputs); void pass_end(); - // The build ended: every package still open gets its final line. + // The build ended. Under --verbose, the packages with nothing to do are + // named, and each package that did work states its steps and span. void finish(bool success); private: @@ -269,12 +289,16 @@ long long to_ll(std::string_view s) { } // namespace // Format: a header, then `P` lines (packages, in order), `A` lines (actions) -// and `O` lines (an output and its package's index). +// and `O` lines (an output and its package's index). Version 2 adds a +// package's detail and source to its `P` line; a version 1 record (mcpp +// 2026.9.29.5) is read with both empty, and its subject then carries what +// version 1 wrote there. std::string format_record(const Record& r) { - std::string out = std::format("# mcpp steps v1\t{}\n", r.steps); + std::string out = std::format("# mcpp steps v2\t{}\n", r.steps); for (auto const& p : r.packages) - out += std::format("P\t{}\t{}\t{}\t{}\t{}\n", field(p.name), p.requested ? 1 : 0, - p.cachedUnits, p.steps, field(p.subject)); + out += std::format("P\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n", field(p.name), + p.requested ? 1 : 0, p.cachedUnits, p.steps, field(p.subject), + field(p.detail), field(p.source)); std::vector> actions(r.actions.begin(), r.actions.end()); std::ranges::sort(actions); for (auto const& [out1, label] : actions) @@ -298,10 +322,15 @@ Record parse_record(std::string_view text) { if (!line.empty() && line.back() == '\r') line.remove_suffix(1); if (line.empty()) continue; auto f = split_tabs(line); - if (f[0] == "# mcpp steps v1" && f.size() >= 2) { r.steps = to_size(f[1]); continue; } + if ((f[0] == "# mcpp steps v1" || f[0] == "# mcpp steps v2") && f.size() >= 2) { + r.steps = to_size(f[1]); + continue; + } if (f[0] == "P" && f.size() >= 6) { r.packages.push_back({std::string(f[1]), f[2] == "1", std::string(f[5]), - to_size(f[3]), to_size(f[4])}); + to_size(f[3]), to_size(f[4]), + f.size() >= 8 ? std::string(f[6]) : std::string{}, + f.size() >= 8 ? std::string(f[7]) : std::string{}}); } else if (f[0] == "A" && f.size() >= 3) { r.actions.emplace(std::string(f[1]), std::string(f[2])); } else if (f[0] == "O" && f.size() >= 4) { @@ -399,7 +428,7 @@ Record Attribution::record(const std::vector& declared) const { auto it = index.find(s.owner); if (it == index.end()) { it = index.emplace(s.owner, r.packages.size()).first; - r.packages.push_back({s.owner, false, s.owner, 0, 0}); + r.packages.push_back({s.owner, false, s.owner, 0, 0, {}, {}}); } ++r.packages[it->second].steps; for (auto const& o : s.outputs) { @@ -423,15 +452,18 @@ std::optional parse_status(std::string_view line) { auto nums = line.substr(0, closeEnd); StatusLine s; s.text = close == std::string_view::npos ? std::string_view{} : line.substr(close + 3); - std::array part; - for (std::size_t i = 0; i < 3; ++i) { + std::array part; + std::size_t parts = 0; + for (; parts < 4; ++parts) { auto sp = nums.find(' '); - part[i] = nums.substr(0, sp); - if (sp == std::string_view::npos) { if (i < 2) return std::nullopt; break; } + part[parts] = nums.substr(0, sp); + if (sp == std::string_view::npos) { ++parts; break; } nums.remove_prefix(sp + 1); } + if (parts < 3) return std::nullopt; s.finished = to_size(part[0]); s.total = to_size(part[1]); + if (parts >= 4 && !part[3].empty()) s.unstarted = to_size(part[3]); // `%e` is seconds with three decimals: the step's end in milliseconds. auto e = part[2]; auto dot = e.find('.'); @@ -526,11 +558,10 @@ void record_action_start(std::string_view stamp) { // ─── The model ─────────────────────────────────────────────────────────── // Not exported, and not TU-local either: `Build::Impl` holds them. -enum class Phase { Resolving, Programs, Building, Stopping, Checking }; +enum class Phase { Planning, Programs, Building, Stopping, Checking }; struct Program { std::string name; - bool requested = false; enum State { Waiting, Compiling, Running, Done } state = Waiting; ProgramOutcome outcome = ProgramOutcome::Ran; Clock::time_point since{}; @@ -538,11 +569,10 @@ struct Program { }; struct PackageState { - std::size_t finished = 0; - long long first = std::numeric_limits::max(); // ms since command start - long long last = 0; - bool failed = false; - bool committed = false; + std::size_t finished = 0; + long long first = std::numeric_limits::max(); // ms since command start + long long last = 0; + bool announced = false; // its line was written }; struct Running { @@ -560,11 +590,11 @@ struct Build::Impl { bool closed = false; // the build's Build object is gone std::unordered_set counted; // steps already counted std::vector packages; - bool depsCommitted = false; // Passes of ninja. long long passStart = 0; // ms since command start std::size_t doneBefore = 0, totalBefore = 0; // earlier passes std::size_t finished = 0, total = 0; // this pass + std::optional unstarted; // this pass, from `%u` bool inPass = false; // The log of this pass. std::filesystem::path logPath; @@ -594,20 +624,28 @@ void ensure_record(Build::Impl& b) { } } +namespace screen = mcpp::ui::dots_screen; + struct Report { std::mutex m; bool open = false; bool verbose = false; - Phase phase = Phase::Resolving; + Phase phase = Phase::Planning; std::size_t configurations = 1; std::vector programs; - bool programsCommitted = false; ms programTime{0}; std::vector> builds; std::optional buildStart; // ms since command start bool failureReported = false; bool deferred = false; std::optional> deferredFinish; + // The status row's screen (revision 3, §5.9 to §5.13): an animation fed + // by the build, or none. + std::unique_ptr animation; + bool animationColour = false; + std::vector started; // packages announced since the last frame + long long lastFrame = 0; // ms since command start + std::size_t lastDone = 0; }; Report& report() { @@ -625,13 +663,26 @@ std::string plural(std::size_t n, std::string_view one, std::string_view many) { return std::format("{} {}", n, n == 1 ? one : many); } +screen::Source source_of(std::string_view s) { + if (s == "project") return screen::Source::Project; + if (s == "official") return screen::Source::Official; + if (s == "index") return screen::Source::Index; + if (s == "git") return screen::Source::Git; + return screen::Source::Other; +} + +mcpp::ui::Hue hue_of(std::string_view s) { + if (s == "official") return mcpp::ui::Hue::Cyan; + if (s == "index") return mcpp::ui::Hue::Magenta; + if (s == "git") return mcpp::ui::Hue::Blue; + return mcpp::ui::Hue::Plain; +} + // ── Programs ── std::size_t program_column(const Report& r) { - std::size_t w = 0; - for (auto const& p : r.programs) - if (p.requested || r.verbose) w = std::max(w, mcpp::ui::display_width(p.name)); - w = std::max(w, 16); // "N dependencies" + std::size_t w = 16; + for (auto const& p : r.programs) w = std::max(w, mcpp::ui::display_width(p.name)); return std::min(w + 2, kColumnMax); } @@ -666,156 +717,52 @@ std::string program_line(const Report& r, const Program& p) { program_tone(p), /*infoVerb=*/true); } -// The folded line of the dependencies' programs. -std::optional folded_programs_line(const Report& r) { - std::size_t n = 0, ran = 0, cached = 0, failed = 0; - ms time{0}; - for (auto const& p : r.programs) { - if (p.requested || r.verbose || p.state != Program::Done) continue; - ++n; - if (p.outcome == ProgramOutcome::Cached) ++cached; - else if (p.outcome == ProgramOutcome::Failed) ++failed; - else { ++ran; time += p.compile + p.run; } - } - if (n == 0) return std::nullopt; - std::string state; - if (ran) state = std::format("ran {}", mcpp::ui::format_duration(time)); - if (cached) state += std::format("{}{}", state.empty() ? "" : " · ", - ran ? std::format("{} cached", cached) : std::string("cached")); - if (failed) state += std::format("{}{} failed", state.empty() ? "" : " · ", failed); - return mcpp::ui::step_line("build.mcpp", plural(n, "dependency", "dependencies"), - program_column(r), state, - failed ? mcpp::ui::Tone::Bad - : ran ? mcpp::ui::Tone::Good : mcpp::ui::Tone::Muted, - /*infoVerb=*/true); -} - -Program& program(Report& r, std::string_view name, bool requested) { +Program& program(Report& r, std::string_view name) { for (auto& p : r.programs) if (p.name == name && p.state != Program::Done) return p; - r.programs.push_back({std::string(name), requested}); + r.programs.push_back({std::string(name)}); return r.programs.back(); } // ── Packages ── -bool listed(const Report& r, const PackageInfo& p) { return p.requested || r.verbose; } - +// The package as its line names it: the name coloured by source, the version +// and origin dim (revision 3, §5.10). std::string subject_of(const Report& r, const Build::Impl& b, const PackageInfo& p) { - if (r.configurations <= 1) return p.subject; - return std::format("{} [{}]", p.subject, b.dir.filename().string()); -} - -std::size_t package_column(const Report& r, const Build::Impl& b) { - std::size_t w = 16; // "NN dependencies" - if (b.record) - for (auto const& p : b.record->packages) - if (listed(r, p)) w = std::max(w, mcpp::ui::display_width(subject_of(r, b, p))); - return std::min(w + 2, kColumnMax); -} - -bool complete(const PackageInfo& p, const PackageState& s) { - return p.steps > 0 && s.finished >= p.steps; + std::string s = mcpp::ui::hue(p.subject, hue_of(p.source)); + if (!p.detail.empty()) s += " " + mcpp::ui::hue(p.detail, mcpp::ui::Hue::Dim); + if (r.configurations > 1) s += std::format(" [{}]", b.dir.filename().string()); + return s; } -std::string span_of(const PackageState& s) { - if (s.first > s.last) return {}; - return mcpp::ui::format_duration(ms(s.last - s.first)); +// The same, without colour, for the error line. +std::string plain_subject(const Build::Impl& b, std::size_t i) { + const auto& p = b.record->packages[i]; + return p.detail.empty() ? p.subject : std::format("{} {}", p.subject, p.detail); } -std::string package_line(const Report& r, const Build::Impl& b, std::size_t i, bool final, - bool success) { +// A package's line (revision 3, §7.1): `Cached` when the global cache serves +// its units, `Compiling` otherwise. +std::string package_line(const Report& r, const Build::Impl& b, std::size_t i) { const auto& p = b.record->packages[i]; - const auto& s = b.packages[i]; - std::string state; - auto tone = mcpp::ui::Tone::Plain; - if (s.failed) { state = "failed"; tone = mcpp::ui::Tone::Bad; } - else if (s.finished == 0 && final) { - state = p.cachedUnits > 0 ? std::format("cached {}", plural(p.cachedUnits, "unit", "units")) - : "fresh"; - tone = mcpp::ui::Tone::Muted; - } else if (p.cachedUnits > 0 && (complete(p, s) || (final && success))) { - state = std::format("cached {}", plural(p.cachedUnits, "unit", "units")); - tone = mcpp::ui::Tone::Muted; - } else if (complete(p, s) || (final && success)) { - state = std::format("done {}", span_of(s)); - tone = mcpp::ui::Tone::Good; - } else { - state = plural(s.finished, "step", "steps"); - } - return mcpp::ui::step_line("Compiling", subject_of(r, b, p), package_column(r, b), state, tone); -} - -// The folded line of the dependencies (design §4.3): those with steps in -// this build, and at the end also those the global cache supplied whole. -std::optional dependencies_line(const Report& r, const Build::Impl& b, bool final, - bool success) { - std::size_t active = 0, cached = 0, cachedOnly = 0, steps = 0; - long long first = std::numeric_limits::max(), last = 0; - for (std::size_t i = 0; i < b.record->packages.size(); ++i) { - const auto& p = b.record->packages[i]; - const auto& s = b.packages[i]; - if (listed(r, p) || s.committed) continue; - if (p.cachedUnits > 0 && final) { - ++cached; - if (s.finished == 0) ++cachedOnly; - } - if (s.finished == 0) continue; - ++active; - steps += s.finished; - first = std::min(first, s.first); - last = std::max(last, s.last); - } - const std::size_t n = active + cachedOnly; - if (active == 0 && (!final || cached == 0)) return std::nullopt; - std::string state; - auto tone = mcpp::ui::Tone::Plain; - if (final && success) { - if (active > 0) { - state = first <= last - ? std::format("done {}", mcpp::ui::format_duration(ms(last - first))) - : std::string("done"); - if (cached) state += std::format(" · {} cached", cached); - tone = mcpp::ui::Tone::Good; - } else { - state = "cached"; - tone = mcpp::ui::Tone::Muted; - } - } else { - state = plural(steps, "step", "steps"); + std::string subject = subject_of(r, b, p); + if (p.cachedUnits > 0) { + subject += " " + mcpp::ui::hue(std::format("({})", plural(p.cachedUnits, "unit", "units")), + mcpp::ui::Hue::Dim); + return mcpp::ui::step_line("Cached", subject, 0, ""); } - return mcpp::ui::step_line("Compiling", plural(n, "dependency", "dependencies"), - package_column(r, b), state, tone); + return mcpp::ui::step_line("Compiling", subject, 0, ""); } -bool dependencies_complete(const Report& r, const Build::Impl& b) { - for (std::size_t i = 0; i < b.record->packages.size(); ++i) { - const auto& p = b.record->packages[i]; - if (listed(r, p) || b.packages[i].committed) continue; - if (p.steps > 0 && !complete(p, b.packages[i])) return false; - } - return true; -} - -// Commits what became final; lines to write are appended to `out`. -void settle(Report& r, Build::Impl& b, std::vector& out) { - if (!b.record) return; - for (std::size_t i = 0; i < b.record->packages.size(); ++i) { - auto& s = b.packages[i]; - const auto& p = b.record->packages[i]; - if (s.committed || !listed(r, p) || !complete(p, s)) continue; - s.committed = true; - out.push_back(package_line(r, b, i, /*final=*/true, /*success=*/true)); - } - if (!b.depsCommitted && dependencies_complete(r, b)) { - if (auto l = dependencies_line(r, b, /*final=*/true, /*success=*/true)) { - out.push_back(*l); - b.depsCommitted = true; - for (std::size_t i = 0; i < b.record->packages.size(); ++i) - if (!listed(r, b.record->packages[i]) && b.packages[i].finished > 0) - b.packages[i].committed = true; - } - } +// Writes a package's line the first time it does work; model lock held. The +// standard library module is the toolchain's and is not named. +void announce(Report& r, Build::Impl& b, std::size_t i, std::vector& out) { + auto& s = b.packages[i]; + const auto& p = b.record->packages[i]; + if (s.announced || p.name == "std" || p.name.empty()) return; + s.announced = true; + out.push_back(package_line(r, b, i)); + r.started.push_back(p.cachedUnits > 0 ? screen::Source::Cache : source_of(p.source)); } void write_lines(const std::vector& lines) { @@ -824,7 +771,7 @@ void write_lines(const std::vector& lines) { } // Reads the log of an open pass; model lock held. -void read_log(Build::Impl& b) { +void read_log(Report& r, Build::Impl& b, std::vector& out) { if (!b.inPass || !b.record) return; std::error_code ec; const auto size = std::filesystem::file_size(b.logPath, ec); @@ -885,7 +832,8 @@ void read_log(Build::Impl& b) { ++s.finished; s.first = std::min(s.first, start); s.last = std::max(s.last, end); - label = std::format("{}: {}", b.record->packages[*owner].name, label); + label = std::format("{}: {}", b.record->packages[*owner].subject, label); + announce(r, b, *owner, out); } if (st.end - st.start > b.longest || !b.anyStep) { b.longest = st.end - st.start; @@ -898,8 +846,9 @@ void read_log(Build::Impl& b) { } } -// Reads the start file of an open pass; model lock held. -void read_starts(Build::Impl& b) { +// Reads the start file of an open pass; model lock held. A check or prepare +// action that starts names its package: that is work. +void read_starts(Report& r, Build::Impl& b, std::vector& out) { if (!b.inPass || !b.record) return; std::error_code ec; const auto path = b.dir / kStartsFile; @@ -922,6 +871,7 @@ void read_starts(Build::Impl& b) { b.running.push_back({owner->second, label != b.record->actions.end() ? label->second : a.stamp, a.stamp, now_ms() - std::max(0, nowUnix - a.unixMs)}); + announce(r, b, owner->second, out); } } @@ -934,62 +884,97 @@ std::vector> live_builds(Report& r) { return out; } -std::string phase_status(Report& r) { +// The status row (revision 3, §7.2): the phase aligned with the verbs above +// it, the screen, the counts, the clock, then what is known to be running. +std::string phase_status(Report& r, std::string_view cells) { const auto clock = mcpp::ui::format_clock(ms(now_ms())); std::string current; long long oldest = std::numeric_limits::max(); - std::size_t done = 0, total = 0; + std::size_t done = 0, total = 0, remaining = 0; + bool tail = true; // every build in a pass has started its last step + bool anyPass = false; for (auto const& b : live_builds(r)) { done += b->doneBefore + b->finished; total += b->totalBefore + b->total; + if (b->inPass) { + anyPass = true; + if (!b->unstarted || *b->unstarted > 0) tail = false; + else remaining += b->total > b->finished ? b->total - b->finished : 0; + } for (auto const& a : b->running) if (a.since < oldest) { oldest = a.since; - current = std::format("{}: {} {}", b->record->packages[a.package].name, a.label, + current = std::format("{}: {} {}", b->record->packages[a.package].subject, a.label, mcpp::ui::format_clock(ms(now_ms() - a.since))); } } + std::string counts; + std::string phase; + std::vector extra; switch (r.phase) { - case Phase::Resolving: - return mcpp::ui::status_line("Resolving", std::format("· {}", clock)); + case Phase::Planning: phase = "Planning"; break; case Phase::Programs: { - for (auto const& p : r.programs) + phase = "Running"; + std::size_t finishedPrograms = 0; + for (auto const& p : r.programs) { + if (p.state == Program::Done) ++finishedPrograms; if (p.state == Program::Compiling || p.state == Program::Running) - current = std::format("{} {}", p.name, mcpp::ui::format_clock( - std::chrono::duration_cast(Clock::now() - p.since))); - return mcpp::ui::status_line("Running build programs", - std::format("· {}{}", clock, current.empty() ? "" : " · " + current)); + current = std::format("build.mcpp {} {} {}", p.name, + p.state == Program::Compiling ? "compiling" : "running", + mcpp::ui::format_clock(std::chrono::duration_cast(Clock::now() - p.since))); + } + counts = std::format("{}/{}", finishedPrograms, r.programs.size()); + break; } case Phase::Building: case Phase::Stopping: - return mcpp::ui::status_line(r.phase == Phase::Building ? "Building" : "Stopping", - std::format("{}· {}{}", r.phase == Phase::Building && total > 0 - ? std::format("{}/{} ", done, total) : "", - clock, current.empty() ? "" : " · " + current)); - case Phase::Checking: - return mcpp::ui::status_line("Checking", std::format("· {}", clock)); + phase = r.phase == Phase::Building ? "Building" : "Stopping"; + if (total > 0) counts = std::format("{}/{}", done, total); + // Once no step is left to start, every step left is running (`%u`). + if (r.phase == Phase::Building && anyPass && tail && remaining > 0) + extra.push_back(std::format("last {} running", remaining)); + break; + case Phase::Checking: phase = "Checking"; break; } - return {}; + std::string rest(cells); + if (!counts.empty()) rest += (rest.empty() ? "" : " ") + counts; + rest += std::format("{}· {}", rest.empty() ? "" : " ", clock); + for (auto const& e : extra) rest += " · " + e; + if (!current.empty()) rest += " · " + current; + return mcpp::ui::status_line(phase, rest); } mcpp::ui::Frame frame() { auto& r = report(); std::lock_guard lock(r.m); mcpp::ui::Frame f; - for (auto const& p : r.programs) - if (p.state != Program::Done && (p.requested || r.verbose)) f.lines.push_back(program_line(r, p)); - for (auto const& b : live_builds(r)) { - if (!b->record) continue; - for (std::size_t i = 0; i < b->record->packages.size(); ++i) { - const auto& p = b->record->packages[i]; - const auto& s = b->packages[i]; - if (s.committed || s.finished == 0 || !listed(r, p)) continue; - f.lines.push_back(package_line(r, *b, i, /*final=*/false, false)); + std::string cells; + if (r.animation) { + std::size_t done = 0, total = 0; + for (auto const& b : live_builds(r)) { + done += b->doneBefore + b->finished; + total += b->totalBefore + b->total; + } + const auto now = now_ms(); + screen::Input in; + in.dt = r.lastFrame ? static_cast(now - r.lastFrame) / 1000.0 : 0.0; + in.finished = done > r.lastDone ? done - r.lastDone : 0; + in.fraction = total ? std::min(1.0, static_cast(done) / static_cast(total)) : 0.0; + in.failed = r.failureReported; + for (auto s : r.started) r.animation->package(s); + r.started.clear(); + r.animation->update(in); + r.lastFrame = now; + r.lastDone = done; + // The screen takes 25 columns; a terminal narrower than 60 keeps the + // counts and the clock instead. + if (mcpp::platform::terminal::cols() >= 60) { + screen::Screen sc; + r.animation->draw(sc); + cells = sc.render(r.animationColour); } - if (!b->depsCommitted) - if (auto l = dependencies_line(r, *b, /*final=*/false, false)) f.lines.push_back(*l); } - f.status = phase_status(r); + f.status = phase_status(r, cells); return f; } @@ -999,14 +984,33 @@ void poll() { { std::lock_guard lock(r.m); for (auto const& b : live_builds(r)) { - read_starts(*b); - read_log(*b); - settle(r, *b, out); + read_starts(r, *b, out); + read_log(r, *b, out); } } write_lines(out); } +// The status row's screen, as MCPP_PROGRESS asks: `random` (the default) or +// an animation's name; `plain` keeps the row without a screen; `off` draws no +// live row at all. The screen needs a terminal that draws braille. +std::unique_ptr choose_animation() { + std::string want = mcpp::platform::env::get("MCPP_PROGRESS").value_or("random"); + for (auto& c : want) c = static_cast(std::tolower(static_cast(c))); + if (want == "off") { + mcpp::ui::set_live_progress(false); + return nullptr; + } + if (want == "plain" || !mcpp::ui::live_progress() + || !mcpp::platform::terminal::unicode_capable()) + return nullptr; + const auto seed = static_cast( + std::chrono::steady_clock::now().time_since_epoch().count()); + auto names = screen::names(); + if (auto a = screen::make(want, seed)) return a; + return screen::make(names[seed % names.size()], seed); +} + } // namespace void open(bool verbose) { @@ -1016,6 +1020,8 @@ void open(bool verbose) { if (r.open) return; r.open = true; r.verbose = verbose; + r.animation = choose_animation(); + r.animationColour = mcpp::ui::is_color_enabled(); } mcpp::ui::open_region(&frame, &poll); } @@ -1033,54 +1039,53 @@ void configurations(std::size_t n) { r.configurations = std::max(1, n); } -void program_scheduled(std::string_view package, bool requested) { +void program_scheduled(std::string_view package, bool /*requested*/) { auto& r = report(); { std::lock_guard lock(r.m); - program(r, package, requested); + program(r, package); } mcpp::ui::touch_region(); } -void program_compiling(std::string_view package, bool requested) { +void program_compiling(std::string_view package, bool /*requested*/) { auto& r = report(); { std::lock_guard lock(r.m); r.phase = Phase::Programs; - auto& p = program(r, package, requested); + auto& p = program(r, package); p.state = Program::Compiling; p.since = Clock::now(); } mcpp::ui::touch_region(); } -void program_running(std::string_view package, bool requested) { +void program_running(std::string_view package, bool /*requested*/) { auto& r = report(); { std::lock_guard lock(r.m); r.phase = Phase::Programs; - auto& p = program(r, package, requested); + auto& p = program(r, package); p.state = Program::Running; p.since = Clock::now(); } mcpp::ui::touch_region(); } -void program_finished(std::string_view package, bool requested, ProgramOutcome outcome, +void program_finished(std::string_view package, bool /*requested*/, ProgramOutcome outcome, std::chrono::milliseconds compile, std::chrono::milliseconds run) { auto& r = report(); std::vector out; { std::lock_guard lock(r.m); - auto& p = program(r, package, requested); + auto& p = program(r, package); p.state = Program::Done; p.outcome = outcome; p.compile = compile; p.run = run; r.programTime += compile + run; - if (p.requested || r.verbose || outcome == ProgramOutcome::Failed) - out.push_back(program_line(r, p)); - if (outcome == ProgramOutcome::Failed) p.requested = true; // not folded again + // A program whose result is reused did no work (revision 3, §7.1). + if (outcome != ProgramOutcome::Cached || r.verbose) out.push_back(program_line(r, p)); } write_lines(out); mcpp::log::info("progress", std::format("build.mcpp {} {}", package, @@ -1089,16 +1094,16 @@ void program_finished(std::string_view package, bool requested, ProgramOutcome o : std::format("compiled {}ms ran {}ms", compile.count(), run.count()))); } +// The build programs are done and the plan continues. Measured with +// 2026.9.29.5: the status row read `Running build programs` for 13 s after the +// only program finished, because nothing returned the phase to planning. void programs_done() { auto& r = report(); - std::vector out; { std::lock_guard lock(r.m); - if (r.programsCommitted) return; - r.programsCommitted = true; - if (auto l = folded_programs_line(r)) out.push_back(*l); + if (r.phase == Phase::Programs) r.phase = Phase::Planning; } - write_lines(out); + mcpp::ui::touch_region(); } void checking() { @@ -1224,6 +1229,7 @@ void Build::pass_begin() { b.doneBefore += b.finished; b.totalBefore += b.total; b.finished = b.total = 0; + b.unstarted.reset(); b.passStart = now_ms(); if (!r.buildStart) r.buildStart = b.passStart; r.phase = Phase::Building; @@ -1258,21 +1264,21 @@ void Build::status(const StatusLine& line) { std::lock_guard lock(r.m); auto& b = *impl_; ensure_record(b); - b.finished = line.finished; - b.total = line.total; + b.finished = line.finished; + b.total = line.total; + b.unstarted = line.unstarted; b.ends.push_back(line.endMs); - read_starts(b); - read_log(b); - settle(r, b, out); + read_starts(r, b, out); + read_log(r, b, out); } write_lines(out); mcpp::ui::touch_region(); } -bool Build::failed(std::string_view outputs) { +std::optional Build::failed(std::string_view outputs) { auto& r = report(); std::vector out; - bool first = false; + std::optional first; { std::lock_guard lock(r.m); auto& b = *impl_; @@ -1292,13 +1298,12 @@ bool Build::failed(std::string_view outputs) { if (auto it = b.record->owner.find(o); it != b.record->owner.end()) owner = it->second; rest.remove_prefix(sp == std::string_view::npos ? rest.size() : sp + 1); } - if (owner && !b.packages[*owner].committed) { - b.packages[*owner].failed = true; - b.packages[*owner].committed = true; - out.push_back(package_line(r, b, *owner, /*final=*/true, /*success=*/false)); - } + // A failed step is not written to ninja's log, so a package whose + // first step failed is named here. + if (owner) announce(r, b, *owner, out); r.phase = Phase::Stopping; - first = !r.failureReported; + if (!r.failureReported) + first = owner ? plain_subject(b, *owner) : std::string{}; r.failureReported = true; } write_lines(out); @@ -1311,8 +1316,7 @@ void Build::pass_end() { { std::lock_guard lock(r.m); auto& b = *impl_; - read_log(b); - settle(r, b, out); + read_log(r, b, out); b.inPass = false; b.running.clear(); } @@ -1325,32 +1329,24 @@ void Build::finish(bool success) { { std::lock_guard lock(r.m); auto& b = *impl_; - if (b.record) { - // In the order the packages completed; those with nothing to do - // last, in the plan's order. - std::vector> lines; - constexpr auto never = std::numeric_limits::max(); + // Under --verbose, the packages with nothing to do are named, and each + // package that did work states its steps and the span from the start + // of its first step to the end of its last, read from ninja's log + // (revision 3, §7.4): the exact form of revision 2's `done `. + if (b.record && r.verbose) { for (std::size_t i = 0; i < b.record->packages.size(); ++i) { - auto& s = b.packages[i]; const auto& p = b.record->packages[i]; - if (s.committed || !listed(r, p)) continue; - // A package with nothing to do is listed only with --verbose. - if (s.finished == 0 && !(r.verbose && success)) continue; - s.committed = true; - lines.emplace_back(s.finished ? s.last : never, - package_line(r, b, i, /*final=*/true, success)); - } - if (!b.depsCommitted) { - long long last = 0; - for (std::size_t i = 0; i < b.record->packages.size(); ++i) - if (!listed(r, b.record->packages[i]) && b.packages[i].finished) - last = std::max(last, b.packages[i].last); - if (auto l = dependencies_line(r, b, /*final=*/true, success)) - lines.emplace_back(last ? last : never, *l); - b.depsCommitted = true; + const auto& s = b.packages[i]; + if (p.name == "std" || p.name.empty()) continue; + if (s.finished == 0) { + if (success) out.push_back(mcpp::ui::step_line("Fresh", subject_of(r, b, p), 0, "")); + continue; + } + std::string span = s.first <= s.last + ? " · " + mcpp::ui::format_duration(ms(s.last - s.first)) : std::string{}; + out.push_back(mcpp::ui::step_line("Compiled", std::format("{} · {}{}", + subject_of(r, b, p), plural(s.finished, "step", "steps"), span), 0, "")); } - std::ranges::stable_sort(lines, {}, &std::pair::first); - for (auto& l : lines) out.push_back(std::move(l.second)); } std::size_t done = 0; for (auto const& s : b.packages) done += s.finished; diff --git a/src/ui.cppm b/src/ui.cppm index a9545725..5a53aa98 100644 --- a/src/ui.cppm +++ b/src/ui.cppm @@ -3,14 +3,17 @@ // All user-visible status lines from CLI / fetcher / build go through // here. TTY auto-detect; MCPP_NO_COLOR / --no-color disables colors. // -// ONE RENDERER, TWO MEDIA (build progress design 2026-09-29, §5). Every line -// goes through `emit`. On a terminal that can move the cursor, the lines that -// are still changing -- a download bar, a build program running, a package -// still compiling, and the status line -- form a region below the log, and a -// line written while the region is on screen is written above it: the region -// is erased, the line is written, and the region is drawn again. Anywhere -// else only final lines are written, and the status line is repeated when the -// log has been silent for a minute. +// ONE RENDERER, TWO MEDIA (build progress design 2026-09-29, §5, and its +// revision 3, 2026-09-30, §9). Every line goes through `emit`. On a terminal +// that can move the cursor, the rows that are still changing -- a download +// bar and the status row -- form a region below the log, and a line written +// while the region is on screen is written above it. The lines and the +// region's new rows leave in ONE write that overwrites the old rows in place: +// no row is erased before it is written, so no screen between two writes +// shows the region half-drawn (measured with 2026.9.29.5: 184 of 202 frames of +// one build left in two writes, and every line above the region in three). +// Anywhere else only final lines are written, and the status line is repeated +// when the log has been silent for a minute. module; #include // fileno, stdout @@ -126,8 +129,12 @@ void set_line_buffered(); // The columns `text` occupies on a terminal: colour sequences count zero, an // East Asian wide or fullwidth character two, a combining mark zero, and every -// other character one. +// other character one. Where the terminal is likely to draw East Asian +// ambiguous characters wide (`terminal::ambiguous_wide`), those count two, so +// that a row fitted by this measure never overflows and wraps. std::size_t display_width(std::string_view text); +// Overrides the ambiguous-width decision (tests). +void set_ambiguous_wide(bool wide); // `text` cut to at most `width` columns, its last column `…` when it was cut. // Colour sequences are kept, and a reset follows a cut inside colour. std::string fit(std::string_view text, std::size_t width); @@ -146,9 +153,16 @@ enum class Tone { Plain, Good, Muted, Bad }; std::string step_line(std::string_view verb, std::string_view subject, std::size_t column, std::string_view state, Tone tone = Tone::Plain, bool infoVerb = false); -// The status line (design §4.4): its first word in the verb colour. +// The status line (design §4.4): its phase in the verb colour, right-aligned +// in the 12 columns of the verbs above it (revision 3, §7.2). std::string status_line(std::string_view phase, std::string_view rest); +// A hue for part of a line (revision 3, §5.10): the name of a package from the +// official index is cyan, from another index magenta, from a git repository +// blue; a version and an origin are dim. Plain text when colour is off. +enum class Hue { Plain, Cyan, Magenta, Blue, Dim }; +std::string hue(std::string_view text, Hue h); + // --- the command's clock --- // The moment the command started; main() marks it before anything else. The @@ -198,17 +212,23 @@ public: // One final line to stdout, through the region; suppressed by --quiet. void line(std::string_view text); -// The bytes that replace a region of `previousRows` rows with `rows` (each -// already fitted to the width), and leave the cursor at the end of the last -// row. Pure: the unit tests read it. +// The bytes of one frame: from the first row of a region of `previousRows` +// rows, `text` (whole lines) is written over it, then `rows` (each already +// fitted to the width), and what remains of the old region is cleared. Every +// row is written over the old one and its tail cleared (`ESC[K`) rather than +// erased first; each row of the region is drawn with autowrap off, so a row +// that a terminal draws wider than it was measured is cut at the margin +// instead of wrapping. The cursor is left at the end of the last row. Pure: +// the unit tests read it. +std::string frame_bytes(std::size_t previousRows, std::string_view text, + const std::vector& rows); +// `frame_bytes` without lines above the region. std::string redraw_bytes(std::size_t previousRows, const std::vector& rows); // The rows of a frame: the bars, then at most `maxLines` of the frame's lines -// (the rest summarised as `… N more`), then a blank row and the status line -// when there is a status. The blank row is left out when nothing precedes the -// status line, on the screen or above it (`anythingAbove`). +// (the rest summarised as `… N more`), then the status line when there is a +// status. std::vector region_rows(const std::vector& bars, - const Frame& frame, std::size_t maxLines, - bool anythingAbove); + const Frame& frame, std::size_t maxLines); // --- progress bar (single-line, \r-rewritten) --- // @@ -345,6 +365,8 @@ bool g_quiet = false; bool g_inited = false; // -1: follow stdout; 0 / 1: set by set_live_progress. int g_liveOverride = -1; +// East Asian ambiguous characters count two columns (terminal::ambiguous_wide). +bool g_ambiguousWide = false; constexpr std::string_view kReset = "\033[0m"; constexpr std::string_view kBold = "\033[1m"; @@ -407,6 +429,7 @@ struct Region { // The bars of the ProgressBars alive, in creation order. std::vector> bars; std::size_t drawnRows = 0; // rows of the region on the screen now + std::vector lastRows; // the rows drawn last bool anythingAbove = false; // this command wrote a line to stdout std::chrono::steady_clock::time_point lastDraw{}; std::chrono::steady_clock::time_point lastLine{}; @@ -457,43 +480,81 @@ std::size_t max_live_lines() { return std::min(10, rows > 3 ? rows - 3 : 1); } -// Draws the region as it is now; line_mutex() held. -void redraw_locked() { +// The region is first drawn half a second into the command (revision 3, +// §7.2): a command that ends sooner shows no status row, and the lines of its +// first half-second do not move one. Measured with 2026.9.29.5: the status +// line was on the screen 0.6 ms after the command started, and the first +// warning, 0.37 s later, pushed it down. +constexpr auto kFirstDraw = std::chrono::milliseconds(500); + +// Whether the region has something to draw on a terminal now; line_mutex() +// held. +bool may_draw_locked() { + auto& r = region(); + if (r.suspended > 0 || g_quiet || !bars_live()) return false; + if (r.drawnRows > 0) return true; + if (!(r.open && r.live) && r.bars.empty()) return false; + return std::chrono::steady_clock::now() - start_point() >= kFirstDraw; +} + +// The region's rows as they are now, fitted to the width; line_mutex() held. +std::vector current_rows_locked() { auto& r = region(); - if (r.suspended > 0 || g_quiet) return; - const bool live = bars_live(); - if (!live) return; Frame frame; if (r.open && r.source) frame = r.source(); std::vector bars; for (auto const& [who, text] : r.bars) bars.push_back(text); - auto rows = region_rows(bars, frame, max_live_lines(), r.anythingAbove); + auto rows = region_rows(bars, frame, max_live_lines()); const auto width = term::cols() > 1 ? term::cols() - 1 : 1; for (auto& row : rows) row = fit(row, width); - term::write(term::Stream::Out, redraw_bytes(r.drawnRows, rows)); - std::fflush(stdout); + return rows; +} + +void drawn_locked(std::vector rows) { + auto& r = region(); r.drawnRows = rows.size(); + r.lastRows = std::move(rows); r.lastDraw = std::chrono::steady_clock::now(); } +// Draws the region as it is now, in one write; line_mutex() held. A region +// that has not changed is not written again. +void redraw_locked() { + auto& r = region(); + if (!may_draw_locked()) return; + auto rows = current_rows_locked(); + if (rows.size() == r.drawnRows && rows == r.lastRows) return; + term::write_frame(term::Stream::Out, frame_bytes(r.drawnRows, {}, rows)); + drawn_locked(std::move(rows)); +} + void erase_locked() { auto& r = region(); if (r.drawnRows == 0) return; - term::write(term::Stream::Out, erase_bytes(r.drawnRows)); - std::fflush(stdout); + term::write_frame(term::Stream::Out, erase_bytes(r.drawnRows)); r.drawnRows = 0; + r.lastRows.clear(); } -// Writes `text` (whole lines) to the stream above the region; line_mutex() -// held. +// Writes `text` (whole lines) above the region; line_mutex() held. With the +// region on a terminal, the lines and the region's new rows leave in one +// write. A line for standard error travels in that write when standard error +// is the same terminal; otherwise it goes to standard error alone, which is +// not the screen the region is on. void emit_locked(term::Stream s, std::string_view text) { auto& r = region(); - erase_locked(); - term::write(s, text); - std::fflush(s == term::Stream::Out ? stdout : stderr); if (s == term::Stream::Out && !text.empty()) r.anythingAbove = true; r.lastLine = std::chrono::steady_clock::now(); - redraw_locked(); + const bool framed = may_draw_locked() + && (s == term::Stream::Out || term::same_terminal()); + if (!framed) { + term::write(s, text); + std::fflush(s == term::Stream::Out ? stdout : stderr); + return; + } + auto rows = current_rows_locked(); + term::write_frame(term::Stream::Out, frame_bytes(r.drawnRows, text, rows)); + drawn_locked(std::move(rows)); } void emit(term::Stream s, std::string_view text); @@ -555,10 +616,13 @@ void emit(term::Stream s, std::string_view text) { void init() { if (g_inited) return; g_color = detect_color(); + g_ambiguousWide = term::ambiguous_wide(); g_inited = true; mcpp::log::set_terminal_sink(&verbose_record); } +void set_ambiguous_wide(bool wide) { g_ambiguousWide = wide; } + void disable_color() { g_color = false; } bool is_color_enabled() { return g_color; } @@ -770,8 +834,17 @@ std::pair decode(std::string_view s, std::size_t i) { return {b, 1}; } +// East Asian ambiguous characters this program writes: the middle dot, the +// punctuation block (`…`, dashes), arrows, box drawing, blocks and geometric +// shapes. Braille, which the status row's display uses, is narrow everywhere. +bool ambiguous(char32_t c) { + return c == 0x00B7 || (c >= 0x2010 && c <= 0x203E) || (c >= 0x2190 && c <= 0x21FF) + || (c >= 0x2500 && c <= 0x25FF); +} + std::size_t char_width(char32_t c) { if (c < 0x20 || c == 0x7F) return 0; + if (g_ambiguousWide && ambiguous(c)) return 2; // Combining marks. if ((c >= 0x0300 && c <= 0x036F) || (c >= 0x1AB0 && c <= 0x1AFF) || (c >= 0x1DC0 && c <= 0x1DFF) || (c >= 0x20D0 && c <= 0x20FF) @@ -867,12 +940,23 @@ std::string step_line(std::string_view verb, std::string_view subject, std::string status_line(std::string_view phase, std::string_view rest) { init(); - std::string s = g_color ? std::format("{}{}{}{}", kBold, kBrightCyan, phase, kReset) - : std::string(phase); + const auto verb = std::format("{:>12}", phase); + std::string s = g_color ? std::format("{}{}{}{}", kBold, kBrightCyan, verb, kReset) + : verb; if (!rest.empty()) s += std::format(" {}", rest); return s; } +std::string hue(std::string_view text, Hue h) { + init(); + if (!g_color || h == Hue::Plain || text.empty()) return std::string(text); + std::string_view code = h == Hue::Cyan ? "\033[36m" + : h == Hue::Magenta ? "\033[95m" + : h == Hue::Blue ? "\033[94m" + : kDim; + return std::format("{}{}{}", code, text, kReset); +} + // ─── The command's clock ───────────────────────────────────────────────── void mark_command_start() { (void)start_point(); } @@ -880,18 +964,35 @@ std::chrono::steady_clock::time_point command_start() { return start_point(); } // ─── The live region ───────────────────────────────────────────────────── -std::string redraw_bytes(std::size_t previousRows, const std::vector& rows) { - std::string s = erase_bytes(previousRows); +std::string frame_bytes(std::size_t previousRows, std::string_view text, + const std::vector& rows) { + std::string s; + if (previousRows > 0) { + s += '\r'; + if (previousRows > 1) s += std::format("\033[{}A", previousRows - 1); + } + while (!text.empty()) { + const auto nl = text.find('\n'); + s += text.substr(0, nl); + s += "\033[K\n"; + text.remove_prefix(nl == std::string_view::npos ? text.size() : nl + 1); + } for (std::size_t i = 0; i < rows.size(); ++i) { if (i) s += '\n'; + s += "\033[?7l"; s += rows[i]; + s += "\033[K\033[?7h"; } + if (previousRows > 0) s += "\033[J"; return s; } +std::string redraw_bytes(std::size_t previousRows, const std::vector& rows) { + return frame_bytes(previousRows, {}, rows); +} + std::vector region_rows(const std::vector& bars, - const Frame& frame, std::size_t maxLines, - bool anythingAbove) { + const Frame& frame, std::size_t maxLines) { std::vector rows = bars; const std::size_t room = maxLines > bars.size() ? maxLines - bars.size() : 0; const std::size_t shown = frame.lines.size() <= room @@ -900,10 +1001,7 @@ std::vector region_rows(const std::vector& bars, if (shown < frame.lines.size()) rows.push_back(std::format("{}… {} more", std::string(12, ' '), frame.lines.size() - shown)); - if (!frame.status.empty()) { - if (!rows.empty() || anythingAbove) rows.emplace_back(); - rows.push_back(frame.status); - } + if (!frame.status.empty()) rows.push_back(frame.status); return rows; } diff --git a/src/ui/dots_screen/chomp.cppm b/src/ui/dots_screen/chomp.cppm new file mode 100644 index 00000000..e882be92 --- /dev/null +++ b/src/ui/dots_screen/chomp.cppm @@ -0,0 +1,40 @@ +// mcpp.ui.dots_screen:chomp — the chomper, its pellets and its ghost +// (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.9 to §5.13). + +export module mcpp.ui.dots_screen:chomp; + +import std; +import :core; + +namespace mcpp::ui::dots_screen { + +// ── chomp: the chomper's position is the fraction, the pellets ahead the +// work left, and a ghost follows; the chomper chomps when steps finish +// (design §5.10; the first design, as the review chose in §5.13). +class Chomp final : public Animation { +public: + void update(const Input& in) override { + t_ += in.dt; + failed_ = in.failed; + x_ = in.fraction * (kWidth - 5); + if (in.finished > 0) shut_ = !shut_; + else if (static_cast(t_ * 2) % 2) shut_ = false; // an idle chomp + } + void draw(Screen& cv) const override { + for (int x = static_cast(x_) + 5; x < kWidth; x += 2) cv.set(x, 2, Colour::Grey); + static constexpr std::array ghostA = {".XX.", "XXXX", "XXXX", "X.X."}; + static constexpr std::array ghostB = {".XX.", "XXXX", "XXXX", ".X.X"}; + static constexpr std::array open = {".XXX", "XX..", "XX..", ".XXX"}; + static constexpr std::array shut = {".XX.", "XXXX", "XXXX", ".XX."}; + const bool stride = static_cast(t_ * 4) % 2; + sprite(cv, failed_ ? x_ - 3 : x_ - 7, 0, stride ? ghostB : ghostA, Colour::BrightCyan); + sprite(cv, x_, 0, (shut_ || failed_) ? shut : open, failed_ ? Colour::Red : Colour::Yellow); + } +private: + double t_ = 0, x_ = 0; + bool shut_ = false, failed_ = false; +}; + +std::unique_ptr make_chomp(std::uint64_t) { return std::make_unique(); } + +} // namespace mcpp::ui::dots_screen diff --git a/src/ui/dots_screen/core.cppm b/src/ui/dots_screen/core.cppm new file mode 100644 index 00000000..6e285857 --- /dev/null +++ b/src/ui/dots_screen/core.cppm @@ -0,0 +1,158 @@ +// mcpp.ui.dots_screen:core — the screen of the status row, its colours, and what +// an animation is (.agents/docs/2026-09-30-build-output-refinement-design.md, +// §5.9 to §5.13). +// +// The screen is 24 braille cells: 48 columns of four dots. A braille cell is +// U+2800 plus eight bits, one per dot, so every dot is addressable; a colour +// applies to the whole cell. Braille is East Asian narrow everywhere, so the +// screen takes one column per cell whatever the terminal does with ambiguous +// characters. + +export module mcpp.ui.dots_screen:core; + +import std; + +export namespace mcpp::ui::dots_screen { + +inline constexpr int kWidth = 48; // columns of dots +inline constexpr int kHeight = 4; // rows of dots +inline constexpr int kCells = kWidth / 2; + +enum class Colour : std::uint8_t { + None, Dark, Grey, Default, White, Cyan, BrightCyan, Magenta, Blue, + Yellow, Red, Green, BrightGreen, +}; + +// Where a package's work came from (design §5.10). Each source has the colour +// its packages' names have in the package lines, so those lines are the +// screen's legend. +enum class Source : std::uint8_t { Cache, Official, Index, Git, Project, Other }; +Colour colour_of(Source s); + +// 48 x 4 dots, each unlit or lit in a colour. +class Screen { +public: + // Lights the dot nearest (x, y); a dot off the screen is ignored. + void set(double x, double y, Colour c); + Colour at(int x, int y) const { return dots_[static_cast(y * kWidth + x)]; } + // The 24 cells. A cell takes the colour of its brightest lit dot (grey + // and dark count least). With `colour` false no escape sequence is + // written, and the shapes alone remain. + std::string render(bool colour) const; +private: + std::array dots_{}; +}; + +// What the build did since the previous frame. +struct Input { + double dt = 0.0; // seconds since the previous frame + std::size_t finished = 0; // steps finished since the previous frame + double fraction = 0.0; // finished / planned, 0 to 1 + bool failed = false; // a step has failed +}; + +// EVERY ANIMATION TAKES ITS TEMPO FROM THE BUILD: time moves it slowly, which +// shows that mcpp is alive; finished steps move it further, which shows that +// the build is busy; a failure changes it. A wait therefore looks like a +// wait, and the counts beside the screen say why. An animation is pure: given +// a seed and a sequence of inputs it draws the same frames, which is what the +// unit tests read. +class Animation { +public: + virtual ~Animation() = default; + virtual void update(const Input& in) = 0; + // A package's first step finished: its line was written. + virtual void package(Source) {} + virtual void draw(Screen& screen) const = 0; +}; + +} // namespace mcpp::ui::dots_screen + +namespace mcpp::ui::dots_screen { + +// For the animations of this module: draws `rows` ('X' lit) with its top +// left corner at (x, y). +void sprite(Screen& screen, double x, double y, std::span rows, Colour c) { + for (std::size_t dy = 0; dy < rows.size(); ++dy) + for (std::size_t dx = 0; dx < rows[dy].size(); ++dx) + if (rows[dy][dx] == 'X') + screen.set(x + static_cast(dx), y + static_cast(dy), c); +} + +namespace { + +// Bit of the dot at (column within the cell, row). +constexpr std::uint8_t kBit[2][4] = {{0x01, 0x02, 0x04, 0x40}, {0x08, 0x10, 0x20, 0x80}}; + +std::string_view sgr(Colour c) { + switch (c) { + case Colour::Dark: return "\x1b[2;90m"; + case Colour::Grey: return "\x1b[90m"; + case Colour::Default: return "\x1b[39m"; + case Colour::White: return "\x1b[97m"; + case Colour::Cyan: return "\x1b[36m"; + case Colour::BrightCyan: return "\x1b[96m"; + case Colour::Magenta: return "\x1b[95m"; + case Colour::Blue: return "\x1b[94m"; + case Colour::Yellow: return "\x1b[93m"; + case Colour::Red: return "\x1b[91m"; + case Colour::Green: return "\x1b[32m"; + case Colour::BrightGreen: return "\x1b[92m"; + case Colour::None: break; + } + return "\x1b[90m"; +} + +int rank(Colour c) { + return c == Colour::None ? -1 : (c == Colour::Grey || c == Colour::Dark) ? 0 : 1; +} + +} // namespace + +Colour colour_of(Source s) { + switch (s) { + case Source::Cache: return Colour::Grey; + case Source::Official: return Colour::Cyan; + case Source::Index: return Colour::Magenta; + case Source::Git: return Colour::Blue; + case Source::Project: return Colour::White; + case Source::Other: break; + } + return Colour::Default; +} + +void Screen::set(double x, double y, Colour c) { + const auto xi = static_cast(std::lround(x)); + const auto yi = static_cast(std::lround(y)); + if (xi < 0 || xi >= kWidth || yi < 0 || yi >= kHeight) return; + dots_[static_cast(yi * kWidth + xi)] = c; +} + +std::string Screen::render(bool colour) const { + std::string out; + Colour open = Colour::None; + for (int cell = 0; cell < kCells; ++cell) { + std::uint8_t bits = 0; + Colour c = Colour::None; + for (int side = 0; side < 2; ++side) + for (int y = 0; y < kHeight; ++y) { + const Colour d = at(cell * 2 + side, y); + if (d == Colour::None) continue; + bits |= kBit[side][y]; + if (rank(d) > rank(c)) c = d; + } + if (colour && bits && c != open) { + out += sgr(c); + open = c; + } + // U+2800 + bits, in UTF-8: three bytes. + const char32_t cp = 0x2800 + bits; + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } + if (colour && open != Colour::None) out += "\x1b[0m"; + return out; +} + +} // namespace mcpp::ui::dots_screen diff --git a/src/ui/dots_screen/dots_screen.cppm b/src/ui/dots_screen/dots_screen.cppm new file mode 100644 index 00000000..dfe13510 --- /dev/null +++ b/src/ui/dots_screen/dots_screen.cppm @@ -0,0 +1,45 @@ +// mcpp.ui.dots_screen — the screen of the status row and the animations it plays +// (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.9 to §5.13). +// +// The counts beside the screen state the progress, so the screen plays one of +// four animations, chosen per command: the chomper, the snake, the stack and +// the ions. Each lives in a partition of its own; this unit knows them by +// name. The progress model feeds the chosen animation and asks it for its +// cells; nothing here touches a terminal or a clock. + +export module mcpp.ui.dots_screen; + +export import :core; +export import :chomp; +export import :snake; +export import :stack; +export import :ions; + +import std; + +export namespace mcpp::ui::dots_screen { + +// The animations built in: chomp, snake, stack, ions. +std::span names(); +// The animation called `name`, seeded; nullptr for a name not in `names()`. +std::unique_ptr make(std::string_view name, std::uint64_t seed); + +} // namespace mcpp::ui::dots_screen + +namespace mcpp::ui::dots_screen { + +namespace { +constexpr std::array kNames = {"chomp", "snake", "stack", "ions"}; +} // namespace + +std::span names() { return kNames; } + +std::unique_ptr make(std::string_view name, std::uint64_t seed) { + if (name == "chomp") return make_chomp(seed); + if (name == "snake") return make_snake(seed); + if (name == "stack") return make_stack(seed); + if (name == "ions") return make_ions(seed); + return nullptr; +} + +} // namespace mcpp::ui::dots_screen diff --git a/src/ui/dots_screen/ions.cppm b/src/ui/dots_screen/ions.cppm new file mode 100644 index 00000000..c88f16c9 --- /dev/null +++ b/src/ui/dots_screen/ions.cppm @@ -0,0 +1,63 @@ +// mcpp.ui.dots_screen:ions — the emitter whose ions build the deposit +// (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.9 to §5.13). + +export module mcpp.ui.dots_screen:ions; + +import std; +import :core; + +namespace mcpp::ui::dots_screen { + +// ── ions: an emitter on the right edge scans the four rows and fires ions +// when steps finish; they fly left and vanish at the deposit, which is the +// progress, each dot in the colour of the source whose package last started +// when it was laid (design §5.11). The deposit follows the fraction exactly; +// the ions only show the pace. +class Ions final : public Animation { +public: + explicit Ions(std::uint64_t seed) : rnd_(seed) {} + void package(Source s) override { current_ = colour_of(s); } + void update(const Input& in) override { + t_ += in.dt; + failed_ = in.failed; + const auto target = static_cast(in.fraction * (kWidth - 4) * kHeight); + while (units_.size() < target) units_.push_back(current_); + muzzle_ = 1.5 + 1.5 * std::sin(t_ * 3); + fire_ = std::max(0.0, fire_ - in.dt); + if (in.finished > 0 && !failed_) { + const auto n = std::min(3, 1 + in.finished / 20); + std::uniform_real_distribution jitter(-0.6, 0.6); + for (std::size_t i = 0; i < n; ++i) + ions_.push_back({kWidth - 3.0, muzzle_ + jitter(rnd_), + current_ == Colour::Grey ? Colour::White : current_}); + fire_ = 0.15; + } + const double front = static_cast(units_.size() / kHeight); + for (auto& ion : ions_) ion.x -= 2.5 * in.dt * 10; + std::erase_if(ions_, [&](const Ion& ion) { return ion.x <= front + 1; }); + } + void draw(Screen& cv) const override { + for (std::size_t u = 0; u < units_.size(); ++u) { + const bool breach = failed_ && u + 4 >= units_.size(); + cv.set(static_cast(u / kHeight), static_cast(kHeight - 1 - u % kHeight), + breach ? Colour::Red : units_[u]); + } + for (auto const& ion : ions_) + cv.set(ion.x, std::clamp(ion.y, 0.0, static_cast(kHeight - 1)), ion.colour); + for (int y = 0; y < kHeight; ++y) cv.set(kWidth - 1, y, Colour::Blue); + cv.set(kWidth - 2, std::clamp(std::round(muzzle_), 0.0, static_cast(kHeight - 1)), + failed_ ? Colour::Red : fire_ > 0 ? Colour::White : Colour::Blue); + } +private: + struct Ion { double x, y; Colour colour; }; + std::mt19937_64 rnd_; + std::vector units_; + std::vector ions_; + Colour current_ = Colour::Grey; + double t_ = 0, muzzle_ = 1.5, fire_ = 0; + bool failed_ = false; +}; + +std::unique_ptr make_ions(std::uint64_t seed) { return std::make_unique(seed); } + +} // namespace mcpp::ui::dots_screen diff --git a/src/ui/dots_screen/snake.cppm b/src/ui/dots_screen/snake.cppm new file mode 100644 index 00000000..fefaebe7 --- /dev/null +++ b/src/ui/dots_screen/snake.cppm @@ -0,0 +1,118 @@ +// mcpp.ui.dots_screen:snake — the snake that eats the packages the build reaches +// (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.9 to §5.13). + +export module mcpp.ui.dots_screen:snake; + +import std; +import :core; + +namespace mcpp::ui::dots_screen { + +// ── snake: it takes the shortest free path to its food; a package's first +// step drops a food in the package's colour, and the segments grown after +// that meal keep its colour, so the body records the packages the build +// reached (design §5.12). It grows with the fraction. +class Snake final : public Animation { +public: + explicit Snake(std::uint64_t seed) : rnd_(seed) { + for (int x = 6; x >= 3; --x) { body_.push_back({x, 1}); colours_.push_back(Colour::Grey); } + food_ = place(); + } + void package(Source s) override { + meals_.push_back({place(), colour_of(s)}); + } + void update(const Input& in) override { + failed_ = in.failed; + length_ = 4 + static_cast(in.fraction * 30); + acc_ += in.dt; + const double period = in.finished > 0 ? 0.05 : 0.25; + if (failed_ || acc_ < period) return; + acc_ = 0; + const Cell target = meals_.empty() ? food_ : meals_.front().first; + auto step = path_step(target); + if (!step) { + step = any_step(); + if (!step) { reset(); return; } + } + body_.push_front(*step); + if (!meals_.empty() && *step == meals_.front().first) { + eaten_ = meals_.front().second; + meals_.pop_front(); + } else if (*step == food_) { + food_ = place(); + } + colours_.push_front(eaten_); + while (body_.size() > length_) body_.pop_back(); + while (colours_.size() > body_.size()) colours_.pop_back(); + } + void draw(Screen& cv) const override { + cv.set(food_.first, food_.second, Colour::Dark); + for (auto const& [cell, colour] : meals_) cv.set(cell.first, cell.second, colour); + for (std::size_t i = 0; i < body_.size(); ++i) { + const Colour c = failed_ ? Colour::Red + : i == 0 ? Colour::BrightGreen + : i < colours_.size() ? colours_[i] : Colour::Grey; + cv.set(body_[i].first, body_[i].second, c); + } + } +private: + using Cell = std::pair; + bool occupied(Cell c) const { return std::ranges::find(body_, c) != body_.end(); } + Cell place() { + std::vector free; + for (int x = 0; x < kWidth; ++x) + for (int y = 0; y < kHeight; ++y) + if (!occupied({x, y})) free.push_back({x, y}); + if (free.empty()) return {0, 0}; + return free[std::uniform_int_distribution(0, free.size() - 1)(rnd_)]; + } + static Cell moved(Cell c, int dx, int dy) { return {(c.first + dx + kWidth) % kWidth, c.second + dy}; } + std::optional path_step(Cell target) const { + const Cell head = body_.front(); + std::set blocked(body_.begin(), body_.end() - 1); + std::map prev{{head, head}}; + std::deque q{head}; + while (!q.empty()) { + const Cell cur = q.front(); + q.pop_front(); + if (cur == target) break; + for (auto [dx, dy] : {std::pair{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) { + const Cell n = moved(cur, dx, dy); + if (n.second < 0 || n.second >= kHeight || prev.contains(n) || blocked.contains(n)) + continue; + prev.emplace(n, cur); + q.push_back(n); + } + } + if (!prev.contains(target) || target == head) return std::nullopt; + Cell step = target; + while (prev.at(step) != head) step = prev.at(step); + return step; + } + std::optional any_step() const { + std::set blocked(body_.begin(), body_.end() - 1); + for (auto [dx, dy] : {std::pair{1, 0}, {0, 1}, {0, -1}, {-1, 0}}) { + const Cell n = moved(body_.front(), dx, dy); + if (n.second >= 0 && n.second < kHeight && !blocked.contains(n)) return n; + } + return std::nullopt; + } + void reset() { + body_.clear(); + colours_.clear(); + for (int x = 6; x >= 3; --x) { body_.push_back({x, 1}); colours_.push_back(Colour::Grey); } + } + std::mt19937_64 rnd_; + std::deque body_; + std::deque colours_; + std::deque> meals_; + Cell food_{0, 0}; + Colour eaten_ = Colour::Grey; + std::size_t length_ = 4; + double acc_ = 0; + bool failed_ = false; +}; + +std::unique_ptr make_snake(std::uint64_t seed) { return std::make_unique(seed); } + +} // namespace mcpp::ui::dots_screen diff --git a/src/ui/dots_screen/stack.cppm b/src/ui/dots_screen/stack.cppm new file mode 100644 index 00000000..b37f55ab --- /dev/null +++ b/src/ui/dots_screen/stack.cppm @@ -0,0 +1,110 @@ +// mcpp.ui.dots_screen:stack — Tetris lying on its side +// (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.9 to §5.13). + +export module mcpp.ui.dots_screen:stack; + +import std; +import :core; + +namespace mcpp::ui::dots_screen { + +// ── stack: Tetris on its side. Pieces fall leftward and rest against the +// stack where they leave the fewest holes; at most three fly at once, and a +// burst of progress settles at once, so the stack's area follows the fraction +// within four pieces (design §5.11). +class Stack final : public Animation { +public: + explicit Stack(std::uint64_t seed) : rnd_(seed) {} + void update(const Input& in) override { + failed_ = in.failed; + const auto target = static_cast(in.fraction * (kWidth - 6) * kHeight); + if (!failed_) { + while (cells_.size() + 4 * flying_.size() + 16 <= target) lock(spawn()); + while (flying_.size() < 3 && cells_.size() + 4 * (flying_.size() + 1) <= target) + flying_.push_back(spawn()); + } + const double speed = (5.0 + std::min(6.0, static_cast(in.finished) * 0.5)) * in.dt * 10; + for (auto it = flying_.begin(); it != flying_.end();) { + it->x = std::max(static_cast(it->land), it->x - speed); + if (it->x <= it->land) { lock(*it); it = flying_.erase(it); } + else ++it; + } + } + void draw(Screen& cv) const override { + for (auto const& [cell, colour] : cells_) + cv.set(cell.first, cell.second, failed_ ? Colour::Red : colour); + for (auto const& p : flying_) + for (auto [dx, dy] : p.shape) cv.set(p.x + dx, dy, p.colour); + } +private: + using Cell = std::pair; + using Shape = std::vector; + struct Piece { Shape shape; Colour colour; double x; int land; }; + static const std::vector, Colour>>& kinds() { + static const std::vector, Colour>> k = { + {{{{0,0},{1,0},{2,0},{3,0}}, {{0,0},{0,1},{0,2},{0,3}}}, Colour::BrightCyan}, + {{{{0,0},{1,0},{0,1},{1,1}}}, Colour::Yellow}, + {{{{0,0},{1,0},{2,0},{1,1}}, {{0,0},{0,1},{0,2},{1,1}}, + {{1,0},{0,1},{1,1},{2,1}}, {{1,0},{1,1},{1,2},{0,1}}}, Colour::Magenta}, + {{{{1,0},{2,0},{0,1},{1,1}}, {{0,0},{0,1},{1,1},{1,2}}}, Colour::BrightGreen}, + {{{{0,0},{1,0},{1,1},{2,1}}, {{1,0},{1,1},{0,1},{0,2}}}, Colour::Red}, + {{{{0,0},{0,1},{0,2},{1,2}}, {{0,0},{1,0},{2,0},{0,1}}, + {{0,0},{1,0},{1,1},{1,2}}, {{2,0},{0,1},{1,1},{2,1}}}, Colour::White}, + {{{{1,0},{1,1},{1,2},{0,2}}, {{0,0},{0,1},{1,1},{2,1}}, + {{0,0},{1,0},{0,1},{0,2}}, {{0,0},{1,0},{2,0},{2,1}}}, Colour::Blue}, + }; + return k; + } + bool taken(Cell c) const { + if (cells_.contains(c)) return true; + for (auto const& p : flying_) + for (auto [dx, dy] : p.shape) + if (Cell{p.land + dx, dy} == c) return true; + return false; + } + int landing(const Shape& s) const { + int x = kWidth; + auto fits = [&](int at) { + for (auto [dx, dy] : s) if (taken({at + dx, dy})) return false; + return true; + }; + while (x > 0 && fits(x - 1)) --x; + return x; + } + Piece spawn() { + const auto& all = kinds(); + const auto& [rotations, colour] = + all[std::uniform_int_distribution(0, all.size() - 1)(rnd_)]; + std::optional> best; + for (auto const& r : rotations) { + int h = 0; + for (auto [dx, dy] : r) h = std::max(h, dy + 1); + for (int oy = 0; oy + h <= kHeight; ++oy) { + Shape s; + for (auto [dx, dy] : r) s.push_back({dx, dy + oy}); + const int land = landing(s); + int front = 0, minx = kWidth; + for (auto [dx, dy] : s) { front = std::max(front, land + dx); minx = std::min(minx, land + dx); } + int holes = 0; + for (auto [dx, dy] : s) + for (int qx = minx; qx < land + dx; ++qx) + if (!taken({qx, dy}) && std::ranges::find(s, Cell{qx - land, dy}) == s.end()) + ++holes; + const int score = holes * 4 + front; + if (!best || score < best->first) best = {score, Piece{s, colour, double(kWidth), land}}; + } + } + return best->second; + } + void lock(const Piece& p) { + for (auto [dx, dy] : p.shape) cells_[{p.land + dx, dy}] = p.colour; + } + std::mt19937_64 rnd_; + std::map cells_; + std::vector flying_; + bool failed_ = false; +}; + +std::unique_ptr make_stack(std::uint64_t seed) { return std::make_unique(seed); } + +} // namespace mcpp::ui::dots_screen diff --git a/tests/unit/test_build_progress.cpp b/tests/unit/test_build_progress.cpp index 36dcae8b..3b2ebfdb 100644 --- a/tests/unit/test_build_progress.cpp +++ b/tests/unit/test_build_progress.cpp @@ -4,9 +4,10 @@ import std; import mcpp.ui; import mcpp.build.progress; -// The build's report (.agents/docs/2026-09-29-build-progress-display-design.md): -// its readers, the step record, the measures of text the renderer uses, and -// the completion rule of §3.2. +// The build's report (.agents/docs/2026-09-29-build-progress-display-design.md, +// revised by .agents/docs/2026-09-30-build-output-refinement-design.md): its +// readers, the step record, the measures of text the renderer uses, the bytes +// of one frame, and when a package is named. using namespace mcpp::build::progress; @@ -56,6 +57,16 @@ TEST(ProgressStatus, ReadsTheCountsAndTheEndTime) { // A nested ninja's status line, which the outer ninja relays with its // escape sequence stripped, is output, not a status line. + // `%u`: the steps not yet started (revision 3, §5.8); absent in a line of + // the three-field format. + EXPECT_FALSE(s->unstarted.has_value()); + auto u = parse_status("\x1b[0m@@mcpp 700 707 41.2 0@@ LINK bin/xlings"); + ASSERT_TRUE(u.has_value()); + EXPECT_EQ(u->finished, 700u); + EXPECT_EQ(u->total, 707u); + ASSERT_TRUE(u->unstarted.has_value()); + EXPECT_EQ(*u->unstarted, 0u); + EXPECT_EQ(u->text, "LINK bin/xlings"); EXPECT_FALSE(parse_status("@@mcpp 3 12 0.027@@ OBJ obj/a.o").has_value()); EXPECT_FALSE(parse_status("[3/12] OBJ obj/a.o").has_value()); EXPECT_FALSE(parse_status("src/a.cpp:1:2: error: @@mcpp").has_value()); @@ -156,16 +167,33 @@ TEST(ProgressRecord, EveryStatementIsRecordedWithItsOwner) { EXPECT_EQ(r.step.at("obj/main.o"), r.step.at("pcm.cache/app.pcm")); EXPECT_NE(r.step.at("obj/main.o"), r.step.at("bin/app.bin")); + r.packages[0].detail = "v0.1.0 (.)"; + r.packages[0].source = "project"; auto back = parse_record(format_record(r)); EXPECT_EQ(back.steps, r.steps); ASSERT_EQ(back.packages.size(), 2u); EXPECT_EQ(back.packages[0].subject, "app v0.1.0 (.)"); + EXPECT_EQ(back.packages[0].detail, "v0.1.0 (.)"); + EXPECT_EQ(back.packages[0].source, "project"); EXPECT_TRUE(back.packages[0].requested); EXPECT_EQ(back.owner, r.owner); EXPECT_EQ(back.step, r.step); EXPECT_EQ(back.actions, r.actions); } +TEST(ProgressRecord, AVersionOneRecordIsRead) { + // 2026.9.29.5 wrote version 1: six fields to a package line, the subject + // carrying the origin. The fast path of a newer mcpp reads it as it is. + auto r = parse_record("# mcpp steps v1\t3\nP\txlings\t1\t0\t2\txlings (.)\n" + "O\t0\t0\tobj/a.o\n"); + EXPECT_EQ(r.steps, 3u); + ASSERT_EQ(r.packages.size(), 1u); + EXPECT_EQ(r.packages[0].subject, "xlings (.)"); + EXPECT_TRUE(r.packages[0].detail.empty()); + EXPECT_TRUE(r.packages[0].source.empty()); + EXPECT_EQ(r.owner.at("obj/a.o"), 0u); +} + TEST(ProgressRecord, PathsAreComparedNormalised) { EXPECT_EQ(normalise("./obj\\a.o"), "obj/a.o"); EXPECT_EQ(normalise("obj/../obj/b.o"), "obj/b.o"); @@ -210,100 +238,139 @@ TEST(ProgressText, TheStateStartsAtTheBlocksColumn) { // ─── The region ────────────────────────────────────────────────────────── -TEST(ProgressRegion, TheStatusLineIsLastAfterABlankRow) { - mcpp::ui::Frame f{{" Compiling gpp.gui (GPPGUI) 61 steps"}, "Building 612/1203 · 14:32"}; - auto rows = mcpp::ui::region_rows({}, f, 10, true); - ASSERT_EQ(rows.size(), 3u); - EXPECT_EQ(rows[1], ""); - EXPECT_EQ(rows[2], "Building 612/1203 · 14:32"); - // Nothing above it and no live line: no blank row. - auto alone = mcpp::ui::region_rows({}, {{}, "Resolving · 0:01"}, 10, false); - ASSERT_EQ(alone.size(), 1u); +TEST(ProgressRegion, TheStatusLineIsTheLastRowWithNoBlankRow) { + mcpp::ui::Frame f{{}, " Building 612/1203 · 14:32"}; + auto rows = mcpp::ui::region_rows({"bar"}, f, 10); + ASSERT_EQ(rows.size(), 2u); + EXPECT_EQ(rows[0], "bar"); + EXPECT_EQ(rows[1], " Building 612/1203 · 14:32"); } TEST(ProgressRegion, LinesBeyondTheRoomAreSummarised) { mcpp::ui::Frame f; for (int i = 0; i < 14; ++i) f.lines.push_back(std::format("line {}", i)); f.status = "Building"; - auto rows = mcpp::ui::region_rows({}, f, 10, true); - ASSERT_EQ(rows.size(), 12u); // 9 lines, the summary, the blank row, the status + auto rows = mcpp::ui::region_rows({}, f, 10); + ASSERT_EQ(rows.size(), 11u); // 9 lines, the summary, the status EXPECT_NE(rows[9].find("… 5 more"), std::string::npos); } -TEST(ProgressRegion, ARedrawReplacesThePreviousRows) { - auto bytes = mcpp::ui::redraw_bytes(3, {"a", "", "status"}); - EXPECT_TRUE(bytes.starts_with("\r\033[2A\033[J")); - EXPECT_TRUE(bytes.ends_with("a\n\nstatus")); - EXPECT_EQ(mcpp::ui::redraw_bytes(0, {"x"}), "x"); +TEST(ProgressRegion, AFrameOverwritesTheOldRowsInsteadOfErasingThem) { + // From the first row of a region of two rows: the line over the first, + // the new status row over the second, the tail of each cleared, and only + // then what is left below. No row is erased before it is written + // (revision 3, §9.2). + EXPECT_EQ(mcpp::ui::frame_bytes(2, " Compiling a\n", {"s"}), + "\r\033[1A Compiling a\033[K\n\033[?7ls\033[K\033[?7h\033[J"); + // A region drawn for the first time starts where the cursor is. + EXPECT_EQ(mcpp::ui::frame_bytes(0, {}, {"s"}), "\033[?7ls\033[K\033[?7h"); + // Erasing leaves the cursor at the region's first row. + EXPECT_EQ(mcpp::ui::frame_bytes(1, {}, {}), "\r\033[J"); + auto bytes = mcpp::ui::redraw_bytes(3, {"a", "status"}); + EXPECT_TRUE(bytes.starts_with("\r\033[2A\033[?7la")) << bytes; + EXPECT_EQ(count(bytes, "\033[J"), 1u); + EXPECT_TRUE(bytes.ends_with("\033[J")); +} + +TEST(ProgressText, AmbiguousCharactersCountTwoWhereTheTerminalDrawsThemWide) { + mcpp::ui::set_ambiguous_wide(true); + EXPECT_EQ(mcpp::ui::display_width("·"), 2u); + EXPECT_EQ(mcpp::ui::display_width("━─"), 4u); + EXPECT_EQ(mcpp::ui::display_width("⣿⣀"), 2u); // braille is narrow everywhere + mcpp::ui::set_ambiguous_wide(false); + EXPECT_EQ(mcpp::ui::display_width("·"), 1u); +} + +TEST(ProgressText, ThePhaseIsAlignedWithTheVerbs) { + mcpp::ui::disable_color(); + EXPECT_EQ(mcpp::ui::status_line("Building", "612/707 · 0:35"), " Building 612/707 · 0:35"); + EXPECT_EQ(mcpp::ui::status_line("Running", "· 0:04"), " Running · 0:04"); } -// ─── The completion rule (§3.2) ────────────────────────────────────────── +// ─── When a package is named (revision 3, §5.3) ────────────────────────── -TEST(ProgressModel, APackageIsFinalWhenEveryStepOfItRan) { +TEST(ProgressModel, APackageIsNamedOnceWhenItsFirstStepFinishes) { mcpp::ui::disable_color(); Tmp tmp; Record rec; - rec.packages = {{"app", true, "app v0.1.0 (.)", 0, 2}, {"dep", false, "dep v1.0.0", 0, 1}, - {"std", false, "std", 0, 2}}; + rec.packages = {{"app", true, "app", 0, 2, "v0.1.0 (.)", "project"}, + {"compat.dep", false, "compat.dep", 0, 1, "v1.0.0", "official"}, + {"std", false, "std", 0, 2, {}, {}}}; rec.owner = {{"obj/main.o", 0}, {"bin/app", 0}, {"obj/dep.o", 1}, - {"pcm.cache/std.pcm", 2}, {"pcm.cache/std.compat.pcm", 2}}; - rec.step = {{"obj/main.o", 0}, {"bin/app", 1}, {"obj/dep.o", 2}, - {"pcm.cache/std.pcm", 3}, {"pcm.cache/std.compat.pcm", 4}}; - rec.steps = 5; + {"pcm.cache/std.pcm", 2}}; + rec.step = {{"obj/main.o", 0}, {"bin/app", 1}, {"obj/dep.o", 2}, {"pcm.cache/std.pcm", 3}}; + rec.steps = 4; Build b(tmp.path); b.set_record(rec); testing::internal::CaptureStdout(); b.pass_begin(); const auto log = tmp.path / ".ninja_log"; append(log, "# ninja log v6\n1\t10\t0\tobj/dep.o\taa\n"); - b.status({1, 4, 10, {}}); - append(log, "10\t30\t0\tobj/main.o\tbb\n"); - b.status({2, 4, 30, {}}); - auto mid = testing::internal::GetCapturedStdout(); - // `dep` ran its one step, but `std` has a step that never runs - // (`std.compat`): the folded line waits, and states nothing early. - EXPECT_EQ(mid.find("dependenc"), std::string::npos) << mid; - EXPECT_EQ(mid.find("app v0.1.0"), std::string::npos) << mid; - - testing::internal::CaptureStdout(); + b.status({1, 4, 10, {}, {}}); + append(log, "1\t12\t0\tpcm.cache/std.pcm\tdd\n10\t30\t0\tobj/main.o\tbb\n"); + b.status({3, 4, 30, {}, {}}); append(log, "30\t50\t0\tbin/app\tcc\n"); - b.status({3, 4, 50, {}}); - auto done = testing::internal::GetCapturedStdout(); - EXPECT_NE(done.find("Compiling app v0.1.0 (.)"), std::string::npos) << done; - EXPECT_NE(done.find("done 0.04s"), std::string::npos) << done; // 10 ms to 50 ms + b.status({4, 4, 50, 0, {}}); + b.pass_end(); + b.finish(true); + auto out = testing::internal::GetCapturedStdout(); + // The dependency first, as its first step finished first; each once; the + // standard library module is the toolchain's and is not named. + const auto dep = out.find("Compiling compat.dep v1.0.0"); + const auto app = out.find("Compiling app v0.1.0 (.)"); + ASSERT_NE(dep, std::string::npos) << out; + ASSERT_NE(app, std::string::npos) << out; + EXPECT_LT(dep, app) << out; + EXPECT_EQ(count(out, "app v0.1.0"), 1u) << out; + EXPECT_EQ(out.find("std"), std::string::npos) << out; + // No outcome, no fold: the line states that the package compiles. + EXPECT_EQ(out.find("done"), std::string::npos) << out; + EXPECT_EQ(out.find("dependenc"), std::string::npos) << out; +} +TEST(ProgressModel, APackageTheCacheServesIsNamedCachedWithItsUnits) { + mcpp::ui::disable_color(); + Tmp tmp; + Record rec; + rec.packages = {{"compat.ftxui", false, "compat.ftxui", 73, 2, "v6.1.9", "official"}}; + rec.owner = {{"obj/a.o", 0}, {"obj/b.o", 0}}; + rec.step = {{"obj/a.o", 0}, {"obj/b.o", 1}}; + Build b(tmp.path); + b.set_record(rec); testing::internal::CaptureStdout(); + b.pass_begin(); + append(tmp.path / ".ninja_log", "# ninja log v6\n1\t2\t0\tobj/a.o\taa\n1\t2\t0\tobj/b.o\tbb\n"); + b.status({2, 2, 2, 0, {}}); b.pass_end(); b.finish(true); - auto end = testing::internal::GetCapturedStdout(); - EXPECT_NE(end.find("Compiling 1 dependency"), std::string::npos) << end; - EXPECT_EQ(count(end, "app v0.1.0"), 0u) << end; // written once, earlier + auto out = testing::internal::GetCapturedStdout(); + EXPECT_NE(out.find("Cached compat.ftxui v6.1.9 (73 units)"), std::string::npos) << out; + EXPECT_EQ(count(out, "compat.ftxui"), 1u) << out; } -TEST(ProgressModel, AfterAFailureAnOpenPackageStatesOnlyItsSteps) { +TEST(ProgressModel, AFailureNamesItsPackageOnce) { mcpp::ui::disable_color(); Tmp tmp; Record rec; - rec.packages = {{"app", true, "app v0.1.0 (.)", 0, 3}, {"lib", true, "lib (lib)", 0, 2}}; - rec.owner = {{"obj/a.o", 0}, {"obj/b.o", 0}, {"bin/app", 0}, {"obj/l.o", 1}, {"bin/l.a", 1}}; - rec.step = {{"obj/a.o", 0}, {"obj/b.o", 1}, {"bin/app", 2}, {"obj/l.o", 3}, {"bin/l.a", 4}}; + rec.packages = {{"app", true, "app", 0, 1, "v0.1.0 (.)", "project"}, + {"lib", true, "lib", 0, 1, "v0.1.0 (lib)", "project"}}; + rec.owner = {{"obj/a.o", 0}, {"obj/l.o", 1}}; + rec.step = {{"obj/a.o", 0}, {"obj/l.o", 1}}; Build b(tmp.path); b.set_record(rec); testing::internal::CaptureStdout(); b.pass_begin(); - append(tmp.path / ".ninja_log", "# ninja log v6\n1\t10\t0\tobj/a.o\taa\n"); - b.status({1, 5, 10, {}}); - const bool first = b.failed("obj/l.o "); - b.status({2, 5, 12, {}}); + // A failed step is not in ninja's log: the package is named from the + // FAILED line. + const auto first = b.failed("obj/l.o "); + const auto second = b.failed("obj/a.o "); b.pass_end(); b.finish(false); auto out = testing::internal::GetCapturedStdout(); - EXPECT_TRUE(first); - EXPECT_NE(out.find("lib (lib)"), std::string::npos) << out; - EXPECT_NE(out.find("failed"), std::string::npos) << out; - EXPECT_NE(out.find("app v0.1.0 (.)"), std::string::npos) << out; - EXPECT_NE(out.find("1 step"), std::string::npos) << out; - EXPECT_EQ(out.find("done"), std::string::npos) << out; + ASSERT_TRUE(first.has_value()); + EXPECT_EQ(*first, "lib v0.1.0 (lib)"); + EXPECT_FALSE(second.has_value()); + EXPECT_EQ(count(out, "Compiling lib v0.1.0 (lib)"), 1u) << out; } TEST(ProgressModel, AStepWhoseEntriesAreReadInTwoPiecesCountsOnce) { @@ -312,7 +379,7 @@ TEST(ProgressModel, AStepWhoseEntriesAreReadInTwoPiecesCountsOnce) { mcpp::ui::disable_color(); Tmp tmp; Record rec; - rec.packages = {{"lib", true, "lib (lib)", 0, 2}}; + rec.packages = {{"lib", true, "lib", 0, 2, "v0.1.0 (lib)", "project"}}; rec.owner = {{"obj/lib.m.o", 0}, {"pcm.cache/lib.pcm", 0}, {"bin/lib.a", 0}}; rec.step = {{"obj/lib.m.o", 0}, {"pcm.cache/lib.pcm", 0}, {"bin/lib.a", 1}}; Build b(tmp.path); @@ -321,14 +388,11 @@ TEST(ProgressModel, AStepWhoseEntriesAreReadInTwoPiecesCountsOnce) { b.pass_begin(); const auto log = tmp.path / ".ninja_log"; append(log, "# ninja log v6\n1\t10\t0\tobj/lib.m.o\taa\n"); - b.status({1, 2, 10, {}}); + b.status({1, 2, 10, {}, {}}); append(log, "1\t10\t0\tpcm.cache/lib.pcm\taa\n"); - b.status({1, 2, 10, {}}); - auto out = testing::internal::GetCapturedStdout(); - // Two steps, one of them run: the package is not complete. - EXPECT_EQ(out.find("done"), std::string::npos) << out; - testing::internal::CaptureStdout(); + b.status({1, 2, 10, {}, {}}); b.pass_end(); b.finish(true); - (void)testing::internal::GetCapturedStdout(); + auto out = testing::internal::GetCapturedStdout(); + EXPECT_EQ(count(out, "Compiling lib"), 1u) << out; } diff --git a/tests/unit/test_dots_screen.cpp b/tests/unit/test_dots_screen.cpp new file mode 100644 index 00000000..6a47a50b --- /dev/null +++ b/tests/unit/test_dots_screen.cpp @@ -0,0 +1,140 @@ +#include + +import std; +import mcpp.ui.dots_screen; + +// The status row's screen and its animations +// (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.9 to §5.13): +// 24 braille cells, a colour per cell, and four animations that take their +// tempo from the build and are pure given a seed and their inputs. + +using namespace mcpp::ui::dots_screen; + +namespace { + +// The code points of a rendering without colour: one braille cell each. +std::vector cells(std::string_view s) { + std::vector out; + for (std::size_t i = 0; i < s.size();) { + const auto b = static_cast(s[i]); + if (b == 0x1b) { // skip an escape sequence + while (i < s.size() && s[i] != 'm') ++i; + ++i; + continue; + } + EXPECT_EQ(b & 0xF0, 0xE0) << "not a three-byte code point at " << i; + const char32_t c = (static_cast(b & 0x0F) << 12) + | (static_cast(s[i + 1] & 0x3F) << 6) + | static_cast(s[i + 2] & 0x3F); + out.push_back(c); + i += 3; + } + return out; +} + +// Plays `frames` frames of a build that finishes `perFrame` steps a frame +// until `fraction` reaches 1, and returns the last rendering. +std::string play(Animation& a, int frames, std::size_t perFrame, bool failAt = false, + bool colour = false) { + std::string last; + for (int i = 0; i < frames; ++i) { + Input in; + in.dt = 0.1; + in.finished = perFrame; + in.fraction = std::min(1.0, (i + 1) / static_cast(frames)); + in.failed = failAt && i > frames / 2; + a.update(in); + Screen sc; + a.draw(sc); + last = sc.render(colour); + } + return last; +} + +} // namespace + +TEST(DotsScreen, TheFourAnimationsAreBuiltIn) { + auto names = mcpp::ui::dots_screen::names(); + ASSERT_EQ(names.size(), 4u); + EXPECT_EQ(names[0], "chomp"); + EXPECT_EQ(names[1], "snake"); + EXPECT_EQ(names[2], "stack"); + EXPECT_EQ(names[3], "ions"); + for (auto n : names) EXPECT_NE(make(n, 1), nullptr) << n; + EXPECT_EQ(make("runner", 1), nullptr); + EXPECT_EQ(make("", 1), nullptr); +} + +TEST(DotsScreen, TheScreenIsTwentyFourBrailleCells) { + Screen sc; + sc.set(0, 0, Colour::White); // the first dot of the first cell + sc.set(47, 3, Colour::Red); // the last dot of the last cell + sc.set(99, 0, Colour::White); // off the screen: ignored + const auto text = sc.render(false); + auto c = cells(text); + ASSERT_EQ(c.size(), 24u); + for (auto cp : c) EXPECT_TRUE(cp >= 0x2800 && cp <= 0x28FF); + EXPECT_EQ(c.front(), char32_t{0x2801}); + EXPECT_EQ(c.back(), char32_t{0x2880}); + EXPECT_EQ(text.find('\x1b'), std::string::npos); + const auto coloured = sc.render(true); + EXPECT_NE(coloured.find("\x1b[91m"), std::string::npos); + EXPECT_TRUE(coloured.ends_with("\x1b[0m")); +} + +TEST(DotsScreen, AnAnimationIsPureGivenItsSeedAndInputs) { + for (auto n : mcpp::ui::dots_screen::names()) { + auto a = make(n, 42), b = make(n, 42); + EXPECT_EQ(play(*a, 60, 5), play(*b, 60, 5)) << n; + } +} + +TEST(DotsScreen, EveryFrameIsTwentyFourCellsAndAFailureTurnsRed) { + for (auto n : mcpp::ui::dots_screen::names()) { + auto a = make(n, 7); + const auto plain = play(*a, 40, 3); + EXPECT_EQ(cells(plain).size(), 24u) << n; + EXPECT_EQ(plain.find('\n'), std::string::npos) << n; + auto f = make(n, 7); + const auto failed = play(*f, 40, 3, /*failAt=*/true, /*colour=*/true); + EXPECT_NE(failed.find("\x1b[91m"), std::string::npos) << n; + } +} + +TEST(DotsScreen, TheSnakeCarriesTheColourOfThePackagesItAte) { + auto a = make("snake", 3); + a->package(Source::Official); + a->package(Source::Official); + const auto frame = play(*a, 200, 2, false, /*colour=*/true); + EXPECT_NE(frame.find("\x1b[36m"), std::string::npos) << "no segment in the official colour"; +} + +TEST(DotsScreen, TheIonsDepositIsTheFraction) { + auto a = make("ions", 5); + Input in; + in.dt = 0.1; + in.fraction = 0.5; + a->update(in); + Screen sc; + a->draw(sc); + int lit = 0; + for (int x = 0; x < kWidth - 4; ++x) + for (int y = 0; y < kHeight; ++y) + if (sc.at(x, y) != Colour::None) ++lit; + EXPECT_EQ(lit, (kWidth - 4) * kHeight / 2); +} + +TEST(DotsScreen, TheChomperStandsAtTheFraction) { + auto a = make("chomp", 1); + Input in; + in.fraction = 1.0; + a->update(in); + Screen sc; + a->draw(sc); + // At the end the chomper has eaten every pellet: nothing lit on its right. + bool pellet = false; + for (int x = kWidth - 1 - 0; x < kWidth; ++x) + if (sc.at(x, 2) == Colour::Grey) pellet = true; + EXPECT_FALSE(pellet); + EXPECT_EQ(sc.at(kWidth - 5, 1), Colour::Yellow); +} From e4854d8cdcf6b4bb13503e8c0ea9e9be4757d424 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:05:27 +0800 Subject: [PATCH 05/17] docs: the build output design records review rounds 8 and 9: the four animations, mcpp.ui.dots_screen, and --play-game --- ...26-09-30-build-output-refinement-design.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/.agents/docs/2026-09-30-build-output-refinement-design.md b/.agents/docs/2026-09-30-build-output-refinement-design.md index feee3d8f..9fc3bd57 100644 --- a/.agents/docs/2026-09-30-build-output-refinement-design.md +++ b/.agents/docs/2026-09-30-build-output-refinement-design.md @@ -919,6 +919,157 @@ smallest rendition. - `play_demo.py`: the revised chomp, snake, ions and stack, and the beaver. Flags: `--anim NAME`, `--fail`, `--speed`. +### 5.12 Review round 8: the first set, a playable stack, and the sign withdrawn + +The eighth review settled or asked about five things: + +- the revised chomp is harder to read than the first; +- the snake is liked, and its food and body should carry the colours of real + modules; +- the stack is liked, and the review asked whether it could be a real game + steered with the arrow keys; +- the runner is liked; +- the `MC++` sign reads oddly. + +**Chomp** returns to the first design, in which the chomper's position is +the fraction, pellets lie ahead and one ghost follows. It gains one addition: +a faint corridor along the top and bottom rows, so the part already eaten is +not blank (`--anim chomp1`). + +**Snake.** + +- A package's first step drops a food in the colour of the package's source + (section 5.10), and the snake takes the foods in the order the packages + started. +- The segments that grow after a meal keep that meal's colour, so the body + is a coloured record of the packages the build reached. The head stays + bright. +- The grey food between meals keeps the snake moving at the build's pace. + +**A playable stack.** It can be built, and it would be the first input mcpp +reads while a build runs: + +- the ticker thread reads keys in a non-canonical terminal mode (POSIX + `termios` without `ICANON` and `ECHO`, `ISIG` kept so that Ctrl-C still + stops the build; the console input mode on Windows); +- up and down move the piece between the four rows, left drops it, space + turns it; a filled column clears, as a filled row does in the original; +- the score is stated after `Finished`. + +Its costs decide its place: + +- **Type-ahead.** Keys typed ahead while a build runs, usually the next + command, would be taken by the game. No build tool reads its terminal + during a build, for this reason. +- **The terminal mode.** It must be restored on every exit: success, + failure, Ctrl-C, termination. A process killed outright leaves the shell + without echo until `stty sane`. +- **Competing readers.** A child that reads the terminal (a prepare action's + installer) would compete with the game for keys. +- **What the stack means.** In play the stack no longer shows progress; the + counts do. + +It is therefore proposed only as an explicit opt-in (`MCPP_PROGRESS=play`), +never a default, and after the display itself has shipped. + +**The sign is withdrawn.** A status row is one text row, and braille gives +it four dots of height, the most any character offers. The smallest legible +pixel fonts need five rows (3 × 5), and at four `M`, `C` and `+` become +ambiguous. No arrangement of the letters fixes that. A mark for mcpp at this +size is better carried by a mascot's silhouette (section 5.11) than by +letters. + +**The first set,** from the reviews: chomp (the first design, with the +corridor), snake (coloured by source), stack (automatic), runner, and the +beaver if the mascot is adopted, chosen at random per command. The LED bar +and a plain text row remain available through `MCPP_PROGRESS`. + +### 5.13 Review round 9: the four animations, and where they live + +The ninth review fixed the set: + +- the chomper exactly as first designed (its position is the fraction, the + pellets ahead are the work left, one ghost follows); +- the snake coloured by source; +- the stack (Tetris on its side); +- the ion emitter. + +The command chooses one at random. The runner and the LED bar are not built +in. + +The screen and its animations form one module in a directory of their own, +`src/ui/dots_screen/`: + +- `mcpp.ui.dots_screen:core` holds the 48 × 4 screen (`Screen`), its colours, + the sources, the input an animation receives, and the `Animation` + interface; +- one partition per animation (`:chomp`, `:snake`, `:stack`, `:ions`); +- the primary unit `mcpp.ui.dots_screen` knows them by name. + +The progress model imports only the primary unit. An animation is pure, so +its frames are compared in unit tests from a seed and a sequence of inputs. + +### 5.14 Review round 9: `--play-game` + +The review asked whether the snake, the stack and the runner could be +steered with the arrow keys, turned on by a `--play-game` option, and run at +a game's own speed. + +They can. The option removes the objection of section 5.12: a user who asks +for a game has chosen to have the keys read, so keys typed ahead are no +longer taken by surprise. The design: + +**Games.** + +| Game | Keys | Rules on 48 × 4 | +|---|---|---| +| snake | arrows steer | the food of section 5.12, coloured by the source of each package that starts; hitting the body ends the round, and a new one starts at once | +| stack | up and down move the piece between the four rows, left drops it, space turns it | a filled column clears; a stack that reaches the right edge ends the round | +| runner | space or up jumps | cacti come from the right; a collision ends the round | + +- `--play-game` chooses one of the three at random; `--play-game=snake` + names one. +- The option is accepted by `build`, `run` and `test`. +- A game runs at a fixed speed of its own (the snake eight cells a second, + the stack's pieces two cells a second, faster as columns clear), not at the + build's pace. The counts and the clock beside the screen state the build. + +**The row.** The status row reads, for example, +` Building ⣿…⣀ 612/707 · 0:35 · snake 12`: the score follows the clock. When +the build ends the game ends, and a line after `Finished` states the round's +best score. + +**The terminal.** Keys are read by the region's thread from the controlling +terminal: + +- **POSIX**: the terminal's mode loses `ICANON` and `ECHO` and keeps `ISIG`, + so Ctrl-C still stops the build. Reads do not block (`VMIN` 0, `VTIME` 0). + The arrows arrive as `ESC [ A` to `ESC [ D` (or `ESC O A` in application + mode). +- **Windows**: the console input mode loses `ENABLE_LINE_INPUT` and + `ENABLE_ECHO_INPUT`, and `ReadConsoleInputW` yields key events (`VK_UP` + and the others). +- **Restoring the mode**: it is restored when the region closes, at normal + exit, on failure, and in the handler of SIGINT, SIGTERM and SIGHUP, which + then re-raises. A process killed outright cannot restore it; the option's + help says so, and names `stty sane`. +- **No competing readers**: ninja already gives the commands it runs + `/dev/null` for input; the build programs mcpp runs receive a null input + while a game is on. + +**Where it is off.** The game is off when standard input or standard output +is not a terminal, under `--quiet`, and where the screen is off +(`MCPP_PROGRESS=plain` or `off`, or no braille). A note says why. + +**Tests.** + +- A game is pure given its seed, its key sequence and its clock, so rounds + are replayed in unit tests. +- An e2e through a pseudo-terminal writes arrow keys to the terminal's + master side and checks that the snake turned, that the terminal's mode + after the command equals its mode before, and that Ctrl-C during a game + leaves the mode restored. + ## 6. Requirements, revised | Revision 2 | Revision 3 | @@ -1342,6 +1493,9 @@ Settled in review round 2: W-b, C and 2. (section 5.10). If the animations, the set to build in. - The tail clause, and the tab and taskbar progress. - `MCPP_PROGRESS` and its values. +Settled in review round 9: the four animations (section 5.13), the module +`mcpp.ui.dots_screen`, and `--play-game` (section 5.14). + - **D3''. The animations (section 5.11).** The first set to build in, from the sign (logo or project name), chomp, snake, ions, stack, scanner, pulse, glide, runner and life; whether mcpp adopts a mascot (the beaver is From d7491c1d4d746389dd6a35ca9a701d645fe98b6d Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:27:14 +0800 Subject: [PATCH 06/17] --play-game plays snake, stack or runner in the status row, and the e2e tests read the lines of revision 3 A game is an animation steered from the keyboard (mcpp.ui.dots_screen:snake_game, :stack_game, :runner_game) at a speed of its own; --play-game chooses one, --play-game=NAME names one, and without the option the status row plays the four animations as before. Keys are read from the terminal without echo or line ends while Ctrl-C still interrupts; the mode is restored when the report closes and, through the signal guard that kills ninja's group, when a signal ends mcpp. The game is off, with a line saying why, where standard input or output is not a terminal. A package is named at its first step that is not a scan, so a consumer no longer precedes the package it imports; its source comes from the resolution's record, so a package reached through [feature-deps] is named as the path package it is; a path or git package is named without the default namespace it takes; a cached package's unit count joins the origin's parentheses. The e2e tests that proved a program's cache hit read it under --verbose, where a reused program is stated; e2e 845 plays through a pseudo-terminal. --- modules/platform/src/terminal.cppm | 151 ++++++++++++++++++ .../platform/src/unix/bounded_process.cppm | 57 ++++++- src/build/prepare/plan.cpp | 48 ++++-- src/build/progress.cppm | 106 +++++++++++- src/cli.cppm | 16 ++ src/ui.cppm | 11 +- src/ui/dots_screen/core.cppm | 14 ++ src/ui/dots_screen/dots_screen.cppm | 26 ++- src/ui/dots_screen/runner_game.cppm | 95 +++++++++++ src/ui/dots_screen/snake_game.cppm | 123 ++++++++++++++ src/ui/dots_screen/stack_game.cppm | 139 ++++++++++++++++ tests/e2e/139_build_program_advisory.sh | 3 +- tests/e2e/163_identity_first_resolution.sh | 2 +- tests/e2e/172_build_cache_cross_project.sh | 2 +- .../e2e/186_build_mcpp_protocol_and_bound.sh | 3 +- tests/e2e/196_version_identity_and_lock.sh | 2 +- tests/e2e/19_bmi_cache_reuse.sh | 4 +- tests/e2e/212_cached_dep_std_is_ordered.sh | 2 +- tests/e2e/40_llvm_bmi_cache.sh | 2 +- tests/e2e/49_bmi_cache_nested_custom_index.sh | 2 +- tests/e2e/53_namespaced_cache_label.sh | 2 +- ...build_program_deploys_what_it_generated.sh | 3 +- ..._dependency_selects_a_repository_member.sh | 2 +- ...d_program_declares_a_runtime_search_dir.sh | 3 +- ...6_plugin_diagnostics_features_and_names.sh | 3 +- ...d_programs_are_named_ordered_and_cached.sh | 17 +- .../842_the_build_reports_each_step_once.sh | 53 +++--- ...3_a_terminal_names_the_action_that_runs.sh | 40 ++++- .../845_play_game_restores_the_terminal.sh | 149 +++++++++++++++++ tests/e2e/89_build_mcpp.sh | 3 +- tests/unit/test_dots_screen.cpp | 74 +++++++++ 31 files changed, 1082 insertions(+), 75 deletions(-) create mode 100644 src/ui/dots_screen/runner_game.cppm create mode 100644 src/ui/dots_screen/snake_game.cppm create mode 100644 src/ui/dots_screen/stack_game.cppm create mode 100644 tests/e2e/845_play_game_restores_the_terminal.sh diff --git a/modules/platform/src/terminal.cppm b/modules/platform/src/terminal.cppm index 88e2b1ac..a2cad2a0 100644 --- a/modules/platform/src/terminal.cppm +++ b/modules/platform/src/terminal.cppm @@ -8,6 +8,7 @@ // write(s, text) — UTF-8 text to a standard stream // write_frame(s, b) — a frame of a live display, in one write // same_terminal() — whether stdout and stderr reach one terminal +// KeyInput — keys read from the terminal while a game plays module; #include @@ -17,6 +18,7 @@ module; #include #include #include +#include #endif #if defined(_WIN32) #include // _dup, _dup2, _close, _get_osfhandle, _fileno @@ -38,6 +40,7 @@ module; export module mcpp.platform.terminal; import std; +import mcpp.platform.unix.bounded_process; export namespace mcpp::platform::terminal { @@ -107,6 +110,31 @@ bool ambiguous_wide(); // the braille block. bool unicode_capable(); +// The keys a game reads (build output design revision 3, §5.14): the arrows, +// the space bar, and `wasd` as a second set of arrows. +enum class Key { Up, Down, Left, Right, Space }; + +// KEYS READ FROM THE TERMINAL FOR THE LIFETIME OF THE OBJECT, without echo and +// without waiting for a line end; Ctrl-C still interrupts. Active only when +// standard input and standard output are both terminals. The mode the +// terminal had is restored when the object is destroyed, and by the signal +// handler if a signal ends mcpp first (POSIX: `unixproc::guard_terminal_mode`; +// Windows: a console control handler). +class KeyInput { +public: + KeyInput(); + ~KeyInput(); + KeyInput(const KeyInput&) = delete; + KeyInput& operator=(const KeyInput&) = delete; + bool active() const { return active_; } + // The keys pressed since the last call; never blocks. + std::vector read(); +private: + bool active_ = false; + std::string pending_; // an escape sequence not yet complete + unsigned long savedMode_ = 0; // Windows: the console input mode +}; + // EVERYTHING WRITTEN TO STANDARD OUTPUT GOES TO STANDARD ERROR UNTIL THIS IS // DESTROYED. // @@ -329,6 +357,129 @@ bool unicode_capable() { #endif } +namespace { + +#if defined(_WIN32) +HANDLE g_inputHandle = INVALID_HANDLE_VALUE; +DWORD g_inputMode = 0; +BOOL WINAPI restore_input_mode(DWORD) { + if (g_inputHandle != INVALID_HANDLE_VALUE) ::SetConsoleMode(g_inputHandle, g_inputMode); + return FALSE; // the next handler, then the default action, still run +} +#endif + +// The keys in `bytes` (POSIX): an arrow is `ESC [ A` to `ESC [ D`, or with +// `O` in place of `[` in application mode. An incomplete sequence at the end +// is left in `pending`. +std::vector decode_keys(std::string& pending) { + std::vector keys; + std::size_t i = 0; + while (i < pending.size()) { + const char c = pending[i]; + if (c == '\x1b') { + if (i + 2 >= pending.size()) break; // wait for the rest + if (pending[i + 1] == '[' || pending[i + 1] == 'O') { + switch (pending[i + 2]) { + case 'A': keys.push_back(Key::Up); break; + case 'B': keys.push_back(Key::Down); break; + case 'C': keys.push_back(Key::Right); break; + case 'D': keys.push_back(Key::Left); break; + default: break; + } + i += 3; + continue; + } + ++i; + continue; + } + switch (c) { + case ' ': keys.push_back(Key::Space); break; + case 'w': case 'W': keys.push_back(Key::Up); break; + case 's': case 'S': keys.push_back(Key::Down); break; + case 'a': case 'A': keys.push_back(Key::Left); break; + case 'd': case 'D': keys.push_back(Key::Right); break; + default: break; + } + ++i; + } + pending.erase(0, i); + return keys; +} + +} // namespace + +KeyInput::KeyInput() { + if (!is_terminal(Stream::Out)) return; +#if defined(_WIN32) + const HANDLE h = ::GetStdHandle(STD_INPUT_HANDLE); + DWORD mode = 0; + if (h == INVALID_HANDLE_VALUE || !::GetConsoleMode(h, &mode)) return; + g_inputHandle = h; + g_inputMode = mode; + savedMode_ = mode; + ::SetConsoleCtrlHandler(restore_input_mode, TRUE); + // ENABLE_PROCESSED_INPUT stays: Ctrl-C still interrupts. + if (!::SetConsoleMode(h, mode & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT))) return; + active_ = true; +#elif defined(__unix__) || defined(__APPLE__) + if (::isatty(0) == 0) return; + struct termios mode{}; + if (::tcgetattr(0, &mode) != 0) return; + mcpp::platform::unixproc::guard_terminal_mode(0); + struct termios raw = mode; + raw.c_lflag &= static_cast(~(ICANON | ECHO)); // ISIG stays: Ctrl-C interrupts + raw.c_cc[VMIN] = 0; + raw.c_cc[VTIME] = 0; + if (::tcsetattr(0, TCSANOW, &raw) != 0) { + mcpp::platform::unixproc::unguard_terminal_mode(); + return; + } + active_ = true; +#endif +} + +KeyInput::~KeyInput() { + if (!active_) return; +#if defined(_WIN32) + ::SetConsoleMode(g_inputHandle, static_cast(savedMode_)); + ::SetConsoleCtrlHandler(restore_input_mode, FALSE); + g_inputHandle = INVALID_HANDLE_VALUE; +#elif defined(__unix__) || defined(__APPLE__) + mcpp::platform::unixproc::unguard_terminal_mode(); +#endif +} + +std::vector KeyInput::read() { + std::vector keys; + if (!active_) return keys; +#if defined(_WIN32) + DWORD n = 0; + while (::GetNumberOfConsoleInputEvents(g_inputHandle, &n) && n > 0) { + INPUT_RECORD rec{}; + DWORD got = 0; + if (!::ReadConsoleInputW(g_inputHandle, &rec, 1, &got) || got == 0) break; + if (rec.EventType != KEY_EVENT || !rec.Event.KeyEvent.bKeyDown) continue; + switch (rec.Event.KeyEvent.wVirtualKeyCode) { + case VK_UP: case 'W': keys.push_back(Key::Up); break; + case VK_DOWN: case 'S': keys.push_back(Key::Down); break; + case VK_LEFT: case 'A': keys.push_back(Key::Left); break; + case VK_RIGHT: case 'D': keys.push_back(Key::Right); break; + case VK_SPACE: keys.push_back(Key::Space); break; + default: break; + } + } +#elif defined(__unix__) || defined(__APPLE__) + char buf[64]; + while (true) { + const auto n = ::read(0, buf, sizeof buf); // VMIN 0, VTIME 0: never blocks + if (n <= 0) break; + pending_.append(buf, static_cast(n)); + } + keys = decode_keys(pending_); +#endif + return keys; +} + StdoutToStderr::StdoutToStderr() { std::fflush(stdout); #if defined(_WIN32) diff --git a/modules/platform/src/unix/bounded_process.cppm b/modules/platform/src/unix/bounded_process.cppm index 7179299e..9757652d 100644 --- a/modules/platform/src/unix/bounded_process.cppm +++ b/modules/platform/src/unix/bounded_process.cppm @@ -37,6 +37,9 @@ module; #include // fcntl O_NONBLOCK #include // nanosleep #include // fputs, stderr — the out-of-slots diagnostic +#if defined(__linux__) || defined(__APPLE__) +#include // tcgetattr, tcsetattr — the terminal mode a signal restores +#endif #if defined(__APPLE__) #include // _NSGetEnviron #endif @@ -174,6 +177,17 @@ void guard_group_on_signal(long long group); void unguard_group(long long group); void clear_group_guard(); +// THE TERMINAL MODE A SIGNAL RESTORES (build output design revision 3, +// §5.14). `--play-game` reads keys from the terminal without echo. A Ctrl-C, +// a termination or a hangup that ends mcpp must leave the terminal in the +// mode it found, and the handler installed while ninja runs is the one above, +// so the saved mode is restored there, with tcsetattr, which is +// async-signal-safe. `fd`'s current mode is saved; the caller changes the mode +// after this returns. A process killed outright cannot restore anything. +void guard_terminal_mode(int fd); +// Restores the saved mode and stops guarding it. +void unguard_terminal_mode(); + } // namespace mcpp::platform::unixproc namespace mcpp::platform::unixproc { @@ -353,6 +367,12 @@ namespace { constexpr int kMaxGuardedGroups = 8; volatile sig_atomic_t g_guardedGroups[kMaxGuardedGroups] = {}; +// The terminal whose mode the handler restores (-1: none), and that mode. +volatile sig_atomic_t g_terminalFd = -1; +struct termios g_terminalMode{}; + +void install_handlers(); + extern "C" void background_signal_handler(int sig) { // killpg is async-signal-safe. SIGKILL rather than SIGTERM: this is the // path where mcpp is about to stop existing, and there is nobody left to @@ -367,6 +387,7 @@ extern "C" void background_signal_handler(int sig) { const auto group = g_guardedGroups[i]; if (group > 0) ::killpg(static_cast(group), SIGKILL); } + if (g_terminalFd >= 0) ::tcsetattr(g_terminalFd, TCSANOW, &g_terminalMode); // Die of the signal we were sent, so the exit status is the one the shell // and any outer script expect from a Ctrl-C. ::signal(sig, SIG_DFL); @@ -472,9 +493,7 @@ void guard_group_on_signal(long long group) { for (int i = 0; i < kMaxGuardedGroups; ++i) { if (g_guardedGroups[i] == 0) { g_guardedGroups[i] = static_cast(group); - ::signal(SIGINT, background_signal_handler); - ::signal(SIGTERM, background_signal_handler); - ::signal(SIGHUP, background_signal_handler); + install_handlers(); return; } } @@ -498,7 +517,7 @@ void unguard_group(long long group) { else if (g_guardedGroups[i] != 0) any = true; } - if (!any) { + if (!any && g_terminalFd < 0) { ::signal(SIGINT, SIG_DFL); ::signal(SIGTERM, SIG_DFL); ::signal(SIGHUP, SIG_DFL); @@ -507,11 +526,39 @@ void unguard_group(long long group) { void clear_group_guard() { for (int i = 0; i < kMaxGuardedGroups; ++i) g_guardedGroups[i] = 0; + if (g_terminalFd >= 0) return; // the terminal's guard still needs the handler ::signal(SIGINT, SIG_DFL); ::signal(SIGTERM, SIG_DFL); ::signal(SIGHUP, SIG_DFL); } +void guard_terminal_mode(int fd) { + if (fd < 0 || ::tcgetattr(fd, &g_terminalMode) != 0) return; + g_terminalFd = fd; + install_handlers(); +} + +void unguard_terminal_mode() { + if (g_terminalFd < 0) return; + ::tcsetattr(g_terminalFd, TCSANOW, &g_terminalMode); + g_terminalFd = -1; + bool any = false; + for (int i = 0; i < kMaxGuardedGroups; ++i) any = any || g_guardedGroups[i] != 0; + if (!any) { + ::signal(SIGINT, SIG_DFL); + ::signal(SIGTERM, SIG_DFL); + ::signal(SIGHUP, SIG_DFL); + } +} + +namespace { +void install_handlers() { + ::signal(SIGINT, background_signal_handler); + ::signal(SIGTERM, background_signal_handler); + ::signal(SIGHUP, background_signal_handler); +} +} // namespace + #else DeadlineRun capture_with_deadline(const char* const*, unsigned long, @@ -531,6 +578,8 @@ void background_stop(long long, long long) {} void guard_group_on_signal(long long) {} void unguard_group(long long) {} void clear_group_guard() {} +void guard_terminal_mode(int) {} +void unguard_terminal_mode() {} #endif diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 9f7b53c1..db34831f 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -2348,21 +2348,51 @@ static void step13_report_packages(PrepareState& state, BuildContext& ctx) { } const auto dir = relative(pkg.root); const bool insideProject = dir == "." || !dir.starts_with(".."); + // The source is the resolution's record of the package, not the + // consumer's key: a package reached through `[feature-deps]` or + // another table has no entry in the consumer's `[dependencies]`. + const ResolvedRecord* rec = nullptr; + { + auto rn = mcpp::pm::compat::resolve_package_name(m.package.name, m.package.namespace_); + for (auto const& ns : {rn.namespace_, std::string(mcpp::pm::kDefaultNamespace), std::string{}}) + if (auto it = state.resolved.find(ResolvedKey{ns, rn.shortName}); + it != state.resolved.end()) { rec = &it->second; break; } + } + const std::string kind = rec ? rec->source + : spec && spec->isPath() ? "path" + : spec && spec->isGit() ? "git" : "version"; + // A path or git package is named without the default namespace: a + // manifest that declares none takes it during resolution, and a bare + // name means that namespace (package-identity §4.2), so the prefix + // would state something its author never wrote. + const auto declared = + m.package.namespace_.empty() || m.package.namespace_ == mcpp::pm::kDefaultNamespace + ? m.package.name : std::format("{}.{}", m.package.namespace_, m.package.name); if (pkg.selectedMember || (spec && spec->workspaceMember) - || (spec && spec->isPath() && insideProject)) { + || (kind == "path" && insideProject)) { p.subject = m.package.name; p.detail = versioned(m, dir); p.source = "project"; - } else if (spec && spec->isPath()) { - p.subject = p.name; + } else if (kind == "path") { + p.subject = declared; p.detail = versioned(m, dir); p.source = "path"; - } else if (spec && spec->isGit()) { - std::string ref = spec->gitRev; - if (spec->gitRefKind == "rev" && ref.size() > 12) ref.resize(12); - p.subject = p.name; - p.detail = versioned(m, std::format("git {} {}", - spec->gitRefKind.empty() ? "rev" : spec->gitRefKind, ref)); + } else if (kind == "git") { + // The declared reference: `#=` in the record, or + // the consumer's spec. + std::string refKind = spec ? spec->gitRefKind : std::string{}; + std::string ref = spec ? spec->gitRev : std::string{}; + if (rec) { + const auto hash = rec->sourceRef.rfind('#'); + const auto eq = rec->sourceRef.find('=', hash == std::string::npos ? 0 : hash); + if (hash != std::string::npos && eq != std::string::npos) { + refKind = rec->sourceRef.substr(hash + 1, eq - hash - 1); + ref = rec->sourceRef.substr(eq + 1); + } + } + if ((refKind.empty() || refKind == "rev") && ref.size() > 12) ref.resize(12); + p.subject = declared; + p.detail = versioned(m, std::format("git {} {}", refKind.empty() ? "rev" : refKind, ref)); p.source = "git"; } else { // An index package: official when the default index serves its diff --git a/src/build/progress.cppm b/src/build/progress.cppm index 358d9ad2..7a0f9296 100644 --- a/src/build/progress.cppm +++ b/src/build/progress.cppm @@ -643,6 +643,11 @@ struct Report { // by the build, or none. std::unique_ptr animation; bool animationColour = false; + // `--play-game` (revision 3, §5.14): the game, its name, and the keys it + // reads. The game is also `animation`'s place on the screen. + std::unique_ptr game; + std::string gameName; + std::unique_ptr keys; std::vector started; // packages announced since the last frame long long lastFrame = 0; // ms since command start std::size_t lastDone = 0; @@ -745,13 +750,17 @@ std::string plain_subject(const Build::Impl& b, std::size_t i) { // its units, `Compiling` otherwise. std::string package_line(const Report& r, const Build::Impl& b, std::size_t i) { const auto& p = b.record->packages[i]; - std::string subject = subject_of(r, b, p); if (p.cachedUnits > 0) { - subject += " " + mcpp::ui::hue(std::format("({})", plural(p.cachedUnits, "unit", "units")), - mcpp::ui::Hue::Dim); - return mcpp::ui::step_line("Cached", subject, 0, ""); + // The unit count joins the origin's parentheses when there are any: + // `v3.6.1 (index acme, 2 units)`, `v6.1.9 (73 units)`. + auto q = p; + const auto units = plural(p.cachedUnits, "unit", "units"); + q.detail = !q.detail.empty() && q.detail.ends_with(")") + ? std::format("{}, {})", q.detail.substr(0, q.detail.size() - 1), units) + : std::format("{}{}({})", q.detail, q.detail.empty() ? "" : " ", units); + return mcpp::ui::step_line("Cached", subject_of(r, b, q), 0, ""); } - return mcpp::ui::step_line("Compiling", subject, 0, ""); + return mcpp::ui::step_line("Compiling", subject_of(r, b, p), 0, ""); } // Writes a package's line the first time it does work; model lock held. The @@ -833,7 +842,15 @@ void read_log(Report& r, Build::Impl& b, std::vector& out) { s.first = std::min(s.first, start); s.last = std::max(s.last, end); label = std::format("{}: {}", b.record->packages[*owner].subject, label); - announce(r, b, *owner, out); + // A package is named at its first step that is not a scan: a + // dependency scan finishes before the compiles it orders, and + // naming a package there put a consumer before the package it + // imports. A package that only scanned is named when the pass + // ends. + const bool scan = std::ranges::all_of(st.outputs, [](const std::string& o) { + return o.ends_with(".ddi") || o.ends_with(".dd"); + }); + if (!scan) announce(r, b, *owner, out); } if (st.end - st.start > b.longest || !b.anyStep) { b.longest = st.end - st.start; @@ -949,7 +966,21 @@ mcpp::ui::Frame frame() { std::lock_guard lock(r.m); mcpp::ui::Frame f; std::string cells; - if (r.animation) { + std::string score; + if (r.game) { + const auto now = now_ms(); + screen::Input in; + in.dt = r.lastFrame ? static_cast(now - r.lastFrame) / 1000.0 : 0.0; + in.failed = r.failureReported; + for (auto s : r.started) r.game->package(s); + r.started.clear(); + r.game->update(in); + r.lastFrame = now; + screen::Screen sc; + r.game->draw(sc); + cells = sc.render(r.animationColour); + score = std::format("{} {}", r.gameName, r.game->score()); + } else if (r.animation) { std::size_t done = 0, total = 0; for (auto const& b : live_builds(r)) { done += b->doneBefore + b->finished; @@ -975,6 +1006,7 @@ mcpp::ui::Frame frame() { } } f.status = phase_status(r, cells); + if (!score.empty()) f.status += " · " + score; return f; } @@ -983,6 +1015,14 @@ void poll() { std::vector out; { std::lock_guard lock(r.m); + if (r.game && r.keys) + for (auto k : r.keys->read()) { + using TK = mcpp::platform::terminal::Key; + r.game->key(k == TK::Up ? screen::Key::Up + : k == TK::Down ? screen::Key::Down + : k == TK::Left ? screen::Key::Left + : k == TK::Right ? screen::Key::Right : screen::Key::Space); + } for (auto const& b : live_builds(r)) { read_starts(r, *b, out); read_log(r, *b, out); @@ -1011,18 +1051,58 @@ std::unique_ptr choose_animation() { return screen::make(names[seed % names.size()], seed); } +// `--play-game[=NAME]` (revision 3, §5.14): the CLI publishes the request as +// MCPP_PLAY_GAME (`random` or a name). The game needs what the screen needs, +// and keys: standard input and standard output on a terminal. Otherwise it is +// off, and one line says why. +void choose_game(Report& r, std::vector& notes) { + auto want = mcpp::platform::env::get("MCPP_PLAY_GAME").value_or(""); + if (want.empty()) return; + mcpp::platform::env::unset("MCPP_PLAY_GAME"); // not inherited by what mcpp runs + for (auto& c : want) c = static_cast(std::tolower(static_cast(c))); + const auto names = screen::game_names(); + if (want != "random" && !screen::make_game(want, 0)) { + std::string known; + for (auto n : names) known += (known.empty() ? "" : ", ") + std::string(n); + notes.push_back(std::format("--play-game: no game called '{}' (the games: {}); one is chosen", + want, known)); + want = "random"; + } + const auto progress = mcpp::platform::env::get("MCPP_PROGRESS").value_or(""); + if (mcpp::ui::is_quiet() || !mcpp::ui::live_progress() || progress == "plain" || progress == "off" + || !mcpp::platform::terminal::unicode_capable()) { + notes.push_back("--play-game: the status row's screen is off here (a terminal that " + "draws braille, without --quiet and MCPP_PROGRESS=plain or off, is needed)"); + return; + } + auto keys = std::make_unique(); + if (!keys->active()) { + notes.push_back("--play-game: standard input is not a terminal, so no key can be read"); + return; + } + const auto seed = static_cast( + std::chrono::steady_clock::now().time_since_epoch().count()); + r.gameName = want == "random" ? std::string(names[seed % names.size()]) : want; + r.game = screen::make_game(r.gameName, seed); + r.keys = std::move(keys); + mcpp::ui::set_frame_interval(std::chrono::milliseconds(50)); +} + } // namespace void open(bool verbose) { auto& r = report(); + std::vector notes; { std::lock_guard lock(r.m); if (r.open) return; r.open = true; r.verbose = verbose; - r.animation = choose_animation(); + choose_game(r, notes); + if (!r.game) r.animation = choose_animation(); r.animationColour = mcpp::ui::is_color_enabled(); } + for (auto const& n : notes) mcpp::ui::info("Game", n); mcpp::ui::open_region(&frame, &poll); } @@ -1031,6 +1111,7 @@ void close() { auto& r = report(); std::lock_guard lock(r.m); r.open = false; + r.keys.reset(); // the terminal's mode is restored here } void configurations(std::size_t n) { @@ -1169,8 +1250,14 @@ void finished(std::string_view profile, std::string_view descriptor) { } // `Finished` ends the report: the region is erased before it, and not // drawn again below it. + std::string played; + { + std::lock_guard lock(r.m); + if (r.game) played = std::format("{} · best {}", r.gameName, r.game->best()); + } close(); mcpp::ui::finished(profile, ms(total), descriptor, detail); + if (!played.empty()) mcpp::ui::line(mcpp::ui::step_line("Played", played, 0, "")); } // ─── Build ─────────────────────────────────────────────────────────────── @@ -1317,6 +1404,9 @@ void Build::pass_end() { std::lock_guard lock(r.m); auto& b = *impl_; read_log(r, b, out); + if (b.record) + for (std::size_t i = 0; i < b.packages.size(); ++i) + if (b.packages[i].finished > 0) announce(r, b, i, out); b.inPass = false; b.running.clear(); } diff --git a/src/cli.cppm b/src/cli.cppm index 9d52436a..9222788c 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -98,6 +98,7 @@ void print_usage() { std::println(" --no-color Disable colored output"); std::println(" --offline Never touch the network (also: MCPP_OFFLINE=1)"); std::println(" --locked Fail if resolution differs from mcpp.lock (also: --frozen, MCPP_LOCKED=1)"); + std::println(" --play-game[=NAME] Play snake, stack or runner in the status row while it builds"); std::println(" --jobs N|auto, -j Concurrent compiles ('auto' = cores + free RAM)"); std::println(" --toolchain SPEC Use this toolchain for one build (e.g. llvm@22.1.8)"); std::println(""); @@ -202,6 +203,15 @@ int run(int argc, char** argv) { else if (a.starts_with("--toolchain=")) mcpp::platform::env::set("MCPP_TOOLCHAIN", std::string(a.substr(12))); else if (a.starts_with("-j") && a.size() > 2) mcpp::platform::env::set("MCPP_JOBS", std::string(a.substr(2))); + // --play-game rides the same channel: its value is optional + // (`--play-game` chooses a game, `--play-game=snake` names one), which + // the parser's options do not express, and its consumer is the build's + // report deep in mcpp.build.progress (build output design revision 3, + // §5.14). The option is also declared on build, run and test below, so + // that --help lists it and the parser accepts both spellings. + else if (a == "--play-game") mcpp::platform::env::set("MCPP_PLAY_GAME", "random"); + else if (a.starts_with("--play-game=")) + mcpp::platform::env::set("MCPP_PLAY_GAME", std::string(a.substr(12))); } // Decline xlings' linker-wrapper path injection, for this process and // everything it spawns (openxlings/xlings#540). @@ -391,6 +401,8 @@ int run(int argc, char** argv) { .help("Treat manifest schema warnings (unknown feature/platform) as errors")) .option(cl::Option("workspace") .help("Build all workspace members")) + .option(cl::Option("play-game") + .help("Play a game in the status row while it builds: --play-game=snake|stack|runner, or one at random")) .action(wrap_rc(cmd_build))) .subcommand(cl::App("run") .description("Build + run a binary target (after `--`, args are passed to it)") @@ -486,6 +498,8 @@ int run(int argc, char** argv) { .help("Run the distributable a `--format ` pack would produce " "(same values as `mcpp pack --format`); refused together with " "--no-runner")) + .option(cl::Option("play-game") + .help("Play a game in the status row while it builds: --play-game=snake|stack|runner, or one at random")) .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { return cmd_run(p, std::span(passthrough)); }))) @@ -531,6 +545,8 @@ int run(int argc, char** argv) { .help("Deprecated alias for --cache=off (also clears the build dir)")) .option(cl::Option("workspace") .help("Run tests for all workspace members")) + .option(cl::Option("play-game") + .help("Play a game in the status row while it builds: --play-game=snake|stack|runner, or one at random")) .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { return cmd_test(p, std::span(passthrough)); }))) diff --git a/src/ui.cppm b/src/ui.cppm index 5a53aa98..3ae6f1e6 100644 --- a/src/ui.cppm +++ b/src/ui.cppm @@ -198,6 +198,9 @@ void touch_region(); bool region_live(); // The heartbeat interval of the log medium (tests shorten it). void set_heartbeat(std::chrono::milliseconds interval); +// The shortest interval between two frames: a tenth of a second, and a +// twentieth while a game plays (build output design revision 3, §5.14). +void set_frame_interval(std::chrono::milliseconds interval); // Erases the region for the lifetime of the object, and draws it again after: // for a child that writes to the terminal itself. @@ -564,9 +567,11 @@ void verbose_record(const mcpp::log::Record& record) { emit(term::Stream::Err, mcpp::log::verbose_line(record, g_color)); } +std::atomic g_frameIntervalMs{100}; + void tick(std::stop_token stop) { auto& t = ticker(); - constexpr auto kMinInterval = std::chrono::milliseconds(100); + const auto kMinInterval = std::chrono::milliseconds(g_frameIntervalMs.load()); auto lastTick = std::chrono::steady_clock::now() - kMinInterval; while (!stop.stop_requested()) { { @@ -1050,6 +1055,10 @@ bool region_live() { return region().open && region().live; } +void set_frame_interval(std::chrono::milliseconds interval) { + g_frameIntervalMs.store(std::max(20, interval.count())); +} + void set_heartbeat(std::chrono::milliseconds interval) { std::lock_guard line(line_mutex()); region().heartbeat = interval; diff --git a/src/ui/dots_screen/core.cppm b/src/ui/dots_screen/core.cppm index 6e285857..9b90dab3 100644 --- a/src/ui/dots_screen/core.cppm +++ b/src/ui/dots_screen/core.cppm @@ -66,6 +66,20 @@ public: virtual void draw(Screen& screen) const = 0; }; +// The keys a game reads (design §5.14). +enum class Key { Up, Down, Left, Right, Space }; + +// A GAME IS AN ANIMATION THE USER STEERS (`--play-game`, design §5.14). It +// runs at a speed of its own, not at the build's pace: `update` reads only the +// elapsed time and the failure; the counts beside the screen state the build. +// A round that ends starts again at once, and the best round is kept. +class Game : public Animation { +public: + virtual void key(Key k) = 0; + virtual int score() const = 0; // this round + virtual int best() const = 0; // the best round so far +}; + } // namespace mcpp::ui::dots_screen namespace mcpp::ui::dots_screen { diff --git a/src/ui/dots_screen/dots_screen.cppm b/src/ui/dots_screen/dots_screen.cppm index dfe13510..dbf8e86e 100644 --- a/src/ui/dots_screen/dots_screen.cppm +++ b/src/ui/dots_screen/dots_screen.cppm @@ -3,9 +3,11 @@ // // The counts beside the screen state the progress, so the screen plays one of // four animations, chosen per command: the chomper, the snake, the stack and -// the ions. Each lives in a partition of its own; this unit knows them by -// name. The progress model feeds the chosen animation and asks it for its -// cells; nothing here touches a terminal or a clock. +// the ions. With `--play-game` it plays one of three games instead: the +// snake, the stack and the runner, steered from the keyboard. Each lives in a +// partition of its own; this unit knows them by name. The progress model +// feeds the chosen one and asks it for its cells; nothing here touches a +// terminal or a clock. export module mcpp.ui.dots_screen; @@ -14,6 +16,9 @@ export import :chomp; export import :snake; export import :stack; export import :ions; +export import :snake_game; +export import :stack_game; +export import :runner_game; import std; @@ -24,12 +29,18 @@ std::span names(); // The animation called `name`, seeded; nullptr for a name not in `names()`. std::unique_ptr make(std::string_view name, std::uint64_t seed); +// The games of `--play-game`: snake, stack, runner. +std::span game_names(); +// The game called `name`, seeded; nullptr for a name not in `game_names()`. +std::unique_ptr make_game(std::string_view name, std::uint64_t seed); + } // namespace mcpp::ui::dots_screen namespace mcpp::ui::dots_screen { namespace { constexpr std::array kNames = {"chomp", "snake", "stack", "ions"}; +constexpr std::array kGames = {"snake", "stack", "runner"}; } // namespace std::span names() { return kNames; } @@ -42,4 +53,13 @@ std::unique_ptr make(std::string_view name, std::uint64_t seed) { return nullptr; } +std::span game_names() { return kGames; } + +std::unique_ptr make_game(std::string_view name, std::uint64_t seed) { + if (name == "snake") return make_snake_game(seed); + if (name == "stack") return make_stack_game(seed); + if (name == "runner") return make_runner_game(seed); + return nullptr; +} + } // namespace mcpp::ui::dots_screen diff --git a/src/ui/dots_screen/runner_game.cppm b/src/ui/dots_screen/runner_game.cppm new file mode 100644 index 00000000..f7c56c50 --- /dev/null +++ b/src/ui/dots_screen/runner_game.cppm @@ -0,0 +1,95 @@ +// mcpp.ui.dots_screen:runner_game — the runner that jumps the cacti +// (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.14). +// +// Cacti come from the right, faster as the score rises; space or up jumps. +// Each cactus cleared scores one; touching one ends the round. + +export module mcpp.ui.dots_screen:runner_game; + +import std; +import :core; + +namespace mcpp::ui::dots_screen { + +class RunnerGame final : public Game { +public: + explicit RunnerGame(std::uint64_t seed) : rnd_(seed) { reset(); } + + void key(Key k) override { + if ((k == Key::Space || k == Key::Up) && y_ == 0 && dead_ <= 0) vy_ = 14.0; + } + + void update(const Input& in) override { + t_ += in.dt; + if (dead_ > 0) { + dead_ -= in.dt; + if (dead_ <= 0) reset(); + return; + } + const double speed = 14.0 + score_ * 0.6; // columns a second + for (auto& c : cacti_) c.x -= speed * in.dt; + while (!cacti_.empty() && cacti_.front().x < -1) { + cacti_.pop_front(); + ++score_; + best_ = std::max(best_, score_); + } + if (cacti_.empty() || cacti_.back().x < kWidth - gap_) { + cacti_.push_back({static_cast(kWidth), + std::uniform_int_distribution(1, 2)(rnd_)}); + gap_ = std::uniform_int_distribution(14, 26)(rnd_); + } + y_ = std::max(0.0, y_ + vy_ * in.dt); + vy_ -= 60.0 * in.dt; + if (y_ == 0) vy_ = std::max(vy_, 0.0); + // The runner's feet are on row 3 minus its height; a cactus of height + // h fills rows 4 - h to 3 at its column. + for (auto const& c : cacti_) { + const int cx = static_cast(std::lround(c.x)); + if (cx < kX || cx > kX + 3) continue; + if (std::lround(y_) < c.height) { + best_ = std::max(best_, score_); + dead_ = 1.0; + break; + } + } + } + + void draw(Screen& sc) const override { + for (int x = 0; x < kWidth; x += 3) sc.set(x, 3, Colour::Grey); + for (auto const& c : cacti_) + for (int h = 0; h < c.height; ++h) sc.set(c.x, 3 - h, Colour::Green); + static constexpr std::array a = {"..XX", "XXX.", ".X.X"}; + static constexpr std::array b = {"..XX", "XXX.", "X.X."}; + const auto& body = (y_ == 0 && static_cast(t_ * 8) % 2) ? b : a; + const double top = 1 - std::round(y_); + for (std::size_t dy = 0; dy < body.size(); ++dy) + for (std::size_t dx = 0; dx < body[dy].size(); ++dx) + if (body[dy][dx] == 'X') + sc.set(kX + static_cast(dx), top + static_cast(dy), + dead_ > 0 ? Colour::Red : Colour::White); + } + + int score() const override { return score_; } + int best() const override { return best_; } + +private: + struct Cactus { double x; int height; }; + static constexpr int kX = 3; + + void reset() { + cacti_.clear(); + y_ = vy_ = 0; + score_ = 0; + dead_ = 0; + gap_ = 20; + } + + std::mt19937_64 rnd_; + std::deque cacti_; + double t_ = 0, y_ = 0, vy_ = 0, dead_ = 0; + int gap_ = 20, score_ = 0, best_ = 0; +}; + +std::unique_ptr make_runner_game(std::uint64_t seed) { return std::make_unique(seed); } + +} // namespace mcpp::ui::dots_screen diff --git a/src/ui/dots_screen/snake_game.cppm b/src/ui/dots_screen/snake_game.cppm new file mode 100644 index 00000000..fba373a2 --- /dev/null +++ b/src/ui/dots_screen/snake_game.cppm @@ -0,0 +1,123 @@ +// mcpp.ui.dots_screen:snake_game — the snake, steered with the arrows +// (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.14). +// +// Eight cells a second on the 48 x 4 screen, wrapping left and right and +// walled above and below. A package that starts still drops a food in its +// source's colour, and the segments grown from a meal keep that colour. Hitting +// a wall or the body ends the round; the screen turns red for a second and a +// new round starts. + +export module mcpp.ui.dots_screen:snake_game; + +import std; +import :core; + +namespace mcpp::ui::dots_screen { + +class SnakeGame final : public Game { +public: + explicit SnakeGame(std::uint64_t seed) : rnd_(seed) { reset(); } + + void package(Source s) override { meals_.push_back({place(), colour_of(s)}); } + + void key(Key k) override { + const Cell want = k == Key::Up ? Cell{0, -1} : k == Key::Down ? Cell{0, 1} + : k == Key::Left ? Cell{-1, 0} : k == Key::Right ? Cell{1, 0} : dir_; + if (want.first != -dir_.first || want.second != -dir_.second) next_ = want; + } + + void update(const Input& in) override { + if (dead_ > 0) { + dead_ -= in.dt; + if (dead_ <= 0) reset(); + return; + } + acc_ += in.dt; + while (acc_ >= kPeriod && dead_ <= 0) { + acc_ -= kPeriod; + step(); + } + } + + void draw(Screen& sc) const override { + sc.set(food_.first, food_.second, Colour::Grey); + for (auto const& [cell, colour] : meals_) sc.set(cell.first, cell.second, colour); + for (std::size_t i = 0; i < body_.size(); ++i) { + const Colour c = dead_ > 0 ? Colour::Red + : i == 0 ? Colour::BrightGreen + : i < colours_.size() ? colours_[i] : Colour::Green; + sc.set(body_[i].first, body_[i].second, c); + } + } + + int score() const override { return score_; } + int best() const override { return best_; } + +private: + using Cell = std::pair; + static constexpr double kPeriod = 0.125; // eight cells a second + + void reset() { + body_.clear(); + colours_.clear(); + for (int x = 6; x >= 3; --x) { body_.push_back({x, 1}); colours_.push_back(Colour::Green); } + dir_ = next_ = {1, 0}; + score_ = 0; + dead_ = 0; + acc_ = 0; + food_ = place(); + } + + Cell place() { + std::vector free; + for (int x = 0; x < kWidth; ++x) + for (int y = 0; y < kHeight; ++y) + if (std::ranges::find(body_, Cell{x, y}) == body_.end()) free.push_back({x, y}); + if (free.empty()) return {0, 0}; + return free[std::uniform_int_distribution(0, free.size() - 1)(rnd_)]; + } + + void step() { + dir_ = next_; + const Cell head{(body_.front().first + dir_.first + kWidth) % kWidth, + body_.front().second + dir_.second}; + const bool wall = head.second < 0 || head.second >= kHeight; + const bool bite = std::ranges::find(body_.begin(), body_.end() - 1, head) != body_.end() - 1; + if (wall || bite) { + best_ = std::max(best_, score_); + dead_ = 1.0; + return; + } + body_.push_front(head); + Colour grown = colours_.empty() ? Colour::Green : colours_.front(); + bool ate = false; + if (!meals_.empty() && head == meals_.front().first) { + grown = meals_.front().second; + meals_.pop_front(); + ate = true; + } else if (head == food_) { + food_ = place(); + ate = true; + } + colours_.push_front(grown); + if (ate) { + ++score_; + best_ = std::max(best_, score_); + } else { + body_.pop_back(); + colours_.pop_back(); + } + } + + std::mt19937_64 rnd_; + std::deque body_; + std::deque colours_; + std::deque> meals_; + Cell food_{0, 0}, dir_{1, 0}, next_{1, 0}; + double acc_ = 0, dead_ = 0; + int score_ = 0, best_ = 0; +}; + +std::unique_ptr make_snake_game(std::uint64_t seed) { return std::make_unique(seed); } + +} // namespace mcpp::ui::dots_screen diff --git a/src/ui/dots_screen/stack_game.cppm b/src/ui/dots_screen/stack_game.cppm new file mode 100644 index 00000000..0830fb26 --- /dev/null +++ b/src/ui/dots_screen/stack_game.cppm @@ -0,0 +1,139 @@ +// mcpp.ui.dots_screen:stack_game — Tetris on its side, played +// (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.14). +// +// Gravity pulls to the left. A piece enters at the right and falls one column +// every half second, faster as the score rises. Up and down move it between +// the four rows, left drops it, right or space turns it. A column filled from +// top to bottom clears, as a filled row does in the original, and scores one. +// A piece that cannot enter ends the round. + +export module mcpp.ui.dots_screen:stack_game; + +import std; +import :core; + +namespace mcpp::ui::dots_screen { + +class StackGame final : public Game { +public: + explicit StackGame(std::uint64_t seed) : rnd_(seed) { spawn(); } + + void key(Key k) override { + if (dead_ > 0 || !piece_) return; + auto& p = *piece_; + switch (k) { + case Key::Up: if (fits(p.rot, p.x, p.y - 1)) --p.y; break; + case Key::Down: if (fits(p.rot, p.x, p.y + 1)) ++p.y; break; + case Key::Left: while (fits(p.rot, p.x - 1, p.y)) --p.x; lock(); break; + case Key::Right: + case Key::Space: { + const auto next = (p.rot + 1) % kinds()[p.kind].size(); + for (int dy : {0, -1, 1, -2}) // turn, nudged back onto the board + if (fits(next, p.x, p.y + dy)) { p.rot = next; p.y += dy; break; } + break; + } + } + } + + void update(const Input& in) override { + if (dead_ > 0) { + dead_ -= in.dt; + if (dead_ <= 0) { cells_.clear(); score_ = 0; spawn(); } + return; + } + acc_ += in.dt; + const double fall = std::max(0.1, 0.5 - 0.03 * score_); + while (acc_ >= fall && piece_) { + acc_ -= fall; + if (fits(piece_->rot, piece_->x - 1, piece_->y)) --piece_->x; + else lock(); + } + } + + void draw(Screen& sc) const override { + for (auto const& [cell, colour] : cells_) + sc.set(cell.first, cell.second, dead_ > 0 ? Colour::Red : colour); + if (piece_) + for (auto [dx, dy] : kinds()[piece_->kind][piece_->rot]) + sc.set(piece_->x + dx, piece_->y + dy, colours()[piece_->kind]); + } + + int score() const override { return score_; } + int best() const override { return best_; } + +private: + using Cell = std::pair; + using Shape = std::vector; + struct Piece { std::size_t kind, rot; int x, y; }; + + static const std::vector>& kinds() { + static const std::vector> k = { + {{{0,0},{1,0},{2,0},{3,0}}, {{0,0},{0,1},{0,2},{0,3}}}, + {{{0,0},{1,0},{0,1},{1,1}}}, + {{{0,0},{1,0},{2,0},{1,1}}, {{0,0},{0,1},{0,2},{1,1}}, + {{1,0},{0,1},{1,1},{2,1}}, {{1,0},{1,1},{1,2},{0,1}}}, + {{{1,0},{2,0},{0,1},{1,1}}, {{0,0},{0,1},{1,1},{1,2}}}, + {{{0,0},{1,0},{1,1},{2,1}}, {{1,0},{1,1},{0,1},{0,2}}}, + {{{0,0},{0,1},{0,2},{1,2}}, {{0,0},{1,0},{2,0},{0,1}}, + {{0,0},{1,0},{1,1},{1,2}}, {{2,0},{0,1},{1,1},{2,1}}}, + {{{1,0},{1,1},{1,2},{0,2}}, {{0,0},{0,1},{1,1},{2,1}}, + {{0,0},{1,0},{0,1},{0,2}}, {{0,0},{1,0},{2,0},{2,1}}}, + }; + return k; + } + static const std::vector& colours() { + static const std::vector c = {Colour::BrightCyan, Colour::Yellow, Colour::Magenta, + Colour::BrightGreen, Colour::Red, Colour::White, Colour::Blue}; + return c; + } + + bool fits(std::size_t rot, int x, int y) const { + for (auto [dx, dy] : kinds()[piece_->kind][rot]) { + const int cx = x + dx, cy = y + dy; + if (cx < 0 || cx >= kWidth || cy < 0 || cy >= kHeight) return false; + if (cells_.contains({cx, cy})) return false; + } + return true; + } + + void spawn() { + const auto kind = std::uniform_int_distribution(0, kinds().size() - 1)(rnd_); + piece_ = Piece{kind, 0, kWidth - 4, 1}; + if (!fits(0, piece_->x, piece_->y)) piece_->y = 0; + if (!fits(0, piece_->x, piece_->y)) { + best_ = std::max(best_, score_); + dead_ = 1.0; + } + } + + void lock() { + if (!piece_) return; + for (auto [dx, dy] : kinds()[piece_->kind][piece_->rot]) + cells_[{piece_->x + dx, piece_->y + dy}] = colours()[piece_->kind]; + // Full columns clear; the columns to their right close the gap. + for (int x = kWidth - 1; x >= 0; --x) { + bool full = true; + for (int y = 0; y < kHeight; ++y) full = full && cells_.contains({x, y}); + if (!full) continue; + std::map moved; + for (auto const& [cell, colour] : cells_) { + if (cell.first == x) continue; + moved[{cell.first > x ? cell.first - 1 : cell.first, cell.second}] = colour; + } + cells_ = std::move(moved); + ++score_; + } + best_ = std::max(best_, score_); + spawn(); + } + + std::mt19937_64 rnd_; + std::map cells_; + std::optional piece_; + double acc_ = 0, dead_ = 0; + int score_ = 0, best_ = 0; +}; + +std::unique_ptr make_stack_game(std::uint64_t seed) { return std::make_unique(seed); } + +} // namespace mcpp::ui::dots_screen diff --git a/tests/e2e/139_build_program_advisory.sh b/tests/e2e/139_build_program_advisory.sh index d4d5e538..69d4e16c 100755 --- a/tests/e2e/139_build_program_advisory.sh +++ b/tests/e2e/139_build_program_advisory.sh @@ -86,7 +86,8 @@ grep -qi "Finished" first.log \ # reaches nothing and prints nothing — including this. It is not asserted here, # because it is a fact about the fast path rather than about this feature. touch src/main.cpp -"$MCPP" build > second.log 2>&1 +# A reused program is stated under --verbose only (build output design revision 3, §7.1). +"$MCPP" build -v > second.log 2>&1 # First establish that this build really was a cache hit. Without this the next # assertion could pass for the wrong reason — a re-run would also print the diff --git a/tests/e2e/163_identity_first_resolution.sh b/tests/e2e/163_identity_first_resolution.sh index 4c8b4be6..2c470fd3 100755 --- a/tests/e2e/163_identity_first_resolution.sh +++ b/tests/e2e/163_identity_first_resolution.sh @@ -86,7 +86,7 @@ mkapp app1 acme ../idx1 acme widget # compiled here or served from the global build cache is a different subsystem's # business — and now that the cache actually works, a sibling app dir under the # same MCPP_HOME (or a restored CI sandbox) can legitimately supply them. -grep -qE "Compiling acme\.widget" app1/out.txt || { +grep -qE "(Compiling|Cached) acme\.widget" app1/out.txt || { cat app1/out.txt echo "FAIL: acme.widget was neither compiled nor served from cache" exit 1 diff --git a/tests/e2e/172_build_cache_cross_project.sh b/tests/e2e/172_build_cache_cross_project.sh index 0d6467d3..097da124 100644 --- a/tests/e2e/172_build_cache_cross_project.sh +++ b/tests/e2e/172_build_cache_cross_project.sh @@ -146,7 +146,7 @@ staged="$(dep_stage_edges "$N2")" # The status line must agree, and must carry the unit count. The bare word # "Cached" was printed for months while every unit was recompiled behind it; a # number that has to match the skipped edges cannot go quietly wrong that way. -grep -qE 'Compiling local-dev\.shared-lib v1\.0\.0 +cached [0-9]+ unit' build.log || { +grep -qE 'Cached local-dev\.shared-lib v1\.0\.0 .*\([0-9]+ units?\)' build.log || { echo "FAIL: no 'cached N units' line for the reused dependency" cat build.log exit 1 diff --git a/tests/e2e/186_build_mcpp_protocol_and_bound.sh b/tests/e2e/186_build_mcpp_protocol_and_bound.sh index ed6147fc..a506d1e3 100755 --- a/tests/e2e/186_build_mcpp_protocol_and_bound.sh +++ b/tests/e2e/186_build_mcpp_protocol_and_bound.sh @@ -174,7 +174,8 @@ cp "$CACHE" "$TMP/good.cache" # Touching a source defeats the whole-project fast path so prepare (and with it # the build.mcpp cache) actually runs. touch src/main.cpp -"$MCPP" build > b6.log 2>&1 || { cat b6.log; echo "FAIL: build failed"; exit 1; } +# A reused program is stated under --verbose only (build output design revision 3, §7.1). +"$MCPP" build -v > b6.log 2>&1 || { cat b6.log; echo "FAIL: build failed"; exit 1; } grep -qE "^ *build\.mcpp .* cached" b6.log || { cat b6.log; echo "FAIL: an unchanged build.mcpp was re-run"; exit 1; } diff --git a/tests/e2e/196_version_identity_and_lock.sh b/tests/e2e/196_version_identity_and_lock.sh index 61b3bcfb..be7c3bf5 100755 --- a/tests/e2e/196_version_identity_and_lock.sh +++ b/tests/e2e/196_version_identity_and_lock.sh @@ -164,7 +164,7 @@ grep -q '\^' mcpp.lock \ # The banner and the lock read the same data, so they cannot disagree. (The dep # announces itself as Compiling or Cached depending on the build cache; both go # through the same version string, which is the point.) -grep -qE 'Compiling +acme\.im v1\.92\.8' b7.log \ +grep -qE '(Compiling|Cached) +acme\.im v1\.92\.8' b7.log \ || fail "the dependency banner must announce the resolved version" b7.log grep -q 'acme\.im v\^' b7.log \ && fail "the banner must never print a constraint as a version" b7.log diff --git a/tests/e2e/19_bmi_cache_reuse.sh b/tests/e2e/19_bmi_cache_reuse.sh index 0bf8709d..e67db246 100755 --- a/tests/e2e/19_bmi_cache_reuse.sh +++ b/tests/e2e/19_bmi_cache_reuse.sh @@ -85,8 +85,8 @@ if find "$MCPP_HOME/build-cache/v1/pkg" -path "*mylibA*" 2>/dev/null | grep -q . fi # Build output must NOT state that mylibA came from the cache (it's a path -# dep, not a registry dep). `-v` lists the dependencies the default folds. -if grep -qE 'Compiling mylibA .*cached [0-9]+ unit' build.log; then +# dep, not a registry dep): no `Cached mylibA` line. +if grep -qE 'Cached mylibA|Compiling mylibA .*cached [0-9]+ unit' build.log; then echo "FAIL: path dep wrongly labeled Cached" cat build.log; exit 1 fi diff --git a/tests/e2e/212_cached_dep_std_is_ordered.sh b/tests/e2e/212_cached_dep_std_is_ordered.sh index 894bee5d..6921003a 100755 --- a/tests/e2e/212_cached_dep_std_is_ordered.sh +++ b/tests/e2e/212_cached_dep_std_is_ordered.sh @@ -127,7 +127,7 @@ N="$(find_ninja "$TMP/projhit")" [[ -n "$N" ]] || { echo "FAIL: projhit has no build.ninja"; exit 1; } # The hit actually happened — otherwise the assertion below proves nothing. -grep -qE 'Compiling local-dev\.stdlib-dep v1\.0\.0 +cached [0-9]+ unit' build.log || { +grep -qE 'Cached local-dev\.stdlib-dep v1\.0\.0 .*\([0-9]+ units?\)' build.log || { echo "FAIL: the second project did not hit the cache, so #405 was not exercised" cat build.log exit 1 diff --git a/tests/e2e/40_llvm_bmi_cache.sh b/tests/e2e/40_llvm_bmi_cache.sh index c1784893..8176dfa4 100755 --- a/tests/e2e/40_llvm_bmi_cache.sh +++ b/tests/e2e/40_llvm_bmi_cache.sh @@ -73,7 +73,7 @@ echo "$out1" | grep -q "Compiling.*mcpplibs.cmdline" || { # Second build, clean target dir, cache kept — the dependency must be reused. rm -rf target out2=$("$MCPP" build -v 2>&1) -echo "$out2" | grep -qE "Compiling .*mcpplibs\.cmdline.* cached [0-9]+ unit" || { +echo "$out2" | grep -qE "Cached mcpplibs\.cmdline v[0-9.]+ \([0-9]+ units?\)" || { echo "FAIL: mcpplibs.cmdline not cached on second build: $out2" exit 1 } diff --git a/tests/e2e/49_bmi_cache_nested_custom_index.sh b/tests/e2e/49_bmi_cache_nested_custom_index.sh index d7fe91e8..439f7bef 100644 --- a/tests/e2e/49_bmi_cache_nested_custom_index.sh +++ b/tests/e2e/49_bmi_cache_nested_custom_index.sh @@ -122,7 +122,7 @@ PYEOF rm -rf target "$MCPP" build -v > build2.log 2>&1 || { cat build2.log; exit 1; } -grep -qE "Compiling local-dev\.collision-lib v1\.0\.0 +cached [0-9]+ unit" build2.log || { +grep -qE "Cached local-dev\.collision-lib v1\.0\.0 .*\([0-9]+ units?\)" build2.log || { echo "FAIL: second cold build did not reuse the build cache" cat build2.log exit 1 diff --git a/tests/e2e/53_namespaced_cache_label.sh b/tests/e2e/53_namespaced_cache_label.sh index 1011bc5e..851bd3f7 100755 --- a/tests/e2e/53_namespaced_cache_label.sh +++ b/tests/e2e/53_namespaced_cache_label.sh @@ -103,7 +103,7 @@ rm -rf target exit 1 } -grep -qE "Compiling compat\.widget v1\.0\.0 +cached [0-9]+ unit" build2.log || { +grep -qE "Cached compat\.widget v1\.0\.0 .*\([0-9]+ units?\)" build2.log || { echo "FAIL: cached namespaced dependency should be reported as compat.widget" cat build2.log exit 1 diff --git a/tests/e2e/651_a_build_program_deploys_what_it_generated.sh b/tests/e2e/651_a_build_program_deploys_what_it_generated.sh index 8e55ec44..42d0fce8 100755 --- a/tests/e2e/651_a_build_program_deploys_what_it_generated.sh +++ b/tests/e2e/651_a_build_program_deploys_what_it_generated.sh @@ -147,7 +147,8 @@ grep -qx "changed resource" "$DEPLOYED" \ # ── 4. the replay criterion: bin/ deleted, rebuilt on a build.mcpp cache hit rm -rf "$BINDIR" touch src/main.cpp -"$MCPP" build > b3.log 2>&1 || fail "third build failed" b3.log +# A reused program is stated under --verbose only (build output design revision 3, §7.1). +"$MCPP" build -v > b3.log 2>&1 || fail "third build failed" b3.log grep -qE "^ *build\.mcpp .* cached" b3.log \ || fail "the third build re-ran build.mcpp; the replay path was not exercised" b3.log [ -f "$DEPLOYED" ] \ diff --git a/tests/e2e/713_a_git_dependency_selects_a_repository_member.sh b/tests/e2e/713_a_git_dependency_selects_a_repository_member.sh index 274bc1ad..940266b1 100755 --- a/tests/e2e/713_a_git_dependency_selects_a_repository_member.sh +++ b/tests/e2e/713_a_git_dependency_selects_a_repository_member.sh @@ -137,7 +137,7 @@ grep -q "declared identity is used" b2.log \ && fail "the member's key adopted the root's identity" b2.log grep -q 'DEPBIN tool=\[[^]]' b2.log || fail "the member's tools request was lost" b2.log grep -qE "Compiling spike\.fw v( |$)" b2.log && fail "the git banner printed an empty version" b2.log -grep -q "spike.fw (git rev ${REV:0:12})" b2.log \ +grep -qE "spike\.fw v[^ ]+ \(git rev ${REV:0:12}\)" b2.log \ || fail "the git banner does not name the reference" b2.log "$MCPP" run > r2.log 2>&1 || fail "the application over the git root does not run" r2.log diff --git a/tests/e2e/779_a_build_program_declares_a_runtime_search_dir.sh b/tests/e2e/779_a_build_program_declares_a_runtime_search_dir.sh index c876fe94..e29a93fd 100755 --- a/tests/e2e/779_a_build_program_declares_a_runtime_search_dir.sh +++ b/tests/e2e/779_a_build_program_declares_a_runtime_search_dir.sh @@ -118,7 +118,8 @@ grep -F -- "-Wl,-rpath,$RTDIR" "$G" >/dev/null \ # profile `mcpp build` and `mcpp run` already used. touch src/main.cpp # past the whole-project no-op fast path, without # touching build.mcpp itself -"$MCPP" build > b2.log 2>&1 || fail "second build failed" b2.log +# A reused program is stated under --verbose only (build output design revision 3, §7.1). +"$MCPP" build -v > b2.log 2>&1 || fail "second build failed" b2.log grep -qE "^ *build\.mcpp .* cached" b2.log \ || fail "the second build re-ran build.mcpp; the replay path was not exercised" b2.log G2=$(find_graph) diff --git a/tests/e2e/826_plugin_diagnostics_features_and_names.sh b/tests/e2e/826_plugin_diagnostics_features_and_names.sh index 8d0950df..482b2223 100755 --- a/tests/e2e/826_plugin_diagnostics_features_and_names.sh +++ b/tests/e2e/826_plugin_diagnostics_features_and_names.sh @@ -69,7 +69,8 @@ grep -q "mcpp.acme.gen'.*mcpp\.\|claims an origin" d1.log && fail "R1: an own-na # D2. `touch` first, as e2e 139 does: an unmodified build takes the project # fast path and reaches no build program, so it would measure neither path. touch src/main.cpp -"$MCPP" build > d2.log 2>&1 || fail "D2: the second build failed" d2.log +# A reused program is stated under --verbose only (build output design revision 3, §7.1). +"$MCPP" build -v > d2.log 2>&1 || fail "D2: the second build failed" d2.log grep -qE "^ *build\.mcpp .* cached" d2.log || fail "D2: the second build ran the program; the replay is not measured" d2.log grep -q "impact: no bindings are generated" d2.log || fail "D2: the cached run did not report the diagnostic" d2.log diff --git a/tests/e2e/839_a_workspaces_build_programs_are_named_ordered_and_cached.sh b/tests/e2e/839_a_workspaces_build_programs_are_named_ordered_and_cached.sh index 5e71b5b6..5a6bacef 100644 --- a/tests/e2e/839_a_workspaces_build_programs_are_named_ordered_and_cached.sh +++ b/tests/e2e/839_a_workspaces_build_programs_are_named_ordered_and_cached.sh @@ -14,9 +14,9 @@ # requester of the plan, the virtual root included, so its cache key # followed the selection); # B4 `mcpp emit build-database` after the build reuses them too; -# B5 in a `--workspace` build a member another member depends on is -# named by its directory, and as a path dependency only where it is not -# selected (`-v` lists the dependencies the default output folds). +# B5 a member another member depends on is named by its short name, +# version and directory, whether it is selected or reached as cli's path +# dependency under `-p cli` (build output design revision 3, §5.8). set -e TMP=$(mktemp -d) @@ -70,11 +70,12 @@ first=$(grep -m1 -E "^ *build\.mcpp .* ran [0-9]" b1.log) case "$first" in *"build.mcpp core "*) ;; *) fail "B2: core's program did not run first" b1.log ;; esac # B3 +# A reused program has a line under --verbose only (revision 3, §7.1). "$MCPP" build -p cli -v > b2.log 2>&1 || fail "-p cli failed" b2.log touch core/src/core.cppm -"$MCPP" build --workspace > b3.log 2>&1 || fail "the second --workspace failed" b3.log +"$MCPP" build --workspace -v > b3.log 2>&1 || fail "the second --workspace failed" b3.log touch core/src/core.cppm -"$MCPP" build -p gui > b4.log 2>&1 || fail "-p gui failed" b4.log +"$MCPP" build -p gui -v > b4.log 2>&1 || fail "-p gui failed" b4.log for f in b2.log b3.log b4.log; do ! grep -qE "^ *build\.mcpp .* ran [0-9]" $f || fail "B3: a program reran in $f" $f grep -qE "^ *build\.mcpp .* cached" $f || fail "B3: $f shows no program at all" $f @@ -85,8 +86,8 @@ done ! grep -qE "^ *build\.mcpp .* ran [0-9]" e.log || fail "B4: emit reran a program" e.log # B5 -grep -q "Compiling core (core)" b1.log || fail "B5: core is not announced by its directory" b1.log -! grep -q "Compiling core (path)" b1.log || fail "B5: a selected member is announced as a path dependency" b1.log -grep -q "Compiling core (path)" b2.log || fail "B5: under -p cli, core is cli's path dependency" b2.log +grep -qxF " Compiling core v0.1.0 (core)" b1.log || fail "B5: core is not named by its directory" b1.log +grep -qE "^ +(Compiling|Fresh) core v0\.1\.0 \(core\)$" b2.log || fail "B5: under -p cli, core is not named by its directory" b2.log +! grep -q "(path)" b1.log b2.log || fail "B5: a package is named by its source kind, not its directory" b1.log b2.log echo "PASS: 839_a_workspaces_build_programs_are_named_ordered_and_cached" diff --git a/tests/e2e/842_the_build_reports_each_step_once.sh b/tests/e2e/842_the_build_reports_each_step_once.sh index da458e6a..ac4e61b8 100755 --- a/tests/e2e/842_the_build_reports_each_step_once.sh +++ b/tests/e2e/842_the_build_reports_each_step_once.sh @@ -1,22 +1,27 @@ #!/usr/bin/env bash # requires: python3 # 842_the_build_reports_each_step_once.sh -- build progress design 2026-09-29, -# §4 and §5.2 (the log medium: the output is not a terminal). +# §5.2 (the log medium: the output is not a terminal), as revised by the build +# output design 2026-09-30 (revision 3), §5.3 and §7. # # A workspace whose members are core and cli, where cli also depends on util, # a path package outside the workspace: # -# R1 a first build: each member's line is written with its outcome -# (`done `), util is folded into `Compiling 1 dependency`, and one -# blank line precedes `Finished`; nothing redraws (no carriage return, -# no escape sequence); -# R2 a build program's line states `ran` on the first build and `cached` -# on a planned build that changed nothing it reads; -# R3 an edit to cli lists cli alone; -# R4 `--verbose` names util and prints each step as `[f/t] `; +# R1 a first build names each package once, when it does work, in the +# order its work finished: a member by its short name, version and +# directory, util by its name, version and relative directory, no +# outcome and no fold; one blank line precedes `Finished`; nothing +# redraws (no carriage return, no escape sequence); +# R2 a build program's line states `ran` on the first build; a planned +# build that changed nothing it reads reuses it, which only --verbose +# states (`cached`); +# R3 an edit to cli names cli alone; +# R4 an edit to util names util and cli, util first; `--verbose` prints +# each step as `[f/t] `, names core as `Fresh`, and states the +# steps of each package that did work; # R5 a failed step is reported while a slow `prepare` action of another -# package still runs: `error: build failed` arrives seconds before -# mcpp exits, not after ninja. +# package still runs: `error: build failed in app v0.1.0 (.)` arrives +# seconds before mcpp exits, not after ninja. set -e TMP=$(mktemp -d) @@ -54,10 +59,13 @@ cd ws "$MCPP" build --workspace > b1.log 2>&1 || fail "the first build failed" b1.log # R1 -grep -qE "^ +Compiling core \(core\) +done [0-9]" b1.log || fail "R1: core has no final line" b1.log -grep -qE "^ +Compiling cli \(cli\) +done [0-9]" b1.log || fail "R1: cli has no final line" b1.log -grep -qE "^ +Compiling 1 dependency +done" b1.log || fail "R1: util is not folded into one line" b1.log -! grep -q "Compiling util" b1.log || fail "R1: the default output named a dependency" b1.log +for line in "core v0.1.0 (core)" "cli v0.1.0 (cli)" "util v0.2.0 (../util)"; do + [ "$(grep -cxF " Compiling $line" b1.log)" = 1 ] || fail "R1: '$line' is not named exactly once" b1.log +done +core_at=$(grep -nF "Compiling core v0.1.0" b1.log | cut -d: -f1) +cli_at=$(grep -nF "Compiling cli v0.1.0" b1.log | cut -d: -f1) +[ "$core_at" -lt "$cli_at" ] || fail "R1: cli is named before core, which it imports" b1.log +! grep -qE "Compiling [0-9]+ dependenc|done [0-9]|mcpplibs\.util" b1.log || fail "R1: a fold, an outcome or a namespace nobody wrote" b1.log prev=$(grep -B1 -E "^ +Finished " b1.log | head -1) [ -z "$prev" ] || fail "R1: no blank line precedes Finished" b1.log ! grep -q $'\r' b1.log || fail "R1: the log holds a carriage return" b1.log @@ -66,19 +74,24 @@ prev=$(grep -B1 -E "^ +Finished " b1.log | head -1) # R2 grep -qE "^ *build\.mcpp cli +ran [0-9]" b1.log || fail "R2: the program's line does not state that it ran" b1.log "$MCPP" build --workspace --profile dev > b2.log 2>&1 || fail "the planned rebuild failed" b2.log -grep -qE "^ *build\.mcpp cli +cached" b2.log || fail "R2: the second program line does not state cached" b2.log +! grep -q "build.mcpp" b2.log || fail "R2: a reused program has a line without --verbose" b2.log +"$MCPP" build --workspace --profile dev -v > b2v.log 2>&1 || fail "the verbose rebuild failed" b2v.log +grep -qE "^ *build\.mcpp cli +cached" b2v.log || fail "R2: --verbose does not state the reused program" b2v.log # R3 printf 'import rp_core;\nimport rp_util;\nint main() { return core_v() + util_v() == 3 ? 0 : 2; }\n' > cli/src/main.cpp "$MCPP" build --workspace > b3.log 2>&1 || fail "the incremental build failed" b3.log -grep -qE "^ +Compiling cli \(cli\) +done" b3.log || fail "R3: the edited member has no line" b3.log -! grep -qE "Compiling (core|1 dependency)" b3.log || fail "R3: a package with nothing to do has a line" b3.log +grep -qxF " Compiling cli v0.1.0 (cli)" b3.log || fail "R3: the edited member has no line" b3.log +! grep -qE "Compiling (core|util)" b3.log || fail "R3: a package with nothing to do has a line" b3.log # R4 printf 'export module rp_util;\nexport int util_v() { return 2; }\nint unused = 0;\n' > ../util/src/util.cppm "$MCPP" build --workspace -v > b4.log 2>&1 || fail "the verbose build failed" b4.log -grep -qE "^ +Compiling util \(path\) +done" b4.log || fail "R4: --verbose does not name the dependency" b4.log +grep -qxF " Compiling util v0.2.0 (../util)" b4.log || fail "R4: the edited dependency is not named" b4.log grep -qE "^\[[0-9]+/[0-9]+\] " b4.log || fail "R4: --verbose prints no step" b4.log +grep -qxF " Fresh core v0.1.0 (core)" b4.log || fail "R4: --verbose does not name core as fresh" b4.log +grep -qE "^ +Compiled util v0\.2\.0 \(\.\./util\) · [0-9]+ steps? · [0-9]" b4.log \ + || fail "R4: --verbose does not state util's steps and span" b4.log # R5 -- a separate project: app's source does not compile, and its path # dependency `slow` has a prepare action that takes six seconds. @@ -126,7 +139,7 @@ first_error = None lines = [] for line in p.stdout: lines.append(line.rstrip("\n")) - if first_error is None and line.startswith("error: build failed"): + if first_error is None and line.startswith("error: build failed in app v0.1.0 (.)"): first_error = time.monotonic() - start code = p.wait() end = time.monotonic() - start diff --git a/tests/e2e/843_a_terminal_names_the_action_that_runs.sh b/tests/e2e/843_a_terminal_names_the_action_that_runs.sh index c8cd67aa..3a8d49a8 100755 --- a/tests/e2e/843_a_terminal_names_the_action_that_runs.sh +++ b/tests/e2e/843_a_terminal_names_the_action_that_runs.sh @@ -1,14 +1,18 @@ #!/usr/bin/env bash # requires: python3 unix-shell # 843_a_terminal_names_the_action_that_runs.sh -- build progress design -# 2026-09-29, §4.4 and §5.1 (the terminal medium), through a pseudo-terminal. +# 2026-09-29, §4.4 and §5.1 (the terminal medium), through a pseudo-terminal, +# as revised by the build output design 2026-09-30 (revision 3), §5.9 and §9. # -# T1 while a `prepare` action runs, the status line names its package, its +# T1 while a `prepare` action runs, the status row names its package, its # label and its clock: ninja reports no step when it starts, and the # engine's action wrapper does (§6.4); -# T2 the package's line becomes final with its outcome, and one blank line +# T2 the package is named once, when its action starts, and one blank line # precedes `Finished`; -# T3 the status line is not drawn again after `Finished`. +# T3 the status row is not drawn again after `Finished`; +# T4 the status row's phase is aligned with the verbs (12 columns), and on +# a UTF-8 terminal it carries the screen: 24 braille cells; +# T5 no row of the region is blank: the status row follows the output. set -e TMP=$(mktemp -d) @@ -51,7 +55,10 @@ import fcntl, os, pty, re, struct, sys, termios pid, fd = pty.fork() if pid == 0: os.environ["TERM"] = "xterm-256color" + os.environ["LANG"] = "C.UTF-8" + os.environ["MCPP_PROGRESS"] = "snake" os.environ.pop("NO_COLOR", None) + os.environ.pop("LC_ALL", None) os.execvp(sys.argv[1], [sys.argv[1], "build"]) fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0)) raw = b"" @@ -110,11 +117,32 @@ assert re.search(r"app: app:install \d+:\d\d", plain), "T1: no status line named # T2 at = max(i for i, row in enumerate(screen) if "Finished" in row) assert screen[at - 1] == "", "T2: no blank line precedes Finished" -assert re.search(r"Compiling app v0\.1\.0 \(\.\) +done \d", screen[at - 2]), \ - "T2: the package's final line does not come before Finished" +assert sum(1 for row in screen if row == " Compiling app v0.1.0 (.)") == 1, \ + "T2: the package is not named exactly once" # T3 assert not any(("Building" in row or "Checking" in row) for row in screen[at:]), \ "T3: the status line was drawn after Finished" +# T4: a status row as it was written: the phase in 12 columns, then the screen. +rows = [r for r in re.split(r"[\r\n]", plain) if re.match(r"^ *(Planning|Running|Building|Checking) ", r)] +assert rows, "T4: no status row was drawn" +assert all(re.match(r"^ {4}(Planning|Building|Checking) | {5}Running ", r) for r in rows), \ + f"T4: a status row is not aligned with the verbs: {rows[:3]}" +assert any(re.search(r"[\u2800-\u28ff]{24}", r) for r in rows), \ + f"T4: no status row carries the 24-cell screen: {rows[:3]}" +# T5: replayed frame by frame -- each status row ends with `ESC[?7h` -- the +# row directly above the status row is never blank (revision 2 drew a blank +# separator row there), and the status row is the last row of the screen. +checked = 0 +for m in re.finditer(r"\x1b\[\?7h", text): + rows_now = screen_of(text[:m.end()]) + while rows_now and rows_now[-1] == "": + rows_now.pop() + assert rows_now and re.match(r"^ *(Planning|Running|Building|Stopping|Checking) ", rows_now[-1]), \ + f"T5: the last row after a frame is not the status row: {rows_now[-2:]}" + if len(rows_now) > 1: + assert rows_now[-2] != "", f"T5: a blank row precedes the status row: {rows_now[-3:]}" + checked += 1 +assert checked >= 3, f"T5: only {checked} frames were drawn" PY echo "PASS: 843_a_terminal_names_the_action_that_runs" diff --git a/tests/e2e/845_play_game_restores_the_terminal.sh b/tests/e2e/845_play_game_restores_the_terminal.sh new file mode 100644 index 00000000..75c65f9e --- /dev/null +++ b/tests/e2e/845_play_game_restores_the_terminal.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# requires: python3 unix-shell +# 845_play_game_restores_the_terminal.sh -- build output design 2026-09-30 +# (revision 3), §5.14: `--play-game`, through a pseudo-terminal. +# +# A package whose prepare action takes four seconds, built with +# `--play-game=snake`: +# +# G1 while the game plays, the terminal reads keys without echo and +# without a line end, and the status row carries the game's score; +# G2 after the build the terminal's mode is the mode it had, and a line +# after `Finished` states the best round; +# G3 a Ctrl-C during the game ends mcpp by SIGINT and still restores the +# terminal's mode, and the prepare action does not outlive mcpp; +# G4 with standard input not a terminal the game is off, a line says why, +# and the build succeeds. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +mkdir -p app/src +printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[targets.app]\nkind = "bin"\nmain = "src/main.cpp"\n' > app/mcpp.toml +printf 'int main() { return 0; }\n' > app/src/main.cpp +cat > app/build.mcpp <<'EOF' +import mcpp; +#include +int main() { + const std::string prefix = std::string(mcpp::out_dir()) + "/tool"; + mcpp::action a; + a.id = "app:install"; + a.role = mcpp::roles::prepare; + a.arg("python3").arg("-c") + .arg("import os, sys, time; time.sleep(4); os.makedirs(sys.argv[1], exist_ok=True); open(os.path.join(sys.argv[1], \"ready\"), \"w\").close()") + .arg(prefix.c_str()) + .output((prefix + ".stamp").c_str()) + .output_dir(prefix.c_str()) + .submit(); + return 0; +} +EOF +cd app + +cat > play.py <<'PY' +import fcntl, os, re, select, struct, subprocess, sys, termios, time +mcpp, mode = sys.argv[1], sys.argv[2] +master, slave = os.openpty() +fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0)) +before = termios.tcgetattr(slave) +env = dict(os.environ, TERM="xterm-256color", LANG="C.UTF-8") +env.pop("LC_ALL", None) +env.pop("NO_COLOR", None) +pid = os.fork() +if pid == 0: + os.setsid() + fcntl.ioctl(slave, termios.TIOCSCTTY, 0) + for fd in (0, 1, 2): + os.dup2(slave, fd) + os.execvpe(mcpp, [mcpp, "build", "--play-game=snake"], env) +raw, t0, during, sent, status = b"", time.time(), None, False, 0 +while True: + r, _, _ = select.select([master], [], [], 0.1) + if r: + try: + chunk = os.read(master, 65536) + except OSError: + chunk = b"" + raw += chunk + if time.time() - t0 > 1.5 and not sent: + during = termios.tcgetattr(slave) + for key in (b"\x1b[B", b"\x1b[C", b"\x1b[A"): + os.write(master, key) + time.sleep(0.15) + if mode == "ctrlc": + os.write(master, b"\x03") + sent = True + done, status = os.waitpid(pid, os.WNOHANG) + if done: + while select.select([master], [], [], 0.2)[0]: + try: + chunk = os.read(master, 65536) + except OSError: + break + if not chunk: + break + raw += chunk + break +after = termios.tcgetattr(slave) +plain = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", raw.decode("utf-8", "replace")).replace("\r\n", "\n") +print(plain) +assert during is not None, "the build ended before the game could be observed" +# G1 +assert not (during[3] & termios.ECHO), "G1: echo stayed on during the game" +assert not (during[3] & termios.ICANON), "G1: the terminal still waited for a line end" +assert re.search(r"Building .* · snake \d+", plain), "G1: no status row carries the score" +# G2 / G3 +assert after == before, "G2/G3: the terminal's mode was not restored" +if mode == "ctrlc": + assert os.WIFSIGNALED(status) and os.WTERMSIG(status) == 2, f"G3: mcpp did not end by SIGINT ({status})" + time.sleep(0.5) + left = subprocess.run(["pgrep", "-f", "app:install|time.sleep\\(4\\)"], capture_output=True, text=True).stdout + assert os.path.basename(os.getcwd()) not in left or not left.strip(), f"G3: the prepare action outlived mcpp: {left}" +else: + assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, f"G2: the build failed ({status})" + assert re.search(r"^ +Played snake · best \d+$", plain, re.M), "G2: no line states the best round" + fin = [i for i, l in enumerate(plain.splitlines()) if "Finished" in l] + pla = [i for i, l in enumerate(plain.splitlines()) if "Played snake" in l] + assert fin and pla and pla[0] > fin[0], "G2: the best round is not stated after Finished" +PY + +python3 play.py "$MCPP" normal > g2.log 2>&1 || fail "G1/G2" g2.log +rm -rf target +python3 play.py "$MCPP" ctrlc > g3.log 2>&1 || fail "G3" g3.log + +# G4 +rm -rf target +"$MCPP" build --play-game < /dev/null > g4.log 2>&1 || fail "G4: the build failed" g4.log +grep -q "play-game" g4.log || true # not a terminal on stdout either: the log medium says nothing +python3 - "$MCPP" <<'PY' > g4b.log 2>&1 || fail "G4" g4b.log +import os, pty, sys, re +# stdout a terminal, stdin not: the game is off and one line says why. +pid, fd = pty.fork() +if pid == 0: + devnull = os.open("/dev/null", os.O_RDONLY) + os.dup2(devnull, 0) + os.environ["LANG"] = "C.UTF-8" + os.environ.pop("LC_ALL", None) + os.execvp(sys.argv[1], [sys.argv[1], "build", "--play-game"]) +raw = b"" +while True: + try: + chunk = os.read(fd, 65536) + except OSError: + break + if not chunk: + break + raw += chunk +_, status = os.waitpid(pid, 0) +plain = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", raw.decode("utf-8", "replace")) +print(plain) +assert os.WEXITSTATUS(status) == 0, "G4: the build failed" +assert "standard input is not a terminal" in plain, "G4: no line says why the game is off" +assert "Played" not in plain, "G4: a game was played without keys" +PY + +echo "PASS: 845_play_game_restores_the_terminal" diff --git a/tests/e2e/89_build_mcpp.sh b/tests/e2e/89_build_mcpp.sh index b70df624..dcf2209b 100755 --- a/tests/e2e/89_build_mcpp.sh +++ b/tests/e2e/89_build_mcpp.sh @@ -59,7 +59,8 @@ grep -q "build.mcpp" b1.log || { cat b1.log; echo "FAIL: build.mcpp not invoked" # rebuild is skipped wholesale by the top-level up-to-date check, so we touch a # source to actually exercise the prepare path.) touch src/main.cpp -"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: build 2 errored"; exit 1; } +# A reused program is stated under --verbose only (build output design revision 3, §7.1). +"$MCPP" build -v > b2.log 2>&1 || { cat b2.log; echo "FAIL: build 2 errored"; exit 1; } grep -qi "build.mcpp.*cached" b2.log || { cat b2.log; echo "FAIL: build.mcpp did not short-circuit (expected cached)"; exit 1; } # ── Build 3: a declared env input changed — forces a re-run ──────────────── diff --git a/tests/unit/test_dots_screen.cpp b/tests/unit/test_dots_screen.cpp index 6a47a50b..d116b9c3 100644 --- a/tests/unit/test_dots_screen.cpp +++ b/tests/unit/test_dots_screen.cpp @@ -138,3 +138,77 @@ TEST(DotsScreen, TheChomperStandsAtTheFraction) { EXPECT_FALSE(pellet); EXPECT_EQ(sc.at(kWidth - 5, 1), Colour::Yellow); } + +// ─── The games of --play-game (design §5.14) ───────────────────────────── + +namespace { +bool any_red(const Game& g) { + Screen sc; + g.draw(sc); + for (int x = 0; x < kWidth; ++x) + for (int y = 0; y < kHeight; ++y) + if (sc.at(x, y) == Colour::Red) return true; + return false; +} +void run(Game& g, double seconds) { + Input in; + in.dt = 0.05; + for (double t = 0; t < seconds; t += in.dt) g.update(in); +} +} // namespace + +TEST(DotsScreenGames, TheThreeGamesAreBuiltIn) { + auto names = game_names(); + ASSERT_EQ(names.size(), 3u); + EXPECT_EQ(names[0], "snake"); + EXPECT_EQ(names[1], "stack"); + EXPECT_EQ(names[2], "runner"); + for (auto n : names) EXPECT_NE(make_game(n, 1), nullptr) << n; + EXPECT_EQ(make_game("chomp", 1), nullptr); // an animation, not a game +} + +TEST(DotsScreenGames, TheSnakeTurnsWithTheArrowsAndAWallEndsTheRound) { + auto g = make_game("snake", 4); + EXPECT_FALSE(any_red(*g)); + g->key(Key::Up); // from row 1: row 0, then the wall + run(*g, 0.3); + EXPECT_TRUE(any_red(*g)) << "the snake went through the wall"; + run(*g, 1.2); // a new round starts after a second + EXPECT_FALSE(any_red(*g)); + EXPECT_EQ(g->score(), 0); +} + +TEST(DotsScreenGames, TheStackPlacesPiecesAgainstTheLeftAndScoresClearedColumns) { + auto g = make_game("stack", 9); + for (int i = 0; i < 400 && g->score() == 0; ++i) { + // Spread the pieces over the four rows so that columns fill. + g->key(i % 4 == 0 ? Key::Up : i % 4 == 1 ? Key::Down : Key::Space); + g->key(Key::Left); + run(*g, 0.05); + } + Screen sc; + g->draw(sc); + bool left = false; + for (int y = 0; y < kHeight; ++y) left = left || sc.at(0, y) != Colour::None; + EXPECT_TRUE(left) << "no piece rests against the left edge"; + EXPECT_GE(g->best(), g->score()); +} + +TEST(DotsScreenGames, TheRunnerJumpsOnSpaceAndACactusEndsTheRound) { + auto g = make_game("runner", 2); + // Without a key, a cactus reaches the runner within a few seconds. + bool ended = false; + Input in; + in.dt = 0.05; + for (double t = 0; t < 6 && !ended; t += in.dt) { g->update(in); ended = any_red(*g); } + EXPECT_TRUE(ended) << "no cactus ever touched the runner"; + run(*g, 1.1); + // Space lifts it: its head rises above row 1. + g->key(Key::Space); + run(*g, 0.1); + Screen sc; + g->draw(sc); + bool high = false; + for (int x = 3; x < 7; ++x) high = high || sc.at(x, 0) == Colour::White; + EXPECT_TRUE(high) << "the runner did not jump"; +} From 938bcbf9ef30e502eccfdb10014f27207c0b9e6c Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:30:15 +0800 Subject: [PATCH 07/17] 2026.9.30.1: the docs and CHANGELOG state the build output of revision 3; the bundled xlings is 2026.9.30.1 --- .github/actions/bootstrap-mcpp/action.yml | 2 +- .github/actions/setup-macos-llvm/action.yml | 2 +- .github/workflows/bootstrap-macos.yml | 2 +- .github/workflows/ci-fresh-install.yml | 6 +- .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/cross-build-test.yml | 4 +- .github/workflows/release.yml | 14 +-- CHANGELOG.md | 83 +++++++++++++ docs/00-what-mcpp-is.md | 2 +- docs/09-commands-by-scenario.md | 128 ++++++++++++++------ docs/30-build-mcpp.md | 14 +-- docs/40-baremetal.md | 5 +- docs/zh/00-what-mcpp-is.md | 2 +- docs/zh/09-commands-by-scenario.md | 92 +++++++++----- docs/zh/30-build-mcpp.md | 10 +- docs/zh/40-baremetal.md | 5 +- mcpp.toml | 2 +- modules/versioning/src/version.cppm | 2 +- src/xlings/xlings.cppm | 2 +- 19 files changed, 277 insertions(+), 102 deletions(-) diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 5174cce2..15ef29bf 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.9.29.1' + default: '2026.9.30.1' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index 3bc4d66e..e79a10cb 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.9.29.1' + default: '2026.9.30.1' image: description: > The runner label the job runs on (macos-15, xcode-27). It is part of the diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index fdb2d72a..7508bbd8 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.9.29.1' + XLINGS_VERSION: '2026.9.30.1' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 9e572f87..de5e293d 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.29.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.30.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -315,7 +315,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.29.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.30.1 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -416,7 +416,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.29.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.30.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index e0ad446f..a26be330 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -384,7 +384,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.29.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.30.1 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 16e253b6..24e26bd2 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -135,7 +135,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.9.29.1' + XLINGS_VERSION: '2026.9.30.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -289,7 +289,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.9.29.1' + XLINGS_VERSION: '2026.9.30.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68710368..f8416467 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,7 +104,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.9.29.1' + XLINGS_VERSION: '2026.9.30.1' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -322,7 +322,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.9.29.1' + XLINGS_VERSION: '2026.9.30.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -393,7 +393,7 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.9.29.1-linux-aarch64.tar.gz" + XLA="xlings-2026.9.30.1-linux-aarch64.tar.gz" # NOT fetch_release.sh: this asset is OPTIONAL and the `if` is the # point — an arch with no prebuilt xlings must fall through quietly, # while the helper retries a 404 five times before giving up. The one @@ -402,9 +402,9 @@ jobs: # cover it. if curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ --connect-timeout 20 --max-time 600 -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.9.29.1/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.9.30.1/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.9.29.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.9.30.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -482,7 +482,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.9.29.1' + XLINGS_VERSION: '2026.9.30.1' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -665,7 +665,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.9.29.1' + XLINGS_VERSION: '2026.9.30.1' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/CHANGELOG.md b/CHANGELOG.md index f67d46e9..c7ffdb55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,89 @@ > Each `## []` section is that release's notes. Entries are written in English > from 2026.9.28.3 on; earlier entries remain as written. +## [2026.9.30.1] - 2026-09-30 + +This release revises what a build prints, from a report on `mcpp build` in the +xlings repository with 2026.9.29.5 +(`.agents/docs/2026-09-30-build-output-refinement-design.md`): a package is +named when it does work, the live display is one status row drawn in one +write, and a bare key of a path dependency is no longer reported. It also +records one lock entry per identity again. + +### Added + +- **A screen in the status row.** Beside the phase, 24 braille cells play one + of four animations, chosen per command: a chomper whose position is the + progress, a snake that eats a food in the colour of each package that + starts, Tetris on its side whose stack is the progress, and an emitter whose + ions build the progress bar. Each takes its tempo from the build: slow + while mcpp works, faster as steps finish, still while the build waits. + `MCPP_PROGRESS` names one, or `plain` (the row without the screen) or `off` + (no live row) (e2e 843; `mcpp.ui.dots_screen`). +- **`--play-game[=NAME]`** on `build`, `run` and `test` plays `snake`, `stack` + or `runner` on the screen while the build runs, steered from the keyboard at + a speed of its own. The best round is stated after `Finished`. Keys are + read without echo; Ctrl-C still stops the build, and the terminal's mode is + restored when the build ends or is interrupted. Where standard input or + output is not a terminal, one line says why and the build proceeds + (e2e 845). +- **`last N running`.** Once ninja has no step left to start (its `%u` + reaches 0), the status row states how many steps remain, all of them + running. + +### Fixed + +- **The live display no longer flickers.** A frame reached the terminal in two + or three writes (stdout is line-buffered, and a line above the region was + erase, text, redraw), and every frame erased the region before drawing it: + in one build of xlings, 184 of 202 frames left in two parts. A frame now + leaves in one write that overwrites the rows in place, with autowrap off for + the status row, so a terminal that draws East Asian ambiguous characters + wide clips the row instead of wrapping it. The row is first drawn half a + second into the command, so the first lines of output no longer push it + down (e2e 843). +- **A dependency the global cache serves is named when its units are + placed.** Its staging steps ran in a pass of their own that nothing read, + so such a package never completed and the folded dependency line waited + for ninja to exit (27 s late in a first build of xlings), after the root's + line (e2e 842). +- **The phase returns to planning after the build programs.** The status + line read `Running build programs` for as long as planning continued. +- **A planned build leaves a committed `mcpp.lock` unchanged.** Since + 2026.9.29.1 a planned build of a workspace root wrote the dependencies of a + `[dependencies.]` table under a second spelling with another hash, + beside the entries already there; the lock now holds one entry per + identity, spelled as 2026.9.28.3 spelled it, and a lock that holds two is + reduced to one by the next planned build (e2e 844). + +### Behaviour changes + +- **A package is named once, when it does work**: `Compiling ` when + the first of its steps that is not a dependency scan finishes, or when its + first `check` or `prepare` action starts, and `Cached (N units)` + when the global cache places its units. The line states no outcome and does + not change; nothing is folded, and a package with nothing to do has no line + (`--verbose` names it `Fresh`, and states each package's steps and span as + `Compiled`) (e2e 842). +- **A package inside the project is named by its short name, version and + directory** (`platform v0.1.0 (modules/platform)`); any other by its + identity, with `(index )`, `(git )` or its relative + directory. On a terminal the name's colour states the source: the official + index cyan, another index magenta, git blue, the project's own packages the + default colour. +- **A build program has a line when it runs or fails**; a reused program has + one under `--verbose`. The folded `build.mcpp N dependencies` line is gone. +- **A failure names its package**: `error: build failed in `. +- **The status row is aligned with the verbs** (` Building 612/707 · 0:35`), + its phases are `Planning`, `Running`, `Building`, `Stopping` and `Checking`, + and no blank row separates it from the output. +- **A bare key of a path or git dependency states only the short name.** Its + adoption of the namespace the manifest declares is no longer reported; a + key that states a namespace the manifest contradicts is reported once per + consumer manifest, named by its path, with the TOML that states the + declared identity (e2e 679, 713; package-identity §4.2). +- **The bundled xlings is 2026.9.30.1.** + ## [2026.9.29.5] - 2026-09-29 This release completes the workspace build graph in the commands around the diff --git a/docs/00-what-mcpp-is.md b/docs/00-what-mcpp-is.md index 92aa729b..a7f5d577 100644 --- a/docs/00-what-mcpp-is.md +++ b/docs/00-what-mcpp-is.md @@ -144,7 +144,7 @@ int main() { ```console $ mcpp run Inferred target hello (bin from src/main.cpp) - Compiling hello v0.1.0 (.) done 0.61s + Compiling hello v0.1.0 (.) Finished dev [unoptimized + debuginfo] in 0.64s Running `target/x86_64-linux-gnu/0946988e9e4b52ba/bin/hello` diff --git a/docs/09-commands-by-scenario.md b/docs/09-commands-by-scenario.md index 5a8928ac..e5b3cfa9 100644 --- a/docs/09-commands-by-scenario.md +++ b/docs/09-commands-by-scenario.md @@ -229,53 +229,109 @@ shows its status line and finishes silently, as before. ## What a build prints -A build reports each step once, when its outcome is known (2026.9.29.5+): +A build names each package when it does work, and one status row states the +build while it runs (2026.9.30.1+): ```console -$ mcpp build --workspace - build.mcpp gpp.core ran 16.00s - build.mcpp gpp.gui ran 6.70s - Compiling gpp.core (GalTranslPP) done 3m12s - Compiling 23 dependencies done 6m20s - Compiling gpp.gui (GPPGUI) done 38m05s - - Finished fast-release [unoptimized + debuginfo] in 41m53s · plan 1m13s · programs 32s · build 40m08s · longest gpp.gui: vcpkg install 22m10s -``` - -- A package's line names the package and states its outcome: `done` with the - span of its steps, `cached` when the global cache supplied it, `failed`, or - the number of its steps that ran when a failed build stopped before the - package completed. A package with nothing to do has no line. -- The packages the command was asked to build (the root package, or the - selected members) are listed. The packages they depend on are folded into - one line, and a dependency that fails is named. -- A build program's line states `ran` with its time, `cached`, or `failed`. -- A failed step is reported when it fails, with its diagnostics, while ninja - waits for the steps still running. +$ mcpp build + Workspace building member 'xlings' + build.mcpp mcpplibs.xpkg v0.0.59 ran 0.64s + Cached compat.ftxui v6.1.9 (73 units) + Cached mcpplibs.cmdline v0.0.2 (3 units) + Compiling cancellation v0.1.0 (modules/cancellation) + Compiling platform v0.1.0 (modules/platform) + Compiling mcpplibs.xpkg v0.0.59 + Compiling xlings v2026.9.29.1 (.) + + Finished dev [unoptimized + debuginfo] in 33.63s · plan 3.06s · programs 0.64s · build 29.94s +``` + +- A package's line is written when the first of its steps finishes (a + dependency scan does not count), or when its first `check` or `prepare` + action starts, and it does not change. `Cached` names a dependency whose + units the global build cache supplied, with their number. A package with + nothing to do has no line. +- A package inside the project (the root, a workspace member, a path + dependency under the project root) is named by its short name, version and + directory. Any other package is named by its identity: an index package by + its namespace and name, followed by `(index )` when an index the + project declares serves it; a git dependency with its reference; a path + outside the project with its relative directory. On a terminal the name's + colour states the source: the official index cyan, another index magenta, + a git repository blue, and the project's own packages the default colour. +- A build program has a line when it runs or fails, with its time. A program + whose result is reused has one under `--verbose`. +- A failed step is reported when it fails: `error: build failed in + `, then its diagnostics, while ninja waits for the steps still + running. - `Finished` states the whole command's time. A command of ten seconds or more also states how the time was spent, and names the step that took at least a quarter of the build when there is one. -On a terminal, the steps still running and one status line are drawn below -the output and updated in place: +On a terminal one status row is drawn below the output and updated in place: ``` - Compiling gpp.gui (GPPGUI) 61 steps - -Building 612/1203 · 14:32 · gpp.gui: vcpkg install 6:10 + Compiling platform v0.1.0 (modules/platform) + Building ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢾⡷⠀⠰⣿⠆⠄⠄⠄⠄⠄⠄⠄⠄ 612/707 · 0:35 · gpp.gui: CMAKE ElaWidgetTools 6:10 ``` -The status line counts the build's steps, shows the time since the command -started, and names the longest-running `check` or `prepare` action; ninja -reports every other step only when it finishes. When the output is not a -terminal (a CI log, a pipe), only final lines are written, and the status line -is written when the output has been silent for a minute. `TERM=dumb` selects -that form on a terminal too. +- The phase (`Planning`, `Running` for the build programs, `Building`, + `Stopping` after a failure, `Checking`) is aligned with the verbs above it. +- Beside it, a screen of 24 braille cells plays one of four animations, + chosen per command: a chomper whose position is the progress, a snake that + eats a food in the colour of each package that starts, Tetris on its side + whose stack is the progress, and an emitter whose ions build the progress + bar. An animation moves slowly while mcpp works and faster as steps finish, + and it stands still while the build waits. +- Then come the steps finished and planned, and the time since the command + started. When no step is left to start, `last N running` follows. +- Last comes the longest-running `check` or `prepare` action. ninja reports + every other step only when it finishes. +- The row is first drawn half a second into the command. Every change leaves + in one write that overwrites the row in place, so the row never flickers. + +`MCPP_PROGRESS` chooses the screen: + +- `random`, the default; +- an animation: `chomp`, `snake`, `stack` or `ions`; +- `plain`: the row without the screen; +- `off`: no live row, only the lines of a log. + +The screen needs a terminal that draws braille: a UTF-8 locale, or Windows +Terminal. Elsewhere the row is plain. When the output is not a terminal (a CI +log, a pipe), only final lines are written, and the status row is written +when the output has been silent for a minute. `TERM=dumb` selects that form +on a terminal too. -`--verbose` lists every package, including those with nothing to do (`fresh`), -states each build program's compile and run times, and prints every step as -ninja reports it (`[f/t] ` and its output). `--quiet` prints none of -it. Machine output (`--message-format json`) is unchanged. +`--play-game` plays a game on the screen while the build runs. It is accepted +by `build`, `run` and `test`, and `--play-game=NAME` names the game; +otherwise one is chosen: + +- `snake`: the arrows steer; +- `stack`: up and down move the piece, left drops it, right or space turns + it, and a filled column clears; +- `runner`: space or up jumps. + +```console +$ mcpp build --play-game=snake + Building ⠀⠀⠀⢲⠈⠀⠀⠀⠀⠀⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 3/7 · 0:12 · snake 4 +... + Finished dev [unoptimized + debuginfo] in 41.20s + Played snake · best 9 +``` + +The game runs at its own speed; the counts beside it state the build. Keys +are read without echo, and Ctrl-C still stops the build. The terminal's mode +is restored when the build ends or is interrupted; a process killed outright +cannot restore it, and `stty sane` does. The game needs standard input and +standard output on a terminal; otherwise one line says why, and the build +proceeds. + +`--verbose` names every package: `Fresh` for those with nothing to do, and +`Compiled` with the steps and span of each that did work. It also states each +build program's compile and run times, and prints every step as ninja reports +it (`[f/t] ` and its output). `--quiet` prints none of this. Machine +output (`--message-format json`) is unchanged. ## Validating a descriptor before publishing diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index 58a0ab42..253a912b 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -1518,13 +1518,13 @@ variable, emit `mcpp:rerun-if-changed=config.h` / `mcpp:rerun-if-env-changed=USE This replaces the old "process exited 0, so assume it's fine" guesswork with an explicit input/output contract — incremental builds stay correct. -Each program has one line, written when it finishes (2026.9.29.5+): -`build.mcpp cached` when nothing changed, `build.mcpp ran -