493 lines
21 KiB
Markdown
493 lines
21 KiB
Markdown
# UCE Code Review — Full Findings (2026-06-11)
|
||
|
||
Scope: the pending working-tree changes (73 modified tracked files plus new
|
||
untracked sources — notably `src/lib/sqlite-connector.cpp/.h` and the
|
||
uce-starter theme/router rework).
|
||
|
||
Method: seven independent review angles (line-by-line diff scan,
|
||
removed-behavior audit, cross-file call tracing, reuse, simplification,
|
||
efficiency, altitude), followed by a verification pass on the correctness
|
||
candidates. Status legend:
|
||
|
||
- **Confirmed** — verified against the working tree, decisive lines quoted.
|
||
- **Plausible** — surfaced by a review angle, not individually re-verified.
|
||
- **Refuted** — investigated and found not to be an issue (kept here so
|
||
nobody re-flags it).
|
||
|
||
---
|
||
|
||
## Part 1 — Correctness (all confirmed)
|
||
|
||
### 1.1 XSS: island props break out of single-quoted attribute
|
||
|
||
**File:** `site/examples/uce-starter/components/theme/web_affordances.uce:12`
|
||
|
||
The island component emits JSON props inside a single-quoted attribute
|
||
(`data-props='<?: html_escape(payload) ?>'`), but `html_escape()`
|
||
(`src/lib/functionlib.cpp:936`) escapes only `& < > "`, and `json_encode` /
|
||
`json_escape` (`src/lib/dtree.cpp:794`) never escape apostrophes. Any `'` in a
|
||
prop value terminates the attribute.
|
||
|
||
**Failure:** a view passes user-influenced prop text containing an apostrophe,
|
||
e.g. `x' autofocus onfocus='alert(1)` — the attribute terminates early and
|
||
attacker-controlled attributes/event handlers land on the div. Even benign
|
||
values like `Don't` silently truncate the payload.
|
||
|
||
**Fix:** use a double-quoted attribute (html_escape covers `"`), or extend
|
||
`html_escape` to escape `'` as `'`.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 1.2 Crash: positional `?` placeholders in sqlite_query
|
||
|
||
**File:** `src/lib/sqlite-connector.cpp:86`
|
||
|
||
`bind_params` constructs a `String` directly from
|
||
`sqlite3_bind_parameter_name(stmt, i)`, which returns NULL for nameless
|
||
positional `?` parameters. `std::string(nullptr)` is UB and crashes before the
|
||
`if(name == "") continue;` guard on the next line can run.
|
||
|
||
**Failure:** any page calling
|
||
`sqlite_query(db, "select * from notes where id = ?", params)` with a bare `?`
|
||
instead of `:name` segfaults the worker (500 recovery page).
|
||
|
||
**Fix:** remove support for positional placeholders from sqlite and mysql API in favor of named placeholders, adjust the documentation accordingly.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 1.3 HTTP parsing: split_http_headers assumes lines[0] is the request line
|
||
|
||
**File:** `src/lib/functionlib.cpp:754`
|
||
|
||
The rewritten parser unconditionally treats the first line as the HTTP request
|
||
line. The old parser located the first colon-free line, which tolerated a
|
||
leading CRLF (RFC 7230 §3.5) and header-only input. Two failure modes:
|
||
|
||
- The direct-HTTP caller (`src/fastcgi/src/fcgicc.cc:693`) passes the raw
|
||
buffer unstripped — a client sending a stray leading CRLF before
|
||
`GET /x.uce HTTP/1.1` gets the request line silently dropped (empty
|
||
`REQUEST_METHOD`, request fails).
|
||
- Header-only input (`Host: example.test\nX-Token: abc`, e.g. page code
|
||
parsing a raw header block) parses `Host:` as `REQUEST_METHOD` and loses
|
||
`HTTP_HOST` entirely. The function is directly callable from .uce pages.
|
||
|
||
**Fix:** skip leading empty lines before consuming the request line, and only
|
||
treat a colon-free first line as the request line.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 1.4 Silent data loss: multi-statement SQL drops everything after the first `;`
|
||
|
||
**File:** `src/lib/sqlite-connector.cpp:178`
|
||
|
||
`SQLite::query` passes `0` for `pzTail` to `sqlite3_prepare_v2` and never
|
||
checks for remaining SQL, so multi-statement strings execute only the first
|
||
statement and still report `ok`.
|
||
|
||
**Failure:** a migration page runs
|
||
`sqlite_query(db, "create table t(id integer); insert into t values(1);")` —
|
||
only the CREATE executes, the INSERT is silently dropped, `sqlite_error()`
|
||
says `ok`, leaving the database half-migrated with no error signal.
|
||
|
||
**Fix:** capture `pzTail` and either loop over remaining statements or raise
|
||
an error when trailing SQL is present.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 1.5 Diagnostics regression: fault backtrace captured after siglongjmp
|
||
|
||
**File:** `src/linux_fastcgi.cpp:895`
|
||
|
||
The in-handler `capture_backtrace_string` call (`request_fault_trace`) was
|
||
deleted; the trace is now captured after `siglongjmp` back in
|
||
`handle_complete`, where the faulting stack has already been unwound. No
|
||
capture remains in `on_request_fault_signal`.
|
||
|
||
**Failure:** a `.uce` page null-derefs → SIGSEGV → the error page's Trace
|
||
shows only `handle_complete`/`main` frames. The faulting unit's frames are
|
||
gone, making crash reports undiagnosable beyond the signal number (the README
|
||
still advertises a native backtrace of the failure).
|
||
|
||
**Fix:** restore the capture inside `on_request_fault_signal` (on the faulting
|
||
stack) and hand the result across the longjmp, as the previous code did.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 1.7 Memory leak: SQLite wrapper objects never freed by request cleanup
|
||
|
||
**File:** `src/lib/sqlite-connector.cpp:248`
|
||
|
||
`cleanup_sqlite_connections()` closes the raw `sqlite3` handles tracked in
|
||
`resources.sqlite_connections` but never deletes the heap-allocated `SQLite`
|
||
wrappers from `sqlite_connect` (`new` without `delete`; no arena allocator is
|
||
compiled in). `sqlite_disconnect` itself is correct (`delete db` after
|
||
unregistering). Note: this mirrors a pre-existing identical leak in the MySQL
|
||
connector (`cleanup_mysql_connections`, `mysql-connector.cpp:298-304`).
|
||
|
||
**Failure:** a page calls `sqlite_connect()` per request and relies on
|
||
end-of-request cleanup instead of `sqlite_disconnect()` — exactly what the
|
||
cleanup path exists for. One wrapper leaks per request; unbounded RSS growth
|
||
in the long-lived FastCGI worker.
|
||
|
||
**Fix:** track the wrapper objects (not raw handles) in
|
||
`resources.sqlite_connections` and delete them in cleanup; fix the MySQL
|
||
connector the same way, or factor a shared registry (see 5.1).
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 1.8 Asset shims emit stylesheets mid-`<body>`
|
||
|
||
**File:** `site/examples/uce-starter/components/example/marketing_assets.uce:8`
|
||
(also `theme_assets.uce`, `gauges/assets.uce`)
|
||
|
||
The new ONCE-based asset shims fire inside the view's `ob_start()` capture in
|
||
`index.uce` (lines 67-85: view is captured into `fragments["main"]`, then
|
||
`themes/page` renders), so their `<link rel="stylesheet">` output is baked
|
||
into the main fragment and spliced into the content div — inside `<body>`,
|
||
not `<head>`.
|
||
|
||
**Failure:** every marketing/theme/gauges page ships its stylesheet mid-body
|
||
(FOUC, invalid-ish markup). The deleted `page_shell` asset registry rendered
|
||
these in `<head>`. While this behavior is often okay in practice, we should
|
||
improve on this to encourage cleaner output.
|
||
|
||
**Fix:** Part A: restore a registration mechanism: record component and asset output
|
||
that's intended as once per page in context.call["fragments"]["once"] by default
|
||
and the page template component can then explicitly slot this in where appropriate.
|
||
|
||
Part B: Modify the preprocessor so directives support attributes like this:
|
||
ONCE(Request& context)
|
||
@fragment my-fragment-name
|
||
{
|
||
...
|
||
}
|
||
|
||
Which will then automatically (in this example) slot the output into
|
||
context.call["fragments"]["my-fragment-name"] instead of the default slot name "once".
|
||
In the future we'll introduce more attributes with this syntax. Using this flexible mechanism for the fragment slot
|
||
name, we can leave it up to the page template where to slot in what. ONCE, COMPONENT,
|
||
and RENDER should support the fragment attribute.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 1.9 Error page "Generated C++" hint prints a nonexistent path
|
||
|
||
**File:** `src/linux_fastcgi.cpp:79`
|
||
|
||
`render_request_failure` computes the hint as
|
||
`path_join(BIN_DIRECTORY, SCRIPT_FILENAME) + ".cpp"`, but `path_join` returns
|
||
an absolute child unchanged (`src/lib/sys.cpp:179`), while the compiler builds
|
||
the real path by plain concatenation `BIN_DIRECTORY + src_path`
|
||
(`src/lib/compiler.cpp:631-636`). SCRIPT_FILENAME is always absolute in the
|
||
FastCGI path.
|
||
|
||
**Failure:** every runtime failure page prints e.g.
|
||
`Generated C++: /var/www/site/page.uce.cpp` — the BIN_DIRECTORY prefix is
|
||
silently dropped and the path never exists (real file:
|
||
`/var/cache/uce/work/var/www/site/page.uce.cpp`).
|
||
|
||
**Fix:** export one helper from `compiler.cpp` that maps a source file to its
|
||
generated artifact (`su->pre_path + "/" + su->pre_file_name` — the new
|
||
`compiler_format_compile_failure` in this same changeset already computes it
|
||
correctly) and use it in both places.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 1.10 O(n²) and per-row deep copies in dtree_map / dtree_filter
|
||
|
||
**File:** `src/lib/functionlib.cpp:182`
|
||
|
||
Both helpers call `tree.is_list()` inside the per-element callback —
|
||
`is_list()` (`src/lib/dtree.cpp:165`) walks the entire map, making the
|
||
operation O(n²) — even though both already compute `is_list()` once before
|
||
the loop. Additionally, `DTree::each` (`dtree.h:22`) takes the callback
|
||
element by value (`DTree t`), deep-copying every subtree per iteration.
|
||
|
||
**Failure:** mapping over a 1000-row `sqlite_query` result does ~1M
|
||
key-validation checks plus a full deep copy of each row tree.
|
||
|
||
**Fix:** hoist `is_list()` into a `bool` local reused in the callback; change
|
||
`each()` to pass `const DTree&`.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 1.11 Proactive compiler child can std::terminate on lock failure
|
||
|
||
**File:** `src/lib/compiler.cpp:288`
|
||
|
||
`compiler_with_registry_lock` now throws `std::runtime_error` when the
|
||
registry lock file can't be opened (previously: warning + proceed unlocked).
|
||
`run_proactive_compiler` (`src/linux_fastcgi.cpp:1270-1372`) calls
|
||
`compiler_list_known_units` / `compiler_set_known_units` with no try/catch
|
||
anywhere in the chain, so a transient failure (fd exhaustion, disk full)
|
||
aborts the forked child via `std::terminate`.
|
||
|
||
**Blast radius is small:** the proactive compiler runs in a forked child that
|
||
the main loop respawns each iteration, and request-path callers are protected
|
||
by the try/catch in `handle_complete`. Still, a catch-and-log around the
|
||
proactive scan is cheap insurance.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
---
|
||
|
||
## Part 2 — Reuse / duplication (plausible unless noted)
|
||
|
||
### 2.1 request_query_path() duplicates request_query_route()
|
||
|
||
**File:** `src/lib/uri.cpp:311`
|
||
|
||
`request_query_path()` copy-pastes `request_query_route()`'s (line 325)
|
||
first-keyless-segment scan verbatim — two identical "find first &-part
|
||
without =" loops plus `route_path_sanitize` calls that must be kept in sync.
|
||
It also has **zero callers** in `src/` or `site/` (only a doc page references
|
||
it).
|
||
|
||
**Fix:** implement as
|
||
`return request_query_route(context, default_path)["l_path"].to_string();`.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 2.2 list_filter / list_map re-implement the generic filter<T> / map templates
|
||
|
||
**File:** `src/lib/functionlib.cpp:71`
|
||
|
||
`StringList` is `typedef std::vector<String>` (`types.h:52`), so the existing
|
||
`filter<T>` template (`functionlib.h:68`, declared ~10 lines above the new
|
||
`list_*` declarations) already does exactly what `list_filter` does. Two
|
||
filter implementations now live in the same module; behavior fixes must be
|
||
applied twice.
|
||
|
||
**Fix:** drop it and point the doc page at `filter`); same consideration for `list_map`.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 2.3 Hand-rolled redirects instead of the redirect() helper
|
||
|
||
**Files:** `site/examples/uce-starter/views/account/login.uce:13`,
|
||
`logout.uce:6`, `profile.uce:8`, `site/demo/sqlite.uce:21-22`
|
||
|
||
Four call sites inline `context.set_status(302, "Found");
|
||
context.header["Location"] = ...` instead of calling the existing runtime
|
||
helper `redirect(String url, s32 code = 302)` (`src/lib/uri.cpp:414`). This
|
||
replaced the single `app_redirect()` helper the changeset deleted.
|
||
|
||
**Note — security aspect refuted (see 6.1):** headers are sanitized centrally
|
||
on write-out, so this is a pure reuse nit, not a vulnerability.
|
||
|
||
**Fix:** use `redirect(app_link("account/profile", context))` or restore one
|
||
`app_redirect` wrapper.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 2.5 Asset tag boilerplate repeated across ~a dozen files
|
||
|
||
**Files:** `components/example/marketing_assets.uce:5`,
|
||
`components/example/theme_assets.uce`, `components/gauges/assets.uce`,
|
||
plus inline `<link rel="stylesheet" href="<?= app_asset_url(...) ?>" />`
|
||
blocks in `components/theme/head.uce`, `components/data/widgets.uce`,
|
||
`components/workspace/primitives.uce`, `views/dashboard.uce`
|
||
|
||
About a dozen hand-written copies of the stylesheet/script tag pattern around
|
||
`app_asset_url()`; a versioning or attribute change (defer/integrity) touches
|
||
every file. The marketing and theme shims differ only in one CSS path.
|
||
|
||
**Fix:** collapse the three shims into one parameterizable assets component.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
---
|
||
|
||
## Part 3 — Simplification (plausible)
|
||
|
||
### 3.1 request_query_route() emits a derivable "rejected" flag
|
||
|
||
**File:** `src/lib/uri.cpp:347`
|
||
|
||
The route tree contains both `route["valid"]` and `route["rejected"]` where
|
||
`rejected` is exactly `!valid`, and nothing in the codebase reads
|
||
`"rejected"`. Redundant derivable state doubles the invariant surface — a
|
||
future path that sets one flag without the other produces a route tree that
|
||
lies.
|
||
|
||
**Fix:** drop the `"rejected"` key; callers needing it can write
|
||
`!route["valid"].to_bool()`.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 3.2 Starter router carries write-only diagnostic state
|
||
|
||
**File:** `site/examples/uce-starter/index.uce:38`
|
||
|
||
The router rewrite adds candidate `kind`/`matched` fields and
|
||
`context.call["route"]["candidates"]/["resolved"]/["missed"]`, none of which
|
||
is read by any component, theme, or test. It also uses `dtree_filter` to
|
||
`file_exists`-stat every candidate after the first match is already found.
|
||
~55 lines plus a builder helper replace what the deleted `app_resolve_view`
|
||
did with three early-return `file_exists` checks.
|
||
|
||
**Fix:** remove unused parts.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 3.4 Single-link shim components where a direct ONCE block suffices
|
||
|
||
**File:** `site/examples/uce-starter/components/example/marketing_assets.uce:8`
|
||
(and `theme_assets.uce`)
|
||
|
||
These are single-`<link>` shim files with empty COMPONENT bodies, invoked via
|
||
`print(component(...))` of an empty string. Routed views are mutually
|
||
exclusive per request, so the cross-unit dedup the shims provide can never
|
||
trigger; they exist only to host one ONCE line at the cost of two extra files
|
||
and an indirection on every view. `dashboard.uce:3-6` already demonstrates
|
||
the simpler form (ONCE block directly in the view). `gauges/assets.uce` is
|
||
the justified case (multiple sibling components per page) and can stay.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 3.5 Compile-failure artifact file is self-referential
|
||
|
||
**File:** `src/lib/compiler.cpp:847`
|
||
|
||
`compile_shared_unit()` overwrites `su->compiler_messages` with the formatted
|
||
failure report, then writes that report into `compile_output_file_name` — the
|
||
same artifact the report's own "Compile output:" line
|
||
(`compiler_format_compile_failure`, line 800) points readers at. No copy of
|
||
the raw, unformatted compiler output survives for tooling to parse.
|
||
|
||
**Fix:** keep raw messages as the stored/recorded artifact content and format
|
||
only at the print/response boundary.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
---
|
||
|
||
## Part 4 — Efficiency (plausible unless noted)
|
||
|
||
### 4.1 QUERY_STRING parsed twice per request
|
||
|
||
**File:** `src/linux_fastcgi.cpp:744`
|
||
|
||
`prepare_request_body_maps` calls `request_populate_context_params` (which
|
||
splits QUERY_STRING on `&` + uri-decodes inside `request_query_route`) and
|
||
then `parse_query(QUERY_STRING)` re-splits and re-decodes the identical
|
||
string on the next line. Runs on every HTTP request, CLI invocation, and
|
||
websocket event. Route params are also computed eagerly for requests that
|
||
never read `ROUTE_*`/`BASE_URL`.
|
||
|
||
**Fix:** parse QUERY_STRING once into `request.get` first and derive the
|
||
route token from that single pass, or populate the route params lazily.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 4.2 Repeated normalization in request_populate_context_params
|
||
|
||
**File:** `src/lib/uri.cpp:351`
|
||
|
||
Per request: `route_path_normalize` runs three times on the same path
|
||
(directly, inside `route_path_sanitize`, and inside `route_path_is_safe`),
|
||
`request_script_url` is computed twice (for SCRIPT_URL and again inside
|
||
`request_base_url`), and `route_path_normalize` (line 255) strips slashes via
|
||
`path = path.substr(1)` in a while loop — O(n²) copies per slash run.
|
||
|
||
**Fix:** normalize once and pass the normalized string down; compute
|
||
script_url into a local used by both params; replace the substr loops with
|
||
`find_first_not_of("/")` / `find_last_not_of("/")` and a single substr.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 4.4 collect_rows rebuilds column-name strings for every row
|
||
|
||
**File:** `src/lib/sqlite-connector.cpp:118`
|
||
|
||
For R rows × C columns the row loop does R×C `sqlite3_column_name` calls plus
|
||
R×C String heap constructions and map inserts keyed on the fresh string,
|
||
though column names are invariant across rows. A 10k-row, 8-column result
|
||
builds 80k redundant name strings per query.
|
||
|
||
**Fix:** build a `std::vector<String> names(column_count)` once before the
|
||
step loop and index it inside (`row[names[i]]`).
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 4.5 bind_params copies the params map twice and binds SQLITE_TRANSIENT
|
||
|
||
**File:** `src/lib/sqlite-connector.cpp:95`
|
||
|
||
`SQLite::query` takes `StringMap` by value and passes it by value again to
|
||
`bind_params`, then binds with `SQLITE_TRANSIENT`, forcing SQLite to memcpy
|
||
each value a third time — even though the copied map outlives the statement
|
||
(it lives until after `sqlite3_finalize`).
|
||
|
||
**Fix:** pass `const StringMap&` through `query()`/`bind_params` and bind
|
||
with `SQLITE_STATIC` (or at least drop the two by-value map copies).
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 4.6 Per-request connection opens re-run pragmas; busy_timeout set twice
|
||
|
||
**File:** `src/lib/sqlite-connector.cpp:15`
|
||
|
||
`connect()` calls `sqlite3_busy_timeout(5000)` and then
|
||
`apply_default_pragmas` runs `PRAGMA busy_timeout = 5000` again (pure
|
||
duplicate). `PRAGMA journal_mode = WAL` is persistent in the database file
|
||
but re-issued on every open. Because cleanup closes all handles at request
|
||
end, a page like `site/demo/sqlite.uce` pays `sqlite3_open_v2` + 4 pragmas +
|
||
`CREATE TABLE IF NOT EXISTS` on every request.
|
||
|
||
**Fix:** drop the redundant busy_timeout pragma; cache open
|
||
connections per worker keyed by path across requests (resetting state at
|
||
request end) so pragmas and schema checks run once per worker.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
---
|
||
|
||
## Part 5 — Altitude / design (plausible unless noted)
|
||
|
||
### 5.3 Test suites hand-registered in three parallel lists — already drifting
|
||
|
||
**Files:** `site/tests/index.uce:15`, `tests/plugins/uce_site_suite.py`
|
||
|
||
A test page must be registered in three places: the `site/tests/*.uce` file
|
||
itself, a `site_tests_card()` line in `index.uce`, and a tuple in
|
||
`uce_site_suite.py`. The new `sqlite.uce` was added to all three by hand. The
|
||
lists have already drifted: `site/tests/call_helpers.uce` exists on disk but
|
||
appears in neither `index.uce` nor any plugin list, and
|
||
`security_headers.uce` is covered only by the security-smoke plugin, not the
|
||
index cards — new suites can silently fall out of the dashboard and/or CI.
|
||
|
||
**Fix:** enumerate `site/tests/*.uce` with ls()/glob in both `index.uce` and
|
||
`uce_site_suite.py`, with title/tags metadata declared once (in the test page
|
||
or a single shared manifest).
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 5.4 compiler_developer_hints() couples the runtime to clang's English message text
|
||
|
||
**File:** `src/lib/compiler.cpp:778`
|
||
|
||
The hint table pattern-matches hardcoded English clang diagnostic substrings
|
||
("no member named", "expected ';'", ...). A clang version bump, a switch to
|
||
gcc (COMPILE_SCRIPT is user-configurable server config), or localized
|
||
diagnostics silently degrades every hint to the generic fallback, and each
|
||
new error class means hand-extending an if-chain in runtime C++.
|
||
|
||
**Fix:** the excerpt mechanism added in the same change
|
||
(`compiler_format_compile_failure`'s source/generated excerpts + artifact
|
||
paths) already carries the diagnostic value; make the hints a data-driven
|
||
table or drop them in favor of the excerpts.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|
||
|
||
### 5.5 Generated-artifact path computed by hand in two places
|
||
|
||
**File:** `src/linux_fastcgi.cpp:79` vs `src/lib/compiler.cpp:631-636`
|
||
|
||
The deeper-fix framing of 1.9: the moment the compiler's artifact layout or
|
||
naming changes (hashing, per-config subdirs), any hand-recomputed path goes
|
||
stale. One exported source-file → artifact-path helper in `compiler.cpp`,
|
||
used by both the compile-failure formatter and the runtime failure page.
|
||
|
||
**Status:** fixed, but fix not verified yet.
|