Skip to content

0.9.0: dist-apk takes a manifest template and several Java roots, dist-web copies through the engine - #20

Merged
Sunrisepeak merged 5 commits into
mainfrom
feat/0.9.0-manifest-template-java-roots-stage-copy
Sep 12, 2026
Merged

0.9.0: dist-apk takes a manifest template and several Java roots, dist-web copies through the engine#20
Sunrisepeak merged 5 commits into
mainfrom
feat/0.9.0-manifest-template-java-roots-stage-copy

Conversation

@Sunrisepeak

@Sunrisepeak Sunrisepeak commented Sep 12, 2026

Copy link
Copy Markdown
Member

Summary

Implements P1-P4 of §9.2 in .agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md (mcpp repository), one release, 0.9.0.

  • dist-apk (dist/apk.cppm), P1 -- options::manifest_template. A project file, package-root-relative, rendered through six tokens substituted verbatim wherever they appear: {{application_id}}, {{label}}, {{activity}}, {{lib_name}}, {{min_sdk}}, {{target_sdk}}. {{application_id}} and {{activity}} are required always; {{lib_name}} joins them at level 0. A missing required token, or an unknown {{...}} token, is refused at plan time (applies = false, reason set, printed to the child's own stderr exactly as this member's other refusals are) naming the token -- the missing-token message also names assets/mcpp-run.json, which adb-run reads to start the application. The built-in default is manifest_xml's 0.8.0 output expressed with these tokens; level 0 with no template renders byte-identical to 0.8.0's. The template is declared with mcpp::rerun_if_changed. The unknown-token check is deliberately dist-apk's own and not proposed for dist-web (a page template may legitimately carry {{ }} for a front-end framework); the asymmetry is recorded in a comment in apk.cppm.
  • dist-apk, P2 -- options::java_sources becomes std::vector<std::string>. One javac over every root's .java files, one d8. rerun_if_changed_glob is declared only for a root under mcpp::manifest_dir(); a root outside it (a dependency's own tree) is not walked by the glob fingerprint regardless, its files are already javac action inputs, and its version is already in the build's fingerprint.
  • dist-web (dist/web.cppm), P3. The two copy steps become argv { "${mcpp.self}", "stage", "--verify", "content", "--output", dst, src }; the plan-time create_directories is removed (stage creates the parent); the POSIX-only note leaves the header and the README row; the header states ${mcpp.self}/mcpp stage's argument shape as the engine contract since 2026.9.13.1.
  • P4 -- release plumbing. README rows for dist-apk and dist-web (floor 2026.9.13.1 for both, one sentence each on why); mcpp.toml and src/plugins.cppm to 0.9.0; .github/workflows/ci.yml MCPP_VERSION to 2026.9.13.1.

Post-review fix: the java_sources rerun glob was absolute

Review found that P2's mcpp::rerun_if_changed_glob((root + "/**/*.java").c_str()) declared an absolute pattern for a root under mcpp::manifest_dir(), because opt.java_sources's own roots (composed from mcpp::manifest_dir()) are absolute. The engine matches a glob by comparing the candidate file made relative to the package root against the pattern (modules/manifest/src/glob.cppm, path_matches_glob); an absolute pattern is compared against a relative candidate and never matches anything, so the fingerprint is always the empty set and a .java file appearing under the project root never changed it -- "a .java added to the project root re-runs the build program on the next build" (design record §3.5) was not true. 0.8.0 had the same shape under one root; this PR is where it is stated and fixed.

Reverse leg, measured before the fix, exactly as asked: build.mcpp.cache's recorded fingerprint for the pattern was identical (cbf29ce484222325) whether or not AddedLater.java existed under the project root -- direct proof the fingerprint never saw the file. (The literal "does mcpp pack skip a rebuild and so miss the class" reading could not be measured through mcpp pack on this fixture: this engine re-runs build.mcpp on every mcpp pack of this project regardless of the glob -- confirmed by running it three times in a row with nothing changed at all, always "build.mcpp compiling / build.mcpp running", never "up to date (cached)" -- which is a property of this fixture's dispatch pass, not of the glob, and true both before and after this fix. A plain, non-packaging mcpp build does cache correctly, but never declares the glob at all, since plan_for returns before the java_sources branch when pack_format() is empty. So classes.dex picking up an added file is not, by itself, evidence the glob works -- it would look identical either way. The cache's own recorded fingerprint is the evidence that actually distinguishes the two, and is what the fix and its test rely on.)

Fix: root_in_project now returns std::optional<std::string> -- the manifest-relative form -- instead of a bool; the glob is declared with that relative path (*rel + "/**/*.java"), never with root itself. After the fix, the recorded hash changes with the real file set: 78b24cb88185e8c9 (2 files) -> 4cd5de0f1766a171 (3 files, AddedLater.java added).

New test leg (f) in tests/apk-consumer/check-apk-features.sh, both directions:

  • asserts the cache's recorded java/**/*.java glob hash changes when a file is added under the project root (the precise, caching-independent evidence above);
  • asserts no glob is ever recorded for the external root, before and after adding a file there (design record §3.3: the dependency's own version carries that change, not a glob);
  • as a supplementary functional check (explicitly labelled as not a caching claim, for the reason above), that classes.dex contains the added class in both cases -- true regardless of the fix, but still worth asserting since it is the actual thing a project observes.

Tests

tests/apk-consumer is extended (not a new, HuxerUI-specific fixture): build.mcpp reads two environment variables (APK_CONSUMER_TEMPLATE, APK_CONSUMER_LEVEL1) to reach five configurations without a second project. New fixtures: manifest-template-good.xml (a uses-permission and a receiver), manifest-template-missing-appid.xml, manifest-template-bogus-token.xml, a project-local Java root (tests/apk-consumer/java) and a sibling directory outside the fixture (tests/apk-consumer-external-java) reached by an absolute path build.mcpp computes, standing in for a path dependency's own Java tree. check-apk-features.sh runs criteria (a)-(f) and is wired into CI beside the existing "dist-apk produces a signed APK, level 0 and level 1" step. tests/web-consumer/check-web-idempotent.sh is a new sibling of check-web-plan.sh (which is unchanged and still passes) asserting a second, no-op pack copies nothing; wired in beside the existing dist-web step.

Criteria and local results (engine: a build carrying this batch's engine half, reporting 2026.9.12.4 but with ${mcpp.self} and the unbounded action lists -- the release will be 2026.9.13.1)

criterion what ran result
(a) level 0, no template built the 0.8.0 code against this engine on the same fixture, captured AndroidManifest.xml as a checked-in fixture; built this branch's code and cmp'd the two identical
(b) template with permission + receiver mcpp pack --format apk with manifest-template-good.xml; aapt2 dump xmltree on the linked base.apk lists both android.permission.INTERNET and org.mcpp.apkconsumer.SampleReceiver
(c) missing {{application_id}} re-invoked the compiled build.mcpp binary directly (see note below); mcpp pack itself also refused, "no action claimed --format 'apk'" message: the manifest template does not use '{{application_id}}', and assets/mcpp-run.json -- which adb-run starts the application from -- is written from the same value
(d) unknown {{bogus}} same message: the manifest template names an unknown token '{{bogus}}' -- expected one of application_id, label, activity, lib_name, min_sdk, target_sdk
(e) two Java roots mcpp pack --format apk with APK_CONSUMER_LEVEL1=1; dexdump -l plain on the produced classes.dex lists both org.mcpp.apkconsumer.MainActivity; (project root) and org.mcpp.apkconsumer.ExternalHelper; (external root)
(f) re-run direction, project root before the fix: cache's java/**/*.java glob hash identical with/without AddedLater.java (cbf29ce484222325 both times) after the fix: hash changes (78b24cb88185e8c9 -> 4cd5de0f1766a171); classes.dex contains AddedLater
(f) re-run direction, external root no glob ever recorded for apk-consumer-external-java, before or after adding a file there unchanged by the fix (never declared in either version); classes.dex still contains the added class (a functional fact, not a caching claim -- see the note above)
dist-web second pack two mcpp pack --format web runs 1s apart; every staged file's mtime compared unchanged; build.ninja's command line confirmed as <engine path> stage --verify content --output <dst> <src>
tests/web-consumer/check-web-plan.sh run unmodified PASS: dist-web produces a static directory, node runs it, 1-2-3
existing "dist-apk ... level 0 and level 1" step re-run against this branch still passes (unaffected)
tests/all-rules-compile mcpp build && mcpp run compiles and runs; all-rules-compile ok
"the collection states its own version" mcpp.toml vs src/plugins.cppm both 0.9.0

Note on (c)/(d): mcpp discards a build program's captured stdout/stderr when it exits 0 (build_program.cppm: the capture is surfaced only on a non-zero exit or a timeout), and a plan_for refusal never makes build.mcpp exit non-zero -- submit() returns true for applies = false exactly as every other refusal in this member does, which is the behavior the design record asks this change to match ("printing to stderr exactly as its other refusals do"). So the refusal TEXT itself is checked by re-invoking the already-compiled build.mcpp binary directly with the documented MCPP_* build-program contract (docs/30) reconstructed from a prior, real build's resolved payload paths -- check-apk-features.sh's own header explains this. The FUNCTIONAL refusal (the overall command failing) is additionally checked through the ordinary mcpp pack CLI, which fails with "no action claimed --format 'apk'" in both cases.

Note on (f): see "Post-review fix" above -- the cache's own recorded glob fingerprint, not classes.dex content, is the evidence that actually distinguishes the pre-fix and post-fix behavior on this fixture, because mcpp pack reruns build.mcpp unconditionally here regardless of any cache.

Note on the web idempotency check: the design record's ninja -n leg was not usable as written -- this repository's .ninja_log is written by mcpp's own internal ninja, and the system ninja (a different version) on this machine reads that log, decides it is stale, and reports every edge as pending regardless of whether anything reran. The design record's alternative (unchanged mtimes) was used instead and is what check-web-idempotent.sh asserts.

Not verified

  • Real device/emulator runs of the level-1 APK (adb-run) -- this sandbox has neither an emulator nor an attached device, consistent with the existing CI step's own note.
  • CI itself: mcpp 2026.9.13.1 does not exist yet, so this PR's CI will be red on the fetch-the-release step until that engine release ships. Do not lower the pin to make CI green.

Design record: .agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md (mcpp repository), §3, §4, §9.2 (P1-P4).

…es as an array

options::manifest_template renders a project AndroidManifest.xml through
six tokens substituted verbatim: {{application_id}}, {{label}},
{{activity}}, {{lib_name}}, {{min_sdk}}, {{target_sdk}}. Three are
required, not merely substituted, because their value is also written
to assets/mcpp-run.json, which adb-run reads to start the application:
{{application_id}} and {{activity}} always, {{lib_name}} at level 0. A
missing required token or an unknown {{...}} token is refused at plan
time, naming it. The built-in default is 0.8.0's manifest_xml output
expressed with these tokens, so level 0 with no template renders
byte-identical to 0.8.0's.

options::java_sources becomes std::vector<std::string>: one javac over
every root's .java files and one d8 over the result. rerun_if_changed_glob
is declared only for a root under mcpp::manifest_dir(); a root outside
it (a dependency's own Java tree) is not walked by the glob fingerprint
regardless, and its files are already inputs of the javac action while
its version is already in the build's fingerprint.

Design record: .agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md, §3.
The two copy steps become argv { "${mcpp.self}", "stage", "--verify",
"content", "--output", dst, src }, the same copier every stage_file edge
in build.ninja already runs: it creates the destination's parent,
compares content and writes only on a difference. Plan-time
create_directories is removed with it. The POSIX-only note leaves the
header; this member's floor rises to the mcpp release that carries
${mcpp.self} and mcpp stage's argument shape as an engine contract.

Design record: .agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md, §4.
…s idempotent pack

tests/apk-consumer: build.mcpp reads APK_CONSUMER_TEMPLATE and
APK_CONSUMER_LEVEL1 to reach five configurations without a second
fixture. check-apk-features.sh runs all five: (a) level 0 with no
template, byte-identical to the 0.8.0 manifest fixture; (b) a template
naming a uses-permission and a receiver, both present in aapt2 dump
xmltree on the linked base.apk; (c) a template missing
{{application_id}}, refused naming the token and assets/mcpp-run.json;
(d) a template naming {{bogus}}, refused naming it; (e) two Java roots,
one under the project (tests/apk-consumer/java) and one a sibling
directory reached by an absolute path (tests/apk-consumer-external-java),
producing one classes.dex with classes from both. (c) and (d) read
build.mcpp's own stdout/stderr by re-invoking the compiled binary
directly with the documented MCPP_* contract, because mcpp discards a
build program's captured output on a plan-time refusal's exit code of 0.

tests/web-consumer/check-web-idempotent.sh: a second, no-op
mcpp pack --format web changes no staged file's mtime, the criterion
${mcpp.self} stage gives for free and cp never could. A sibling of
check-web-plan.sh, which keeps passing unchanged.

.github/workflows/ci.yml: both wired in beside the existing
"dist-apk produces a signed APK, level 0 and level 1" and
"dist-web produces a static directory, and node runs it" steps;
MCPP_VERSION raised to 2026.9.13.1, the release both P1-P3 need.

Design record: .agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md, §3.5, §4.3, §9.2.
README rows for dist-apk and dist-web state the new floor and why:
dist-web needs ${mcpp.self} and mcpp stage's argument shape as an
engine contract; dist-apk's own manifest-template and Java-array
changes ask nothing new of the engine, but this collection publishes
one package at one version, and this is the release CI verifies it
under from here on.

mcpp.toml and src/plugins.cppm: version 0.9.0.

The mcpp 2026.9.13.1 release this depends on does not exist yet, so
CI is red on the fetch step until it does (see the pull request body).
mcpp::rerun_if_changed_glob((root + "/**/*.java").c_str()) declared an
ABSOLUTE pattern for a root under mcpp::manifest_dir(), because
opt.java_sources's own roots (manifest_dir() composed with a
subdirectory) are absolute. The engine matches a glob by comparing the
candidate file made relative to the package root against the pattern
(modules/manifest/src/glob.cppm, path_matches_glob); an absolute
pattern is compared against a relative candidate and never matches
anything, so the fingerprint is always the empty set and a .java file
appearing under the project root never changes it -- "a .java added
under the project root re-runs the build program" was not true.

root_in_project now returns the manifest-relative form (std::optional
<std::string>) instead of a bool, and the glob is declared with that
relative path.

Measured directly on the recorded cache fingerprint (build.mcpp.cache's
"glob <hash> java/**/*.java" line), which is the precise evidence: with
the pre-fix absolute pattern, the recorded hash was identical
(cbf29ce484222325) whether or not a .java file existed under the
project root; with the fix, the hash changes (78b24cb88185e8c9 ->
4cd5de0f1766a171) when one is added. tests/apk-consumer/
check-apk-features.sh's new leg (f) asserts this directly, plus the
external root's continuing absence from any recorded glob, plus (as a
functional sanity check, not a caching claim -- this engine reruns
build.mcpp on every `mcpp pack` of this fixture regardless of any
glob, so a dex-content check alone cannot distinguish the two) that
classes.dex picks up a class added under either root.
@Sunrisepeak
Sunrisepeak merged commit fe1db3e into main Sep 12, 2026
3 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant