From 24ce563850bfa7b7ebd885a2822b89ef8da4153e Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:59:05 +0800 Subject: [PATCH 1/3] fix(build): a dlopen-surface pass that does not apply publishes a reason, and never over an answer (2026.9.10.2) `check_dlopen_surface` returned without writing in four cases: not Linux, a non-hermetic binding or `allow_host_libs`, a plan producing no program, a plan producing no linked artifact. Omitting the record makes "the check did not apply" and "the check was never run" the same reading. It does more than omit a sentence, because the two copies of the record have opposite lifetimes. The sidecar survives an invocation; `resolution.json` is regenerated from an empty object at the start of one. The backend runs once per drive and a single invocation can drive it more than once -- `mcpp test` builds the library and then links the test binary -- so a drive that links only a dependency's shared library has no program, has nothing to answer, and yet decides what the documented place to look finally contains. Every early return now publishes a record carrying its `reason`. A non-answer republishes a reading already on file under the same key rather than replacing it with a blank: the key covers the contract hash, the SubOS stamp and the host-libs policy, not the link units, so a reading taken under it is still about this farm and this policy, and a key that moved has already cleared the record. Publishing the reason alone would have turned a silent absence into a loud wrong answer. WHAT WAS MEASURED, AND WHAT WAS NOT. Against the released 2026.9.10.1 in a sandbox, `.agents/docs/2026-09-10-596-verify.sh` passed sections A to C and section D reported `resolution.json has no runtime.dlopen_surface`; that run was also the run that installed the dependency. Run again against the same binary in the same sandbox with the registry warm, section D passes with `dlopen_surface examined 15 of 15 members`. The ordering of drives on the cold run was not captured and has not been reproduced, so what is repaired here is the code path that can produce that reading, recorded as such in section 7 and section 8.6 of the design record rather than as a diagnosis of that run. The invariant is a unit test rather than an end-to-end case: reaching it needs two drives over one output directory where the second is the one that does not apply, and no project shape names that. Both halves were refuted with the repair removed -- without the republish branch the reading becomes 0 members, without the publish the sidecar is not written at all. The verification script gained the matching criterion: a published non-answer counts as a failure. --- ...-09-09-dlopen-surface-and-two-unwinders.md | 52 +++++++++++ .agents/docs/2026-09-10-596-verify.sh | 6 ++ CHANGELOG.md | 32 +++++++ mcpp.toml | 2 +- modules/versioning/src/version.cppm | 2 +- src/build/runtime_validation.cppm | 57 +++++++++++- tests/unit/test_loader_contract.cpp | 87 +++++++++++++++++++ 7 files changed, 233 insertions(+), 5 deletions(-) diff --git a/.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md b/.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md index b72bd45a..02046d1f 100644 --- a/.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md +++ b/.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md @@ -574,6 +574,13 @@ already filed as mcpp-index#376, reached this time through and not here, from the same pinned payload. R7 now serves the adapter through `compat:opencl`, so the practical consequence is gone; what is still unexplained is why their machine supplied `libOpenCL.so.1` without it. +* **The one cold-registry run in which section D found no record (§8.6).** The + released 2026.9.10.1 published nothing for a project it answers for on every + warm run of the same script in the same sandbox. The code path that can + produce that reading is repaired, and the repair is stated by a unit test; + the ordering of backend drives on that particular run was not captured and + has not been reproduced. A second cold sandbox -- a fresh registry, the + dependency built in the same invocation -- is what would close it. * **What R6 does to an executable that is itself a plugin host.** Hiding `libc++.a` and `libc++abi.a` means a library `dlopen`ed later cannot resolve the C++ standard library from the executable. That is the intended direction @@ -646,6 +653,51 @@ Both were invisible before because the same artifact had 68 real findings sitting on top of them. A check whose noise is repaired shows what the noise was covering, which is the third time this issue has produced that shape. +## 8.6 What the sandbox raised, and what the second run of it settled + +The verification script was run against the released 2026.9.10.1 in a sandbox +and section D failed -- `resolution.json has no runtime.dlopen_surface` -- on a +build whose sections A to C had just passed. That run was also the run that +installed `compat:sycl-runtime` into the sandbox registry. + +Run again against the same binary in the same sandbox, with the registry now +warm, section D passes: `dlopen_surface examined 15 of 15 members`. So the +absence is not a property of the released check on this project; it appeared +once, on the cold run, and no second observation of it exists. THAT IS RECORDED +AS AN OPEN OBSERVATION RATHER THAN A DIAGNOSIS, because the difference between +the two runs -- which drives the backend performed, and in what order -- was +not captured while the failing run was in front of us. + +What the failure did expose, by sending us to read the code, is a path that can +produce exactly that reading and should not exist regardless. `check_dlopen_surface` +returned without writing in four cases: not Linux, a non-hermetic binding or +`allow_host_libs`, a plan producing no program, a plan producing no artifact. +Omitting a record makes "did not apply" and "was never run" the same reading, +which is the failure this repository names most often. And it does more than +omit a sentence, because the two copies of the record have opposite lifetimes: +the sidecar survives an invocation, while `resolution.json` is regenerated from +an empty object at the start of one. The backend runs once per drive and a +single invocation can drive it more than once -- `mcpp test` builds the library +and then links the test binary -- so a drive that links only a dependency's +shared library has no program, has nothing to answer, and yet decides what the +documented place to look finally contains. + +THE REPAIR HAS TWO HALVES, and only one of them was obvious. Every early return +now publishes a record carrying its `reason`, so a non-answer is legible. And a +non-answer republishes a reading already on file under the same key rather than +replacing it with a blank: the key covers the contract hash, the SubOS stamp +and the host-libs policy, not the link units, so a reading taken under it is +still about this farm and this policy, and a key that moved has already cleared +the record before this runs. Publishing the reason alone would have converted a +silent absence into a loud wrong answer. + +The invariant is stated in a unit test rather than end to end +(`DlopenSurfaceRecord.ANonAnswerRepublishesTheAnswerAlreadyOnFile`): reaching it +requires two drives over one output directory where the SECOND is the one that +does not apply, and no project shape names that. The verification script gained +the matching criterion -- a published non-answer counts as a failure -- because +without it the next such overwrite would read as a pass. + ## 9. What this touches, across the three repositories The review that asks the other question: not "is each repair right" but "what diff --git a/.agents/docs/2026-09-10-596-verify.sh b/.agents/docs/2026-09-10-596-verify.sh index 6b290b61..87cf174e 100755 --- a/.agents/docs/2026-09-10-596-verify.sh +++ b/.agents/docs/2026-09-10-596-verify.sh @@ -145,6 +145,12 @@ rec = doc.get("runtime", {}).get("dlopen_surface") if rec is None: print("ASSERT-FAIL: resolution.json has no runtime.dlopen_surface") sys.exit(1) +# A published non-answer carries its reason. Distinguishing it from a real +# reading is the whole point of publishing one: this assertion is what caught +# the check erasing its own record on a second pass. +if rec.get("reason"): + print(f"ASSERT-FAIL: the check did not apply: {rec['reason']}") + sys.exit(1) members, walked = rec.get("members", 0), rec.get("walked", 0) if members <= 0: print(f"ASSERT-FAIL: dlopen_surface examined {members} members") diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef10010..4b509947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ ## [Unreleased] +## [2026.9.10.2] - 2026-09-10 + +### dlopen 面检查:不适用的那一趟也会发布记录,并且不会盖掉已经量出来的答案 + +`check_dlopen_surface` 有四条提前返回:非 Linux、绑定非 hermetic 或 +`allow_host_libs`、本次构建不产出程序、本次构建没有链接产物。它们都直接返回、 +什么都不写,于是「没适用」与「没检查过」读成同一个——这正是本仓库记下次数最多的 +那种失败,而它出现在一条为消除这种失败而写的检查里。 + +它不止是少一句话。记录有两份副本,寿命相反:sidecar 跨调用存活,而 +`resolution.json` 由 `prepare_build` 在每次调用开头从空对象重写。后端按「趟」运行, +一次调用可以驱动它不止一次(`mcpp test` 先构建库、再链接测试程序),只链接依赖的 +共享库的那一趟没有程序,这个问题本就不归它答——而它同样决定了**文档指定的查看 +位置**里最后剩下什么。 + +现在每条提前返回都发布一条带 `reason` 的记录;并且当同一 key 下已经有一次真实读数 +时,不适用的那一趟把它**重新发布**,而不是用空白覆盖。key 由契约哈希、SubOS 戳记 +与 host-libs 策略构成,不含链接单元,所以同 key 的读数仍然是关于这个 farm 和这个 +策略的;key 变了本来就会先清空记录。 + +**测量到什么,以及什么没有被复现。** 沙箱里用发布的 2026.9.10.1 跑 +`.agents/docs/2026-09-10-596-verify.sh`:A/B/C 三节通过,D 节报 +`resolution.json has no runtime.dlopen_surface`,那一次注册表是冷的——依赖是在同一次 +运行里装上的。注册表转热之后,同一份脚本、同一个发布二进制、同一个沙箱,D 节通过并 +打印 `dlopen_surface examined 15 of 15 members`。因此「哪一趟的顺序产生了那次缺席」 +并没有被复现;这里修的是**能够产生它的那条代码路径**,以及它此后不再能产生它。 + +不变量由单测陈述(`DlopenSurfaceRecord.ANonAnswerRepublishesTheAnswerAlreadyOnFile`): +没有端到端形状够得着它——它需要同一个输出目录上的两趟,且第二趟必须是不适用的那一趟。 +验证脚本同时补上对应判据:发布出来的非答案带 `reason` 时按失败计,否则同样的覆盖下次 +仍会被读成通过。 + ## [2026.9.10.1] - 2026-09-10 三条改动来自同一次排查(#596):一个 SYCL 工程构建全绿、运行时以退出码 134 终止且 diff --git a/mcpp.toml b/mcpp.toml index 70e5074e..2dfc8efd 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.10.1" +version = "2026.9.10.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 376ad844..6a73bf86 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.9.10.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.10.2"; } // namespace mcpp diff --git a/src/build/runtime_validation.cppm b/src/build/runtime_validation.cppm index 411e3977..cc2cd1d1 100644 --- a/src/build/runtime_validation.cppm +++ b/src/build/runtime_validation.cppm @@ -1151,15 +1151,59 @@ latest_stored_verdict(const std::filesystem::path& targetRoot) { mcpp::platform::elf::DlopenSurfaceReport check_dlopen_surface(const mcpp::build::BuildPlan& plan) { mcpp::platform::elf::DlopenSurfaceReport report; + + // The record below is ELF-shaped, so a host that links no ELF publishes + // nothing rather than an empty answer about a format it never produces. if constexpr (!mcpp::platform::is_linux) return report; + // ON LINUX, A NON-ANSWER IS PUBLISHED, NOT OMITTED -- AND NEVER OVER AN + // ANSWER. + // + // Returning without writing made "the check did not apply" and "the check + // was never run" the same reading, which is the confusion this repository + // has recorded most often. It is worse than a missing sentence here, + // because the two copies of the record have opposite lifetimes: + // `prepare_build` regenerates `resolution.json` from an empty object at + // the start of an invocation while the sidecar survives it, so a pass that + // returns without publishing leaves the DOCUMENTED place to look empty for + // an answer that was measured. + // + // The backend runs once per pass and one invocation can drive it more than + // once -- `mcpp test` builds the library and then links the test binary -- + // so a pass that links only a dependency's shared library has no program + // and nothing to say. Such a pass republishes what is on file rather than + // replacing it: the key covers the contract, the subos stamp and the + // host-libs policy, not the link units, so a reading taken under the same + // key is still about this farm and this policy. A key that moved has + // already cleared the record before this runs. + auto publish_reason = [&](std::string_view why) { + const auto key = post_link_key(plan); + auto doc = read_cache(plan.outputDir); + if (doc.is_object() && doc.value("post_link_key", "") == key) { + auto it = doc.find(std::string(kDlopenSurfaceRecord)); + if (it != doc.end() && it->is_object() && !it->contains("reason")) { + persist_post_link(plan, kDlopenSurfaceRecord, key, *it); + return; + } + } + persist_post_link(plan, kDlopenSurfaceRecord, key, + nlohmann::json{{"members", 0}, {"walked", 0}, + {"findings", nlohmann::json::array()}, + {"reason", std::string(why)}}); + }; + // THE SAME APPLICABILITY THE ARTIFACT VERDICT HAS, and for the same // reason. Under a non-hermetic binding the host loader also consults // `ld.so.cache`, which mcpp deliberately does not parse, so "not on the // path mcpp computed" is not evidence of anything. `allow_host_libs` is // the user's statement that resolution is theirs to arrange. - if (!plan.runtimeBinding.hermetic() || host_libs_allowed(plan)) + if (!plan.runtimeBinding.hermetic() || host_libs_allowed(plan)) { + publish_reason(host_libs_allowed(plan) + ? "allow_host_libs: resolution at run time is the project's to arrange" + : "the runtime binding is not hermetic; the host loader also " + "consults ld.so.cache, which mcpp does not parse"); return report; + } auto searchDirs = runtime_search_dirs(plan); @@ -1193,9 +1237,16 @@ check_dlopen_surface(const mcpp::build::BuildPlan& plan) { return unit.kind == mcpp::build::LinkUnit::Binary || unit.kind == mcpp::build::LinkUnit::TestBinary; }); - if (!producesAProgram) return report; + if (!producesAProgram) { + publish_reason("this build produces no program; the surface is reached " + "from a process and belongs to whatever runs"); + return report; + } const auto artifacts = snapshot_link_artifacts(plan); - if (artifacts.empty()) return report; + if (artifacts.empty()) { + publish_reason("this build produced no linked artifact"); + return report; + } for (auto const& [artifact, stamp] : artifacts) { auto dir = artifact.parent_path(); diff --git a/tests/unit/test_loader_contract.cpp b/tests/unit/test_loader_contract.cpp index 31a5196f..52643e75 100644 --- a/tests/unit/test_loader_contract.cpp +++ b/tests/unit/test_loader_contract.cpp @@ -7,6 +7,10 @@ import mcpp.pack.host_requirements; import mcpp.manifest; import mcpp.runtime.elf; import mcpp.build.runtime_validation; +import mcpp.build.plan; +import mcpp.runtime.binding; +import mcpp.platform; +import mcpp.libs.json; namespace { @@ -265,4 +269,87 @@ TEST(ArtifactIdentity, VersionMatchIsAPathComponent) { EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Mismatch); } +// ─── a pass with nothing to say does not erase what a pass measured ───────── +// +// The backend runs once per pass and one invocation can drive it more than +// once: `mcpp test` builds the library and then links the test binary, and a +// pass that links only a dependency's shared library has no program, so the +// dlopen surface is not its question to answer. +// +// That pass still decides what the documented place to look contains. The two +// copies of the record have opposite lifetimes -- the sidecar survives an +// invocation, `resolution.json` is regenerated from an empty object at the +// start of one -- so a pass that publishes nothing leaves `resolution.json` +// empty for an answer that was measured, and a pass that publishes its +// non-answer overwrites that answer with a blank. +// +// Stated here because no end-to-end shape reaches it: it needs two drives over +// one output directory where the SECOND one is the one that does not apply. +TEST(DlopenSurfaceRecord, ANonAnswerRepublishesTheAnswerAlreadyOnFile) { + namespace fs = std::filesystem; + if constexpr (!mcpp::platform::is_linux) { + SUCCEED() << "the record is ELF-shaped and this host links no ELF"; + return; + } + + auto root = fs::temp_directory_path() / "mcpp_dlopen_surface_record"; + std::error_code ec; + fs::remove_all(root, ec); + fs::create_directories(root); + struct Cleanup { fs::path d; + ~Cleanup() { std::error_code e; fs::remove_all(d, e); } } cleanup{root}; + + // A plan with no link units: it produces no program, which is exactly the + // pass whose answer is "not mine to give". + mcpp::build::BuildPlan plan; + plan.outputDir = root; + plan.runtimeBinding.loader = "/nonexistent/xlings-use-rpath-not-default-search"; + + const auto sidecar = root / ".mcpp-runtime-verdicts.json"; + auto regenerate_resolution = [&] { + std::ofstream out(root / "resolution.json"); + out << nlohmann::json{{"runtime", nlohmann::json::object()}}.dump(2) << '\n'; + }; + auto read_json = [](const fs::path& p) { + std::ifstream in(p); + return nlohmann::json::parse(in, nullptr, false); + }; + + // Drive one: nothing on file, so the non-answer is published WITH its + // reason. "did not apply" and "was never run" must not read the same. + regenerate_resolution(); + mcpp::build::runtime_validation::check_dlopen_surface(plan); + auto stored = read_json(sidecar); + ASSERT_TRUE(stored.is_object()) << "the sidecar was not written"; + ASSERT_TRUE(stored.contains("dlopen_surface")); + EXPECT_FALSE(stored["dlopen_surface"].value("reason", "").empty()) + << "a published non-answer carries the reason it did not apply"; + EXPECT_FALSE(read_json(root / "resolution.json")["runtime"]["dlopen_surface"] + .value("reason", "").empty()) + << "the documented place to look carries it too"; + + // A pass that DID apply now records a reading, under the key already on + // file. The key covers the contract, the SubOS stamp and the host-libs + // policy, none of which this test changes. + stored["dlopen_surface"] = nlohmann::json{ + {"members", 7}, {"walked", 7}, {"findings", nlohmann::json::array()}}; + { std::ofstream out(sidecar); out << stored.dump(2) << '\n'; } + + // Drive two, with `resolution.json` regenerated as an invocation would: + // the same plan that does not apply must neither overwrite the reading nor + // leave the published copy empty. + regenerate_resolution(); + mcpp::build::runtime_validation::check_dlopen_surface(plan); + + auto after = read_json(sidecar)["dlopen_surface"]; + EXPECT_EQ(after.value("members", 0), 7) + << "a non-answer replaced a reading taken under the same key"; + EXPECT_FALSE(after.contains("reason")); + + auto republished = read_json(root / "resolution.json")["runtime"]["dlopen_surface"]; + EXPECT_EQ(republished.value("members", 0), 7) + << "the reading survived in the sidecar but not where it is documented"; + EXPECT_EQ(republished.value("walked", 0), 7); +} + } // namespace From 250df400206e13bd336c217d7a501c922d4fc442 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 10 Sep 2026 03:02:42 +0800 Subject: [PATCH 2/3] docs(33): the record's `reason` key, in both languages A reader who finds `{"members": 0, "walked": 0, "findings": [], "reason": ...}` has to be told what it means, and told to treat it as "not measured" rather than as a clean result. Four reasons are published; a target that is not Linux publishes no record at all, because the record is ELF-shaped and an empty answer about a format the build never produces is its own confusion. --- ...6-09-09-dlopen-surface-and-two-unwinders.md | 7 ++++--- CHANGELOG.md | 11 ++++++----- docs/33-authoring-an-adapter.md | 18 ++++++++++++++++++ docs/zh/33-authoring-an-adapter.md | 14 ++++++++++++++ 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md b/.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md index 02046d1f..52a845a9 100644 --- a/.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md +++ b/.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md @@ -670,9 +670,10 @@ not captured while the failing run was in front of us. What the failure did expose, by sending us to read the code, is a path that can produce exactly that reading and should not exist regardless. `check_dlopen_surface` -returned without writing in four cases: not Linux, a non-hermetic binding or -`allow_host_libs`, a plan producing no program, a plan producing no artifact. -Omitting a record makes "did not apply" and "was never run" the same reading, +returned without writing in four cases: a non-hermetic binding, `allow_host_libs`, +a plan producing no program, a plan producing no artifact. (A target that is not +Linux still publishes nothing, and should: the record is ELF-shaped.) Omitting a +record makes "did not apply" and "was never run" the same reading, which is the failure this repository names most often. And it does more than omit a sentence, because the two copies of the record have opposite lifetimes: the sidecar survives an invocation, while `resolution.json` is regenerated from diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b509947..cf282c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,11 @@ ### dlopen 面检查:不适用的那一趟也会发布记录,并且不会盖掉已经量出来的答案 -`check_dlopen_surface` 有四条提前返回:非 Linux、绑定非 hermetic 或 -`allow_host_libs`、本次构建不产出程序、本次构建没有链接产物。它们都直接返回、 -什么都不写,于是「没适用」与「没检查过」读成同一个——这正是本仓库记下次数最多的 -那种失败,而它出现在一条为消除这种失败而写的检查里。 +`check_dlopen_surface` 有多条提前返回:绑定非 hermetic、设置了 `allow_host_libs`、 +本次构建不产出程序、本次构建没有链接产物。它们都直接返回、什么都不写,于是「没适用」 +与「没检查过」读成同一个——这正是本仓库记下次数最多的那种失败,而它出现在一条为消除 +这种失败而写的检查里。(非 Linux 目标仍然不发布记录:这条记录是 ELF 形状的,对一个 +本次构建根本不产出的格式给出空答案是另一种混淆。) 它不止是少一句话。记录有两份副本,寿命相反:sidecar 跨调用存活,而 `resolution.json` 由 `prepare_build` 在每次调用开头从空对象重写。后端按「趟」运行, @@ -20,7 +21,7 @@ 共享库的那一趟没有程序,这个问题本就不归它答——而它同样决定了**文档指定的查看 位置**里最后剩下什么。 -现在每条提前返回都发布一条带 `reason` 的记录;并且当同一 key 下已经有一次真实读数 +现在这四条提前返回都发布一条带 `reason` 的记录;并且当同一 key 下已经有一次真实读数 时,不适用的那一趟把它**重新发布**,而不是用空白覆盖。key 由契约哈希、SubOS 戳记 与 host-libs 策略构成,不含链接单元,所以同 key 的读数仍然是关于这个 farm 和这个 策略的;key 变了本来就会先清空记录。 diff --git a/docs/33-authoring-an-adapter.md b/docs/33-authoring-an-adapter.md index 830b2b89..906758b1 100644 --- a/docs/33-authoring-an-adapter.md +++ b/docs/33-authoring-an-adapter.md @@ -120,6 +120,24 @@ The full result, including both denominators, is published as failed to build enumerates nothing, and "no findings" would otherwise be indistinguishable from "nothing was examined". +A build the check does not apply to publishes the same record with a `reason` +and no reading: + +```json +{ "members": 0, "walked": 0, "findings": [], + "reason": "this build produces no program; the surface is reached from a process and belongs to whatever runs" } +``` + +The four reasons are: the runtime binding is not hermetic, `allow_host_libs` is +set, the build produces no program, and the build produced no linked artifact. +A target that is not Linux publishes no record at all, because the record is +ELF-shaped and an empty answer about a format the build never produces would be +its own confusion. + +A test that reads this record should treat a `reason` as "not measured" rather +than as a clean result -- "did not apply" and "was never run" are the pair this +record exists to keep apart. + ## Current limitations - **Linux only, by construction.** macOS's dyld and the Windows PE loader have diff --git a/docs/zh/33-authoring-an-adapter.md b/docs/zh/33-authoring-an-adapter.md index ea704c92..7e0c8670 100644 --- a/docs/zh/33-authoring-an-adapter.md +++ b/docs/zh/33-authoring-an-adapter.md @@ -101,6 +101,20 @@ on this artifact's search path: 即使没有任何发现,`members` 与 `walked` 也会被发布。一个构建失败的农场枚举出零个成员, 否则「没有发现」与「什么都没检查」就读起来一模一样。 +这条检查不适用的构建,发布同一条记录,但只带 `reason`、不带读数: + +```json +{ "members": 0, "walked": 0, "findings": [], + "reason": "this build produces no program; the surface is reached from a process and belongs to whatever runs" } +``` + +四种理由是:运行时绑定非 hermetic;设置了 `allow_host_libs`;本次构建不产出程序;本次构建 +没有链接产物。目标不是 Linux 时不发布任何记录 —— 这条记录是 ELF 形状的,对一个本次构建 +根本不产出的格式给出「空答案」本身就是另一种混淆。 + +读这条记录的测试应当把 `reason` 当作「没有测到」而不是「测了,结果干净」—— 「没适用」与 +「没检查过」正是这条记录存在的意义所在。 + ## 当前边界 - **按构造只适用于 Linux。** macOS 的 dyld 与 Windows 的 PE 加载器没有对应的这一层, From ab3ec46e67974d8e32a65291c5612babe72e4022 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 10 Sep 2026 03:04:16 +0800 Subject: [PATCH 3/3] test(596-verify): section D names the record it read, and counts them The object was whatever `find` reached first. A run that also builds a dependency can leave more than one resolution.json under the work tree, and traversal order is not a property of the record being tested. The probe builds one project, so the count is the assertion and the file that was read is printed beside the result. --- .agents/docs/2026-09-10-596-verify.sh | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.agents/docs/2026-09-10-596-verify.sh b/.agents/docs/2026-09-10-596-verify.sh index 87cf174e..61c92efb 100755 --- a/.agents/docs/2026-09-10-596-verify.sh +++ b/.agents/docs/2026-09-10-596-verify.sh @@ -136,8 +136,25 @@ section "D. mcpp reports a dlopen surface it cannot satisfy" # fails the next time the wording improves. Both denominators are asserted for # the reason they exist -- "no findings" and "nothing was examined" must not # read the same. -res=$(find "$work" -name resolution.json 2>/dev/null | head -1) +# +# THE OBJECT IS SELECTED BY IDENTITY, NOT BY WHERE `find` ARRIVES FIRST. The +# probe project's own build tree is `$work/target//`, and +# a run that also builds a dependency can leave more than one resolution.json +# under the work tree. "The first one" is a property of directory traversal +# order, not of the record being tested, so the count is asserted and named. +res="" +res_n=0 +for candidate in "$work"/target/*/*/resolution.json; do + [ -f "$candidate" ] || continue + res_n=$((res_n + 1)) + res="$candidate" +done +if [ "$res_n" -gt 1 ]; then + fail "D: $res_n resolution.json files under $work/target; the probe builds one project" + res="" +fi if [ -n "$res" ] && command -v python3 >/dev/null 2>&1; then + ok "reading ${res#"$work"/}" python3 - "$res" <<'PY' import json, sys doc = json.load(open(sys.argv[1])) @@ -166,8 +183,8 @@ if bad: print("ok: no driver soname is missing from the farm") PY [ $? -eq 0 ] || fails=$((fails + 1)) -else - skip "D: no resolution.json (section C did not build) or no python3" +elif [ "$res_n" -le 1 ]; then + skip "D: no resolution.json under $work/target (section C did not build) or no python3" fi # ---------------------------------------------------------------------------