scaffold deploy-method 5: Tauri desktop shell for the cap-5 SPA

Method 5 was the last aspirational entry in the 5-deploy-method model: 0
tauri.conf.json, 0 Cargo.toml, 0 .rs. It is now scaffolded against the v2
config schema, mirroring the Capacitor pattern that already works.

frontendDist points at the SAME public/ tree Capacitor and the CDN serve, so
one build still feeds every consumer - now four instead of three. The crate
carries no application logic on purpose: the product is the web tree, this is
a window around it.

check:tauri is the analogue of cap:verify and exists for the same reason
capacitor.config.json warns about in capitals: a wrong frontendDist or a window
url that does not exist launches a BLANK WHITE WINDOW, not an error. It runs
with no Rust toolchain and no Tauri CLI, so CI can run it. Negative-tested five
ways - wrong frontendDist, missing window url, identifier drift from the
Capacitor appId, empty windows array, missing crate file - each exits 1, and
the config was restored byte-identical after every test.

Status stays BUILT, NOT SHOT. rustc parses main.rs (it reaches line 14 and
fails only on the uninstalled tauri crate, a dependency error not a syntax
one), but producing a binary needs the Tauri CLI plus system webview libs.
Also recorded the token/CORS caveat up front: a Tauri webview serves from
tauri://localhost, so the token endpoint must send CORS for that origin -
the same surprise Capacitor documents for https://localhost.
This commit is contained in:
doxa-maps-88655c70 2026-09-21 21:06:04 +00:00
parent c8242064e6
commit cad913bc6f
7 changed files with 242 additions and 16 deletions

View file

@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* check-tauri-config.mjs — the Tauri analogue of `cap:verify`.
*
* WHY THIS EXISTS: capacitor.config.json documents the failure mode in capitals — "a wrong
* webDir yields an EMPTY assets/public and a BLANK WHITE SCREEN at launch, NOT AN ERROR."
* Tauri has the identical shape: point `build.frontendDist` at the wrong place, or name a
* window `url` that does not exist in the built tree, and the app launches to a blank white
* window with no message. Neither `cargo build` nor any web gate catches it, because the
* path is only resolved by the webview at runtime.
*
* This check runs with NO Rust toolchain and NO Tauri CLI — it is pure config-vs-filesystem
* assertion, so it works in CI (which installs only bun) exactly as `check:mobile` does.
* What it CANNOT do is prove the binary builds or launches; that still needs
* `cargo tauri build` on a machine with the Tauri CLI and system webview libs.
*
* Exit 0 = the config points at files that exist. Exit 1 = a launch-blank-window defect.
*/
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const CONF = join(ROOT, 'tauri.conf.json')
const fail = []
const ok = []
if (!existsSync(CONF)) {
console.error('TAURI-CONFIG: FAIL — tauri.conf.json is missing at the repo root.')
process.exit(1)
}
let conf
try {
conf = JSON.parse(readFileSync(CONF, 'utf8'))
} catch (e) {
console.error(`TAURI-CONFIG: FAIL — tauri.conf.json does not parse: ${e.message}`)
process.exit(1)
}
// 1. identifier must match Capacitor's appId — one product, one reverse-DNS identity.
const capPath = join(ROOT, 'capacitor.config.json')
if (existsSync(capPath)) {
const appId = JSON.parse(readFileSync(capPath, 'utf8')).appId
if (conf.identifier !== appId) {
fail.push(`identifier "${conf.identifier}" != capacitor appId "${appId}" — one product should carry one identity`)
} else {
ok.push(`identifier matches capacitor appId (${appId})`)
}
}
// 2. frontendDist must resolve to a real directory. Tauri resolves it RELATIVE TO src-tauri/,
// which is the single easiest thing to get wrong in this file.
const dist = conf?.build?.frontendDist
if (!dist) {
fail.push('build.frontendDist is unset — the webview would have nothing to load')
} else {
const resolved = resolve(ROOT, 'src-tauri', dist)
if (!existsSync(resolved)) {
fail.push(`build.frontendDist "${dist}" resolves to ${resolved}, which does not exist`)
} else {
ok.push(`frontendDist "${dist}" -> ${resolved.replace(ROOT + '/', '')}/`)
}
}
// 3. EVERY window url must exist inside frontendDist. This is the blank-white-window check.
const windows = conf?.app?.windows || []
if (!windows.length) {
fail.push('app.windows is empty — no window would open')
}
for (const w of windows) {
if (!w.url) { ok.push(`window "${w.title || '(untitled)'}" uses the default entry`); continue }
const page = resolve(ROOT, 'src-tauri', dist || '.', w.url)
if (!existsSync(page)) {
fail.push(`window url "${w.url}" does not exist in the built tree (${page.replace(ROOT + '/', '')}) — THIS LAUNCHES A BLANK WHITE WINDOW`)
} else {
const bytes = readFileSync(page, 'utf8').length
ok.push(`window url "${w.url}" exists (${bytes} bytes)`)
}
}
// 4. The Rust crate the config depends on must be present.
for (const f of ['src-tauri/Cargo.toml', 'src-tauri/build.rs', 'src-tauri/src/main.rs']) {
if (!existsSync(join(ROOT, f))) fail.push(`${f} is missing — \`cargo tauri build\` would fail`)
else ok.push(`${f} present`)
}
for (const line of ok) console.log(` · ${line}`)
if (fail.length) {
console.error(`\nTAURI-CONFIG: FAIL — ${fail.length} problem(s):`)
for (const f of fail) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log('\nTAURI-CONFIG: PASS — config points at files that exist; no blank-window defect.')
console.log(' NOT PROVEN HERE: that the binary builds or launches. That needs the Tauri CLI')
console.log(' (`cargo install tauri-cli`) plus system webview libs, then `cargo tauri build`.')

View file

@ -3,7 +3,7 @@
"version": "0.1.0",
"license": "Apache-2.0",
"private": true,
"description": "A portable bundler that generates embeddable JAMstack maps \u2014 one library, many map bundles that embed on any site, compose into a lazy-loaded SPA, or wrap a Capacitor Android/iOS app.",
"description": "A portable bundler that generates embeddable JAMstack maps — one library, many map bundles that embed on any site, compose into a lazy-loaded SPA, or wrap a Capacitor Android/iOS app.",
"type": "module",
"scripts": {
"dev": "bun x vite",
@ -20,8 +20,9 @@
"verify:full": "node library/scripts/research/verify-all.mjs --full",
"serve": "python3 -m http.server 4173 --bind 0.0.0.0 --directory public",
"serve:shell": "python3 -m http.server 4174 --bind 0.0.0.0 --directory library/layouts/app-wrapper-material",
"cap:sync": "./node_modules/.bin/cap sync android # NOT `bunx` \u2014 bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.",
"cap:sync": "./node_modules/.bin/cap sync android # NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.",
"cap:verify": "test -f capacitor/android/app/src/main/assets/public/index.html && echo \"OK webDir copied\" || (echo \"FAIL: empty assets/public -> blank white screen. Check webDir.\" && exit 1)",
"check:tauri": "node internal/check-tauri-config.mjs",
"apk:debug": "bun run build && bun run cap:sync && bun run cap:verify && cd capacitor/android && ./gradlew assembleDebug",
"apk:install": "adb install -r capacitor/android/app/build/outputs/apk/debug/app-debug.apk",
"phone": "bun run apk:debug && bun run apk:install",
@ -48,25 +49,25 @@
"check:template-literal": "node internal/template-literal-check.mjs",
"verify:map-paint": "node internal/verify-map-paint.mjs",
"check:sri": "node internal/sri-consistency-check.mjs",
"check:composite": "node internal/check-composite-freshness.mjs # a COMPOSITE is a COPY taken at its own build time: fails if a nested child's source is newer than the parent's bundle. NOT in build:unlocked on purpose \u2014 there everything was just built, so it could never catch the real case (a PARTIAL build). Run it before PUBLISH.",
"check:shots": "node internal/check-shot-freshness.mjs # a README screenshot that predates the bundle it shows is a picture telling a lie \u2014 and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs",
"check:library-index": "node internal/check-library-index.mjs # the DERIVED library menu must not fall behind library/. Compares the index against the newest library SOURCE (not a bundle \u2014 this generator scans library/ directly, with no build step between). Fix: bun run library-index",
"check:quoted-scripts": "node internal/check-quoted-scripts.mjs # docs/README-SECTION-3-cli-commands.md QUOTES the scripts block verbatim; it had silently drifted TWICE (a stale bunx cap:sync, and build:unlocked missing check:public-hygiene + check:label-joins). package.json is what RUNS \u2014 update the DOC.",
"capability-exemplars": "node internal/generate-capability-exemplars.mjs # regenerate docs/reference-capability-exemplars.md \u2014 the DERIVED answer to \"which bundle is the capability-N example?\", a question answered WRONGLY in three separate artifacts (a gate, the README, the /plugin-designer skill).",
"check:a11y-button-names": "node internal/check-a11y-button-names.mjs # every VISIBLE button must have an accessible name (aria-label / aria-labelledby / visible text \u2014 a `title` is a TOOLTIP and does NOT count). Measures the RENDERED DOM through shadow roots: a source grep cannot answer this (132 `<button>` hits, most with visible text). Needs a browser, so NOT in build:unlocked and NOT in CI. ALL=1 sweeps every profile.",
"check:catalog-identity": "node internal/check-catalog-identity.mjs # proves the LIVE server serves the CATALOG\u2019s exact bytes, by hashing each live bundle and comparing sha384 against the manifest\u2019s own integrity field. An HTTP 200 cannot do this: /app/doxa-maps/ returns 200 with a 2872-byte HTML SIGN-IN PAGE. Network-dependent, so NOT in build:unlocked and NOT in CI; SKIPs cleanly when unreachable. CATALOG=<dir> also requires the live manifest to match a catalog checkout.",
"check:composite": "node internal/check-composite-freshness.mjs # a COMPOSITE is a COPY taken at its own build time: fails if a nested child's source is newer than the parent's bundle. NOT in build:unlocked on purpose — there everything was just built, so it could never catch the real case (a PARTIAL build). Run it before PUBLISH.",
"check:shots": "node internal/check-shot-freshness.mjs # a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs",
"check:library-index": "node internal/check-library-index.mjs # the DERIVED library menu must not fall behind library/. Compares the index against the newest library SOURCE (not a bundle — this generator scans library/ directly, with no build step between). Fix: bun run library-index",
"check:quoted-scripts": "node internal/check-quoted-scripts.mjs # docs/README-SECTION-3-cli-commands.md QUOTES the scripts block verbatim; it had silently drifted TWICE (a stale bunx cap:sync, and build:unlocked missing check:public-hygiene + check:label-joins). package.json is what RUNS — update the DOC.",
"capability-exemplars": "node internal/generate-capability-exemplars.mjs # regenerate docs/reference-capability-exemplars.md — the DERIVED answer to \"which bundle is the capability-N example?\", a question answered WRONGLY in three separate artifacts (a gate, the README, the /plugin-designer skill).",
"check:a11y-button-names": "node internal/check-a11y-button-names.mjs # every VISIBLE button must have an accessible name (aria-label / aria-labelledby / visible text — a `title` is a TOOLTIP and does NOT count). Measures the RENDERED DOM through shadow roots: a source grep cannot answer this (132 `<button>` hits, most with visible text). Needs a browser, so NOT in build:unlocked and NOT in CI. ALL=1 sweeps every profile.",
"check:catalog-identity": "node internal/check-catalog-identity.mjs # proves the LIVE server serves the CATALOG’s exact bytes, by hashing each live bundle and comparing sha384 against the manifest’s own integrity field. An HTTP 200 cannot do this: /app/doxa-maps/ returns 200 with a 2872-byte HTML SIGN-IN PAGE. Network-dependent, so NOT in build:unlocked and NOT in CI; SKIPs cleanly when unreachable. CATALOG=<dir> also requires the live manifest to match a catalog checkout.",
"check:capability-exemplars": "node internal/check-capability-exemplars.mjs # REGENERATES and content-compares (not mtimes: a full build rewrites manifest.json every time, so an mtime rule would cry wolf after every build and get switched off).",
"check:capability-citations": "node internal/check-capability-citations.mjs # a doc that names a bundle as \"capability N\" must match manifest.json, which is what the BUILD computed. README captioned doxa-vision-card as cap-1; it ships 2 instances so it is parameterized (cap-2). The SAME error was fixed once in verify-5-capabilities.mjs and never reached the README \u2014 two copies of one fact, nothing forcing agreement.",
"check:shot-distinctness": "node internal/check-shot-distinctness.mjs # two README slots must not show the SAME view. README shipped \"List \u2014 page 1\" and \"page 2\" as the same picture: the shot chased a Next control and a scroll that DO NOT EXIST, every attempt inside try/catch so the miss was silent, and the PNGs differed by 0.16% render noise so no checksum caught them. Keys on url+VIEWPORT \u2014 same url at a different vp (deploy2 mobile) is intended.",
"check:shot-fidelity": "node internal/check-shot-fidelity.mjs # warns when THIS machine cannot RENDER what the shots must SHOW. `bun run shots` printed 13x OK while every emoji rendered as a tofu box \u2014 a glyph fallback loads perfectly (0 console errors, 0 HTTP>=400), so the generator cannot see it. Reports by default; --strict to block once an emoji font is installed.",
"shots": "node internal/readme-shots.mjs # regenerate docs/readme-images/*.png (fetches its own Mapbox token SERVER-SIDE, so the browser CORS block does not apply). Was UNREACHABLE \u2014 present but invoked by no script, which is how the 12 PNGs silently rotted a day behind the UI.",
"library-index": "node internal/generate-library-index.mjs # regenerate docs/reference-library-index.md \u2014 the DERIVED menu of the 84 reusable library pieces an author can import instead of rebuilding. Was UNREACHABLE, and the index did not exist at all: its header promised 'can't drift from the code' while nothing ever ran it.",
"check:done-evidence": "node internal/check-done-evidence.mjs # a card may not reach status:done with NO bluf AND NO fields \u2014 the Doxa team inherits this board and an evidence-less 'done' cannot be reconciled against the code. 10 pre-existing are baselined by id; the list should only ever SHRINK.",
"check:capability-citations": "node internal/check-capability-citations.mjs # a doc that names a bundle as \"capability N\" must match manifest.json, which is what the BUILD computed. README captioned doxa-vision-card as cap-1; it ships 2 instances so it is parameterized (cap-2). The SAME error was fixed once in verify-5-capabilities.mjs and never reached the README — two copies of one fact, nothing forcing agreement.",
"check:shot-distinctness": "node internal/check-shot-distinctness.mjs # two README slots must not show the SAME view. README shipped \"List — page 1\" and \"page 2\" as the same picture: the shot chased a Next control and a scroll that DO NOT EXIST, every attempt inside try/catch so the miss was silent, and the PNGs differed by 0.16% render noise so no checksum caught them. Keys on url+VIEWPORT — same url at a different vp (deploy2 mobile) is intended.",
"check:shot-fidelity": "node internal/check-shot-fidelity.mjs # warns when THIS machine cannot RENDER what the shots must SHOW. `bun run shots` printed 13x OK while every emoji rendered as a tofu box — a glyph fallback loads perfectly (0 console errors, 0 HTTP>=400), so the generator cannot see it. Reports by default; --strict to block once an emoji font is installed.",
"shots": "node internal/readme-shots.mjs # regenerate docs/readme-images/*.png (fetches its own Mapbox token SERVER-SIDE, so the browser CORS block does not apply). Was UNREACHABLE — present but invoked by no script, which is how the 12 PNGs silently rotted a day behind the UI.",
"library-index": "node internal/generate-library-index.mjs # regenerate docs/reference-library-index.md — the DERIVED menu of the 84 reusable library pieces an author can import instead of rebuilding. Was UNREACHABLE, and the index did not exist at all: its header promised 'can't drift from the code' while nothing ever ran it.",
"check:done-evidence": "node internal/check-done-evidence.mjs # a card may not reach status:done with NO bluf AND NO fields — the Doxa team inherits this board and an evidence-less 'done' cannot be reconciled against the code. 10 pre-existing are baselined by id; the list should only ever SHRINK.",
"check:wp-embed": "php wordpress/tests/wp-embed-contract-test.php",
"check:mobile": "bun run cap:sync && bun run cap:verify # deploy-2 CONTRACT check: needs NO JDK/SDK/device. cap sync is a file copy + plugin wiring, so it runs anywhere; cap:verify then asserts assets/public/index.html exists. Guards the SOFT FAILURE capacitor.config.json warns about: a wrong webDir yields an EMPTY assets/public and a BLANK WHITE SCREEN at launch, not an error.",
"check:settled": "node internal/settled-tree-gate.mjs",
"verify:5-caps": "node internal/verify-5-capabilities.mjs",
"verify:gate1": "node internal/acceptance/big-picture-acceptance.mjs # the Gate-1 chain end-to-end against LIVE data: header emits research-filter -> composite passes props down -> the REAL cap-2 grid re-filters -> count reflected back up. Asserts the NEGATIVE (a filter that must return 0 does) and RESET. Was an ORPHAN \u2014 present in internal/acceptance/ but invoked by NO script, so it could never report anything."
"verify:gate1": "node internal/acceptance/big-picture-acceptance.mjs # the Gate-1 chain end-to-end against LIVE data: header emits research-filter -> composite passes props down -> the REAL cap-2 grid re-filters -> count reflected back up. Asserts the NEGATIVE (a filter that must return 0 does) and RESET. Was an ORPHAN — present in internal/acceptance/ but invoked by NO script, so it could never report anything."
},
"devDependencies": {
"@capacitor/android": "7",

3
src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Cargo build output. The committed source is Cargo.toml + build.rs + src/ only,
# mirroring capacitor/android where only the generated assets/public/ is ignored.
/target

25
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
# Cargo.toml — the desktop shell for the cap-5 doxa-maps SPA (deploy method 5).
#
# THIS CRATE INTENTIONALLY CONTAINS NO APPLICATION LOGIC. The product is the web tree in
# public/; this is a window around it, the desktop counterpart of capacitor/android.
# Anything that belongs to the maps belongs in plugin-designs/, never here.
#
# Versions are left to the lockfile on first `cargo build` rather than pinned by hand —
# pinning a version this file has never resolved would be a guess presented as a fact.
[package]
name = "doxa-maps-desktop"
version = "0.1.0"
description = "Doxa Maps — desktop shell for the capability-5 maps SPA"
edition = "2021"
rust-version = "1.77"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
[features]
# Tauri's standard custom-protocol feature: used for production bundles.
custom-protocol = ["tauri/custom-protocol"]

5
src-tauri/build.rs Normal file
View file

@ -0,0 +1,5 @@
// build.rs — Tauri's standard build script. It reads ../tauri.conf.json and generates the
// context (window config, bundle metadata) the binary embeds. Nothing project-specific.
fn main() {
tauri_build::build()
}

16
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,16 @@
// main.rs — the whole desktop shell.
//
// A Tauri app around the SAME public/ tree Capacitor and the CDN serve. The window, its
// size and the entry page all come from ../tauri.conf.json (app.windows[0]) so there is
// ONE place to change them and no value is duplicated between config and code.
//
// No commands, no IPC, no state. The maps are a compiled Vue custom element loaded by the
// webview exactly as a browser loads them; giving this shell logic would fork behaviour
// between desktop and web, which is the opposite of what one-build-four-consumers buys us.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running the Doxa Maps desktop shell");
}

80
tauri.conf.json Normal file
View file

@ -0,0 +1,80 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Doxa Maps",
"version": "0.1.0",
"identifier": "life.doxa.maps",
"//identifier": [
"DELIBERATELY THE SAME ID AS CAPACITOR (capacitor.config.json appId). One product, two native",
"shells: Android via Capacitor, desktop via Tauri. Same reverse-DNS identity, same web payload."
],
"//frontendDist": [
"frontendDist = ../public — THE SAME WEB-ROOT CAPACITOR USES (capacitor.config.json webDir: 'public').",
"It is a POINTER, NOT A COPY: public/ is the single served surface, so ONE `bun run build` feeds every",
"consumer. This file lives at the repo root and Tauri resolves frontendDist RELATIVE TO src-tauri/,",
"hence '../public' rather than 'public'.",
"ONE build, FOUR consumers, all reading the same root:",
" 1. Railway / CDN — serve public/ statically",
" 2. Capacitor — webDir; `cap sync` copies public/ into the Android assets",
" 3. local staging — `bun run serve` (public/ on :4173)",
" 4. Tauri — THIS frontendDist; the webview loads it from the bundled resources",
"⚠️ SAME SOFT FAILURE AS CAPACITOR: a wrong frontendDist yields a BLANK WHITE WINDOW at launch, not an",
"error. Assert the entry page exists before shipping (see `check:tauri` in package.json)."
],
"build": {
"frontendDist": "../public",
"//beforeBuildCommand": [
"Left EMPTY on purpose. `bun run build` is the authoritative single-writer of public/ (CI runs it as",
"build:unlocked). Wiring it here would make a desktop build a SECOND writer of the shared output tree —",
"exactly the concurrent-clean race the build lock exists to prevent. Build first, then package."
],
"beforeBuildCommand": ""
},
"app": {
"//windows": [
"1440x900 matches the viewport the cap-5 SPA is verified at (the rail renders 11 rows there).",
"The window opens on the cap-5 SPA's leaf page — the flagship app, not the gallery index."
],
"windows": [
{
"title": "Doxa Maps",
"width": 1440,
"height": 900,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"url": "doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html"
}
],
"//security": [
"CSP null = inherit whatever the served page declares, matching how the same tree behaves on the CDN.",
"Do NOT tighten this without re-running the mount check: the bundles self-host mapbox-gl from",
"public/assets/vendor/ (the no-CDN rule), so a naive script-src would break every map."
],
"security": { "csp": null }
},
"//THE_TOKEN_CAVEAT": [
"★ THE ONE RUNTIME SURPRISE, stated up front because Capacitor already hit it.",
"A Tauri webview serves the app from a CUSTOM ORIGIN (tauri://localhost, or http://tauri.localhost on",
"Windows) — NOT from doxa.life. Every map bundle fetches its Mapbox token from the configured tokenUrl,",
"so that endpoint MUST send CORS headers permitting the Tauri origin, exactly as capacitor.config.json",
"documents for https://localhost.",
"THE FIX IS A RESPONSE HEADER ON THE TOKEN HOST, NOT A CHANGE IN THIS FILE.",
"Measured 2026-09-21: https://doxa.life/api/maps/token returns 200; https://nuxtbase.app/api/maps/token",
"returns 404 — so a desktop build pointed at nuxtbase would launch with blank basemaps even though the",
"window and the element mount correctly. Verify the token endpoint before blaming the shell."
],
"bundle": {
"active": true,
"targets": "all",
"//icon": [
"No icon set is committed yet. `cargo tauri icon <png>` generates the platform set into src-tauri/icons/.",
"Left out rather than pointing at a path that does not exist — a config that references a missing file",
"fails at BUILD time with a confusing error."
]
}
}