commit 8f98f2a5cd63d6b3f66614556049fb30d0d2c5ce
parent 94b8b0768619568af475938af47f6b9a0e9a93b3
Author: James Eversole <james@eversole.co>
Date: Mon, 12 Oct 2026 19:05:26 -0500
Add native packages and reproducible workspaces; unify playground and factor pattern dispatch
Diffstat:
84 files changed, 6679 insertions(+), 740 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -21,6 +21,16 @@ update callers and independent expectations, and retain negative/security contro
Git makes source experiments reversible; retained accepted toolchains provide
executable rollback. Neither replaces testing.
+## Documentation discipline
+
+`docs/` is for durable documentation, never work-in-progress notes. Do not put
+agent progress reports, provisional findings, in-flight plans, merge/generation
+reminders, handoff notes, or scratch investigations there—even inside an existing
+guide. Keep those in conversation, commit messages, or external scratch artifacts.
+Document supported usage, semantics, implementation contracts, and reproducible
+validation procedures; keep implementation details beside source/tests. Do not
+present unvalidated changes as shipped behavior.
+
## Build & Test
For tricu compiler build/run commands and focused checks, use
diff --git a/demos/interactionTrees/arboricxServer.tri b/demos/interactionTrees/arboricxServer.tri
@@ -7,6 +7,7 @@
-- Run with --allow-write /tmp/store --allow-read /tmp/store
--
-- Endpoints:
+-- GET /_arboricx/package/:name/:version -> exact release record
-- GET /_arboricx/health -> "OK"
-- POST /_arboricx/bundle -> upload bundle, returns hash
-- GET /_arboricx/bundle/hash/:h -> download bundle by hash
diff --git a/demos/package-inspector/main.tri b/demos/package-inspector/main.tri
@@ -0,0 +1,23 @@
+!import foundation as B
+!import fileio as IO
+!import inspection as Inspect
+!export main
+
+-- These three modules are generated offline package bindings, not library source.
+-- main accepts a path through `arboricx run --arg PATH --io --allow-read PATH`.
+-- Select --no-result so the final IO value adds no bytes to the report.
+-- Read once: the digest and report always describe the same byte snapshot.
+main path = (IO.io (IO.bind (IO.readFile path)
+ (reply : B.matchResult
+ (message _ : IO.abortIO (B.append "Read failed: " message))
+ (bytes _ : B.matchResult
+ (code _ : IO.abortIO (B.append "Invalid Arboricx bundle: " (B.append (Inspect.describeError code)
+ (B.append " (code " (B.append (B.showNumber code) ")")))))
+ (summary _ : IO.bind (IO.sha256Hex bytes)
+ (digestReply : B.matchResult
+ (message _ : IO.abortIO (B.append "Hash failed: " message))
+ (digest _ : IO.putStr (B.append "arboricx.inspect.v1\nbundle-sha256\t"
+ (B.append digest (B.append "\n" (Inspect.renderSummary summary)))))
+ digestReply))
+ (Inspect.inspectBundle bytes))
+ reply)))
diff --git a/demos/package-inspector/tricu.project b/demos/package-inspector/tricu.project
@@ -0,0 +1,4 @@
+arboricx.project.v1
+foundation home tricu-base 0.1.0
+fileio home tricu-file-io 0.1.0
+inspection home arboricx-inspection 0.1.0
diff --git a/docs/build-and-run.md b/docs/build-and-run.md
@@ -85,6 +85,15 @@ permissions. Use trusted store directories. See [execution options](tri-tools.md
`WORK` is temporary and contains all objects created by this example. Keep it if
you want to reuse them, or remove it when finished.
+For exact-version registry publication and dependency fetching, see
+[Packages](modules.md.html#exact-version-packages-from-trusted-registries).
+For a whole application dependency set, use a
+[project declaration and consolidated lock](modules.md.html#project-dependencies);
+resolve/update, fetch, bind and check remain separate from compilation. Offline
+[explain and diff reports](modules.md.html#explain-selections-and-compare-locks)
+show selected dependencies, local evidence and structural changes without running
+exported programs.
+
## Explicit compiler selection
For development or a pinned alternate compiler, import a caller-selected bundle:
@@ -101,6 +110,9 @@ Use the manifest hash printed on the last import line, not a mutable alias or
individual export hash. `--tool-store` requires `--toolchain` and otherwise defaults
to the application store. An explicit selection never falls back to package data.
+For retained source/compiler/dependency inputs and an independent offline replay,
+see [provenance capture, verify and rebuild](modules.md.html#retained-build-inputs-and-reproduction).
+
## Interactive use
Run `result/bin/tricu` (or `result/bin/tricu repl`) for a portable session.
@@ -111,7 +123,8 @@ multiline blocks, imports, loading files, and session operations; see
## Where next
- [Language features](language-features.md.html) and [contracts](contracts.md.html).
-- [Modules](modules.md.html) for imports and exports.
+- [Modules](modules.md.html) for imports and exports, including the
+ [offline package inspector](modules.md.html#first-published-libraries-an-offline-bundle-inspector).
- [Tools](tri-tools.md.html) for formatting, IO, and compiler options.
- [Testing](validation.md.html): start with `nix flake check`; understand the difference
between reference tests, candidate checks, and compiler acceptance.
diff --git a/docs/compiler.md b/docs/compiler.md
@@ -108,6 +108,31 @@ groups become nested bindings; recursive groups add their self binder explicitly
Preserve opaque external trees, capture order, spans, and unselected-branch demand
when changing these passes.
+### Outer-constructor factoring
+
+The `source.pattern` lowerer factors one outer-constructor test
+per equation group. The first row must reach that test: every preceding column
+in every row must contain only a wildcard or plain capture. At the selected
+column, only constructor, wildcard, and plain-capture patterns qualify. Literal,
+pin, and whole patterns at that column retain sequential lowering. No independent
+argument or child tests are moved ahead of earlier tests.
+
+Each constructor branch retains compatible rows in source order, reuses the
+already available children, and preserves the original subject for fallback
+captures. Child expressions shift through failure binders alongside the ordinary
+capture context. A row guaranteed to match within that branch needs no failure
+continuation; sequential lowering likewise omits the final irrefutable row's
+unused continuation. Nested refutable patterns keep their existing left-to-right
+matching and saved-sibling protocol. Validation still checks all authored rows.
+
+Factoring can change compiled function representations; intensional tree
+observations remain compiler-dependent. It does not change evaluator rules,
+data encodings, or the equality implementation. The
+[comparison probe](validation.md.html#pattern-lowering-comparison-opt-in) checks
+results, diagnostics, demand, code-generation targets, and costs using explicit
+compiler identities. Source-level checks do not substitute for compiler artifact
+acceptance.
+
## Located representations
Lexer token kinds:
@@ -153,6 +178,39 @@ explicit compiler identity used in receipts. Native module publication uses
indexed objects with the same name-neutral format as CLI import. See [Storage](storage.md.html) for these identities and the
complete-interface cache rules.
+## Haskell evaluation boundary
+
+`Research.T` has lazy constructor fields, and `Research.apply` implements the
+kernel rules without an explicit application/thunk node. Pending reductions
+live in Haskell thunks. Constructor application returns an outer Stem/Fork without
+forcing its children; constant application ignores its discarded argument;
+triage inspects the discriminator only as needed. This is demand-driven,
+call-by-need execution, not a separate full-normalization pass on every call.
+
+Indexed loading reconstructs shared inert subtrees. Distribution reuses its
+argument, but sharing a function tree does not establish a global application
+cache or an exact once-per-source-expression cost model. Compiler abstraction
+and host optimization affect allocation and sharing.
+
+Observation supplies further demand. `toList` validates the entire finite spine
+before returning success, without forcing its elements; `toString` additionally
+decodes all characters. Full tree rendering, flat serialization, and indexed
+publication traverse complete tree structure. Indexed publication can reuse
+physical sharing but cannot serialize unevaluated Haskell computations or
+infinite trees. Stored `--profile` follows normal rendering demand; the explicit
+`--profile-full-result` option additionally forces the complete result using a
+traversal-local physical-node memo (`TreeObservation.forceTreeShared`). Nodes are
+marked complete only after their children, so a cycle is not accepted as a finite
+normal form. Infinite inputs still require external resource limits.
+
+The `Evaluation demand boundaries` tests in `test/Spec.hs` use poisoned host
+subtrees for positive/negative demand controls, check distribution sharing, and
+apply fully constructed stable function trees to partial inputs or an infinite
+producer. Existing local-control/parser controls check ignored subjects, siblings,
+and handlers; transport tests check physical sharing after decoding. These are
+focused implementation/behavior checks, not a proof of normalization, a complete
+sharing specification, or an eager-evaluator compatibility claim.
+
## Checks
The test suite has independent token/span, AST/core, scope, diagnostic, pattern,
diff --git a/docs/language.md b/docs/language.md
@@ -16,6 +16,35 @@ Functions can receive, return, and inspect other functions. Structural equality
observes their compiled trees; function representation depends on the compiler
and lowering backend. Equal results on chosen inputs do not imply equal trees.
+## Evaluation and observation
+
+Execution is non-strict: an application need not evaluate an unused argument,
+and observing a constructor need not evaluate its children. Local bindings do
+not themselves force their values. Pattern selection demands only the structure
+needed by reached tests; an ignored subject or sibling and an unselected body
+remain unevaluated. A function can consume a finite prefix of a recursively
+produced tree without consuming the rest.
+
+The Arboricx execution contract names this `normal-order`. This does not mean
+that every invocation first produces a complete normal form. The observer
+matters: inspecting a root constructor, decoding a number, rendering a complete
+tree, and publishing an executable can demand different amounts of work.
+Serialization requires a complete finite tree in normal form; an infinite or
+partially defined runtime value is not itself a portable bundle. A finite function
+tree that produces such a value when applied can be bundled. Normalizing the
+function's representation does not guarantee eager-compatible execution: applying
+it to an argument creates new computations whose termination can depend on the
+evaluation strategy. Compilation includes normalization, with the construction
+limits described below.
+
+The Haskell host implements demand through lazy fields and call-by-need sharing.
+Physical pointer identity, exact reduction counts, and time/space bounds are not
+source-language guarantees. Sharing a stored subtree is also distinct from
+memoizing all applications of that subtree. See [compiler architecture](compiler.md.html)
+for implementation and regression-test boundaries, and
+[profiling](tri-tools.md.html#profiling) for ordinary observation versus explicit
+full-result forcing.
+
## Source characters and tokens
The lexer consumes a list of Unicode scalar characters. Native source readers
diff --git a/docs/modules.md b/docs/modules.md
@@ -91,3 +91,434 @@ The Haskell host supplies primitive IO and typed tree transport. Its separate
store and recovery frontend support CLI artifact operations and recovery tools.
[Compiler architecture](compiler.md.html) describes the build boundary;
[Testing](validation.md.html) lists the module and graph probes.
+
+## Exact-version packages from trusted registries
+
+The initial package profile is **one compiled native module per release**.
+Versions are exact opaque labels, not SemVer ranges. Names and versions contain
+1–128 ASCII letters, digits, dots, underscores or hyphens, starting with a letter
+or digit. Registry policy lives in the Haskell CLI; the native server serves the
+index and bundles, and native compilation continues to consume local CAS objects.
+
+### Publish
+
+Compile a library normally, then publish from an administrator account with direct
+access to the server's trusted store directory:
+
+```sh
+result/bin/tricu arboricx compile --file library.tri \
+ --store "$WORK/producer" --output "$WORK/library.arboricx"
+result/bin/tricu package publish --root "$REGISTRY_ROOT" \
+ --name library --version 0.1.0 --file "$WORK/library.arboricx"
+```
+
+Publication validates the bundle and its native interface, writes the bundle
+before the index record, and refuses to replace an existing name/version with
+different bytes. Identical retries are allowed, including concurrent publishers.
+Failure may leave an unreferenced bundle. This is a local administrator operation,
+not an unauthenticated network publishing endpoint.
+
+The current `lib/arboricx/server.tri` serves
+`GET /_arboricx/package/NAME/VERSION` from that same store. Compile the updated
+server source before using this route; historical server bundles do not gain it
+automatically. Use an HTTPS reverse proxy for non-loopback service. A read-only
+registry deployment should restrict public methods/routes and grant the server
+only read access; the older bundle-upload route still exists.
+
+### Resolve, fetch, bind
+
+Create an explicit registry configuration. This loopback example assumes the
+native server is listening on port 9050:
+
+```sh
+printf 'arboricx.registries.v1\nhome\thttp://127.0.0.1:9050\n' > "$WORK/registries"
+result/bin/tricu package resolve --registries "$WORK/registries" \
+ --registry home --name library --version 0.1.0 --lock "$WORK/library.lock"
+result/bin/tricu package fetch --registries "$WORK/registries" \
+ --lock "$WORK/library.lock" --store "$WORK/application"
+result/bin/tricu package bind --lock "$WORK/library.lock" \
+ --store "$WORK/application" --output "$WORK/library.tri"
+```
+
+Configuration rows are tab-separated registry name and origin URL. Names must be
+unique. URLs are HTTPS origins without trailing slash, path, credentials, query
+or fragment; HTTP is allowed only for `localhost`, `127.0.0.1` and `[::1]`.
+There is no registry fallback or automatic trust discovery. Redirects are rejected.
+
+`resolve` alone consults the index. It records registry name/origin, exact version,
+module-manifest hash and whole-bundle hash. Changing a selection requires
+`--update`; a changed mapping for the same locked registry/origin/name/version
+is rejected even with that flag. This detects changes relative to the current
+lock, not every historical registry claim.
+
+`fetch` uses only the lock's bundle pin, verifies the download and its advertised
+module identity before importing into the application CAS, and creates no mutable
+module aliases. Its configured origin must still match the lock. A complete,
+verified local module needs no network request. Corrupt or incomplete objects
+under an existing manifest fail rather than silently repairing them.
+
+`bind` is offline. It verifies the local module and writes a normal workspace
+module containing `!export * from HASH`. An application beside that file can use
+`!import library as L` and call `L.someExport`. Use the same application store
+for compilation. Commit the lock and generated binding; after an explicit update,
+fetch and regenerate the binding. The compiler reads the binding, **not the
+package lock**; it never resolves names against the registry or updates locks.
+For several dependencies, use the project declaration and consolidated lock below
+instead of assembling separate package lock files.
+
+Trusting a registry accepts its name/version assertions. It neither proves build
+provenance nor grants downloaded programs IO permissions. TLS uses the host client
+trust configuration. Metadata downloads are bounded to 4 KiB, bundles to 64 MiB,
+and requests to a 60-second overall deadline (30-second response timeout). These
+are transport limits, not fixed heap bounds or an execution sandbox. The native
+server still reads/encodes complete files. Remote credentials, signatures, version
+solving, multi-module package records and provenance are outside this profile.
+
+## Project dependencies
+
+`tricu project` prepares a whole dependency set while keeping registry resolution,
+fetching, workspace binding and compilation separate. It does not add a version
+solver, implicit imports, transitive source dependencies, or network access to the
+compiler. Local source modules still use the ordinary workspace rules above.
+
+A `tricu.project` file declares **local binding, registry label, package name,
+exact version**, separated by tabs, with LF endings and a final newline:
+
+```text
+arboricx.project.v1
+foundation<TAB>home<TAB>tricu-base<TAB>0.1.0
+fileio<TAB>home<TAB>tricu-file-io<TAB>0.1.0
+inspection<TAB>home<TAB>arboricx-inspection<TAB>0.1.0
+```
+
+`<TAB>` above denotes one actual tab. The checked-in
+[`inspector declaration`](../demos/package-inspector/tricu.project) is ready to
+copy. Declaration order does not affect selection; generated locks are sorted
+by binding name. Bindings use the flat workspace name grammar above, at most 128
+ASCII characters, excluding names that would parse as full manifest hashes.
+Duplicate and case-colliding names are rejected, even on case-sensitive systems.
+Project metadata is bounded to 64 KiB and 128 direct dependencies. Empty projects
+are valid. Versions are opaque exact labels: even `1.x` would be a literal label,
+not a range. Comments and blank rows are not part of this metadata format.
+
+With an explicitly trusted registry configuration whose `home` entry serves those
+releases, prepare a fresh workspace (the site releases must be deployed or served
+locally first):
+
+```sh
+mkdir -p "$WORK/inspector"
+cp demos/package-inspector/main.tri demos/package-inspector/tricu.project "$WORK/inspector/"
+PROJECT="$WORK/inspector/tricu.project"
+
+result/bin/tricu project resolve --project "$PROJECT" --registries "$REGISTRIES"
+result/bin/tricu project fetch --project "$PROJECT" --registries "$REGISTRIES" --store "$APP_STORE"
+# The registry can now be stopped.
+result/bin/tricu project bind --project "$PROJECT" --store "$APP_STORE"
+result/bin/tricu project check --project "$PROJECT" --store "$APP_STORE"
+result/bin/tricu arboricx compile --file "$WORK/inspector/main.tri" \
+ --store "$APP_STORE" --output "$WORK/inspector.arboricx"
+```
+
+From within that workspace, omit `--project`: it defaults to `tricu.project`.
+The lock defaults to `tricu.lock` beside the declaration; `--lock FILE` overrides
+it. Bind/check default to that same directory; `--directory WORKSPACE` overrides
+where generated modules live. They do not copy authored sources. Explicit paths
+are relative to the caller's working directory. Local or global `--store` selects
+the application CAS, just as with the other CLI operations.
+
+### Frozen resolution and deliberate updates
+
+- **`resolve`** creates the complete lock on first use, resolving each distinct
+ origin/name/version once. With an existing lock it is **frozen**: it checks the
+ declaration and configured origins against that lock without an index request.
+ Any addition, removal, version, registry-label or origin change requires update.
+ This differs deliberately from the low-level single-package `resolve`, which
+ rechecks its index entry on every call.
+- **`update --dependency BINDING`** re-resolves only the selected binding; repeat
+ the option for several, or use **`--all`** explicitly. Edit the declaration first
+ to request a new version, add a dependency, or remove one. Unselected bindings
+ must match the declaration and configured origins exactly; otherwise the command
+ fails before any GET. A removal needs no registry request. A rename is a removal
+ plus an addition: select both names, or use `--all`. For a case-only rename,
+ remove the old dependency and bind before adding the new spelling.
+- Every successful update prints before/after selections, full module and bundle
+ hashes, and whether each identity changed. A label change need not change code;
+ a changed module or bundle does not establish behavioral differences.
+- All resolutions and validation must succeed before one atomic lock publication;
+ their failure leaves the previous lock intact. A post-publication filesystem
+ sync failure may leave the new complete lock visible: retry and verify, as in
+ the publication rules above. No executable objects are imported at this stage. A changed assertion for an origin/name/version in the **current** lock is
+ rejected, even across binding or registry-label renames. There is no historical
+ claim database; preserve older locks in version control.
+
+For example, after changing only the foundation version in the declaration:
+
+```sh
+result/bin/tricu project update --project "$PROJECT" --registries "$REGISTRIES" \
+ --dependency foundation
+result/bin/tricu project fetch --project "$PROJECT" --registries "$REGISTRIES" --store "$APP_STORE"
+result/bin/tricu project bind --project "$PROJECT" --store "$APP_STORE"
+result/bin/tricu project check --project "$PROJECT" --store "$APP_STORE"
+```
+
+### Fetching, binding and recovery
+
+`fetch` first checks the whole declaration/lock and every configured origin. It
+uses bundle hashes only, never the release index. Verified complete local modules
+need no download. Transport and executable verification use the same package
+controls above; an interrupted fetch may leave valid immutable objects available
+for retry. Corrupt/incomplete existing modules fail rather than being repaired
+silently. This verifies local executable availability, not possession of the
+original distribution bundle's bytes.
+
+`bind` verifies **all** required local interfaces and preflights **all** output
+paths before changing bindings. It writes normal `!export * from HASH` modules.
+A derived `.arboricx-project-bindings` ledger tracks which files it owns, allowing
+safe updates and removal of obsolete bindings. It never adopts/overwrites an
+unmanaged file, even one with matching bytes. Edited owned files, direct symlinks,
+directories and input/output collisions are errors. Unrelated authored files are
+left alone. To migrate manually generated bindings, explicitly remove those
+files after preserving any edits, then bind into the clean paths.
+
+Multi-file binding is **not atomic**. A `.arboricx-project-bindings.pending`
+journal is published before any changes. After interruption, rerun `bind` with
+the same project/lock; it accepts files from the prior or pending generation but
+refuses user edits. Finish recovery before another dependency update. If the lock
+has already changed, restore the interrupted generation's declaration/lock first.
+Use exclusively owned, trusted workspaces and keep their contents stable throughout
+bind/check/compile; concurrent updates or hostile directory mutation are unsupported.
+
+`check` is read-only and offline. It rejects stale declarations, mismatched or
+edited bindings, pending transactions, missing modules and corrupt objects. The
+compiler still reads ordinary source bindings, **not** the project lock: run check
+before compilation when lock/binding agreement matters. It is not a source
+inventory, a compiler pin, a build receipt or an execution sandbox.
+
+Commit the declaration and lock. Either regenerate bindings in fresh workspaces,
+or commit generated modules **and their ownership ledger together**. Do not commit
+an unfinished journal. The one consolidated lock stores registry labels/origins,
+exact releases, module hashes and bundle hashes; the ledger is merely derived
+local file-ownership state, not another dependency resolution mechanism.
+
+### Explain selections and compare locks
+
+These commands are **offline and read-only**. They do not initialize a store,
+fetch missing objects, consult registry configuration, regenerate bindings, write
+comparison caches, or execute exports:
+
+```sh
+result/bin/tricu project explain --project "$PROJECT" --store "$APP_STORE"
+result/bin/tricu project diff --before initial.lock --after current.lock --store "$APP_STORE"
+```
+
+`explain` uses the same declaration/lock/directory defaults as bind/check. It
+shows declared and locked selections, exact registry origins, full module and
+bundle pins, binding-file agreement, ownership/journal status, and verified or
+unavailable local interfaces/exports. A stale declaration or generated file does
+not hide the other evidence. A missing or malformed declaration/lock is an input
+error, not an implicit resolve request. It reports the lock's registry claim, not
+current registry trust, publisher authentication or build provenance.
+
+`diff` needs only two consolidated locks and a local store containing evidence
+for either/both. It separates selection additions/removals/changes, export and ABI
+changes, object identities, and **ordered tree structure** for common exports.
+Unchanged selections are included too. Comparison of unavailable evidence is
+**unknown**, never unchanged merely because the locks contain equal hashes.
+Every relevant local object is authenticated and decoded, including unchanged
+pins. An absent dependency on one side is distinct from a selected module whose
+objects are missing.
+
+Structural comparison operates on validated indexed DAG records. Bottom-up
+interning of constructor/child-ID tuples in a shared table gives exact structural
+equality without probabilistic subtree hashes, recursive expanded-tree equality,
+reconstruction or execution. It ignores physical node numbering, unreachable
+records and irrelevant packaging metadata, while retaining constructor and child
+order. Work for an export pair scales with stored records (with logarithmic map
+operations), not the number of logical occurrences in the unfolded tree. Normal native package import already canonicalizes exports into name-neutral
+indexed objects; the inspector acceptance case therefore also has unchanged
+common-export object hashes. The comparison does not assume that every readable
+indexed encoding is canonical. It does
+**not** normalize programs or establish behavioral equivalence. Distinct trees
+can have identical observed results; tricu can also observe their structure.
+
+The first report profile supports closed nonempty native package modules with
+indexed-tree exports and `arboricx.abi.tree.v1`. Unknown kinds/ABIs are explicitly
+unsupported; available manifest interfaces can still be compared. Corrupt bytes,
+malformed graphs, missing files, nonregular CAS paths and unsupported profiles do
+not become successful evidence. Display fields percent-escape non-ASCII/control
+bytes and `%`, so package metadata cannot insert terminal control sequences.
+
+Report commands return **0** when all requested evidence is available/verified
+(and explain's declaration/bindings/ownership state agree), even when a valid diff
+finds changes. They return **1** for incomplete/invalid/unsupported evidence or
+stale project state, printing the available report to stdout. Invalid input files
+produce a diagnostic instead. Human-readable report wording is not a versioned
+machine protocol.
+
+Reads are bounded to 64 KiB per module manifest and 64 MiB of retained authenticated
+executable bytes per module. Repeated object references within a module share
+inspection results; report text is materialized per dependency before proceeding
+to the next. A budget-limited export is reported unavailable. These encoded-byte
+limits are **not heap/RSS guarantees**: parsed graphs and comparison tables take
+additional space. Use stable, trusted stores and external time/heap controls for
+large inputs; this is not a hostile-filesystem sandbox. Original distribution
+bundle bytes are not required or authenticated by these commands—bundle pins are
+reported as locked claims, while executable evidence comes from verified CAS
+modules and objects.
+
+The inspector acceptance example compares its retained `initial.lock` with the
+updated `consumer/tricu.lock`: foundation gained `revision`; its three common
+exports remained structurally identical; fileio and inspection stayed pinned.
+The acceptance harness separately records whether the rebuilt inspector itself
+remained byte-identical. A dependency-lock diff alone cannot infer the identity
+of a downstream build.
+
+## Retained build inputs and reproduction
+
+The native receipt already records build inputs and outputs, but it does not
+retain source bytes or make a clean rebuild possible on its own. `provenance`
+retains the required closure and keeps three assurances distinct: **verified
+bytes**, **recorded producer claims**, and **a reproduced build**.
+
+First compile with an explicit trusted toolchain, as in
+[build/run](build-and-run.md.html#explicit-compiler-selection). Keep the source
+workspace stable and use the same application/tool stores for capture:
+
+```sh
+result/bin/tricu arboricx compile --file "$WORK/inspector/main.tri" \
+ --store "$APP_STORE" --toolchain "$COMPILER_MANIFEST" --tool-store "$TOOL_STORE" \
+ --output "$WORK/inspector.arboricx"
+
+result/bin/tricu provenance capture --file "$WORK/inspector/main.tri" \
+ --store "$APP_STORE" --toolchain "$COMPILER_MANIFEST" --tool-store "$TOOL_STORE" \
+ --project "$WORK/inspector/tricu.project" --output "$WORK/inspector-inputs"
+```
+
+Capture prints the input-pack hash. It discovers the native root receipt through
+the latest cache locator, or accepts an explicit `--receipt HASH` if that locator
+is absent or another historical build is desired. Every reached source file must
+still match its receipt. `--project` is optional (omit it for a library workspace);
+its lock defaults to `tricu.lock` beside the declaration, with `--lock FILE` as an
+explicit override. It does not capture registry credentials, the whole checkout,
+or unrelated source files. Paths are caller-relative; output directories must be
+new. A failed capture can leave an incomplete directory without a descriptor.
+
+Copy the pack directory to another machine or isolated working directory and
+transfer its hash through the channel whose assertions you intend to accept:
+
+```sh
+INPUTS_HASH=PASTE_THE_PRINTED_HASH
+result/bin/tricu provenance verify --pack "$PACK" --pin "$INPUTS_HASH"
+
+# This executes the retained compiler. Do not blindly trust a hash merely
+# because an untrusted pack prints it: select a compiler you actually approve.
+result/bin/tricu provenance rebuild --pack "$PACK" --pin "$INPUTS_HASH" \
+ --trust-compiler "$COMPILER_MANIFEST" --output "$WORK/fresh-rebuild" \
+ --compile-timeout 900
+```
+
+`verify` is read-only and never executes compiler or application exports. It
+checks the authenticated descriptor and its referenced closure, not unrelated
+files someone might have placed alongside it. `rebuild` verifies first, requires
+explicit compiler authorization, and creates new `workspace`, `application` and
+`tools` directories. It uses only retained sources and executable inputs. There
+is no registry lookup, source fallback, package resolution, or producer-cache
+reuse. The host's packaged compiler is not needed for this explicit route.
+
+A successful result reports exact native module and receipt identities plus
+structural comparison of exported trees, and saves `rebuild.txt`. The resulting
+module is runnable from the new application CAS using `arboricx run`. An exact
+reproduction returns 0; a mismatch reports available structural differences and
+returns nonzero. Tree equality alone does not count as exact reproduction. A
+compiler/validation failure also returns nonzero and may leave partial local
+artifacts, but never a success report. Retry into a new directory, not a warm
+store. The application exports being compared are never executed. This workspace
+is an input snapshot, not a restored project binding-ownership transaction; a
+project may also declare unused dependencies intentionally absent from the pack.
+
+This reproduces the **native module**, not arbitrary export filtering or metadata
+of an original distribution bundle. It retains the compiled compiler, not the
+compiler's own source provenance, the Haskell host executable, OS environment,
+or repository history. A compatible Haskell host is still required. It is a
+file-transferable pack, not yet registry provenance discovery, HTTP fetching,
+a signature system or a remote attestation service. Review source/toolchain
+contents for secrets and licensing requirements before distributing them; the
+pack does not automatically collect repository license or origin metadata.
+
+Use trusted, stable directories. Internal CAS directories and input files must
+not be symlinks; concurrent mutation is unsupported. Limits are 128 reached source
+units, 1 MiB per source/receipt, 64 KiB per descriptor/project file/module manifest,
+64 MiB per executable object, and 256 MiB of retained closure bytes. The shared
+inspection reader additionally bounds retained executable bytes per module.
+These are encoded-byte bounds, not heap/RSS guarantees. Rebuilding executes the
+selected compiler with the existing host IO model and compiler deadline, **not**
+an OS/network sandbox; use external resource/isolation controls as appropriate.
+See [storage](storage.md.html#retained-inputs-and-independent-reproduction) for
+the identities and [validation](validation.md.html#retained-provenance-opt-in)
+for the real-library and false-claim acceptance tests.
+
+## First published libraries: an offline bundle inspector
+
+The initial reusable library set has deliberately small interfaces, not a promise
+that every definition in the underlying `lib/` modules is public:
+
+| Release name | Authored interface | Exports |
+| --- | --- | --- |
+| `tricu-base` | `lib/packages/base.tri` | `append`, `matchResult`, `showNumber` |
+| `tricu-file-io` | `lib/packages/io.tri` | `io`, `bind`, `readFile`, `sha256Hex`, `putStr`, `abortIO` |
+| `arboricx-inspection` | `lib/arboricx/inspect.tri` | `inspectBundle`, `renderSummary`, `describeError` |
+
+Their closed executable exports include the required binary, numeric and parser
+helpers. Consumers do not need those implementation libraries' sources or a
+transitive package resolver. Importing an IO constructor does not grant IO
+permissions. Each release uses the ordinary one-module package profile above.
+
+[`demos/package-inspector/main.tri`](../demos/package-inspector/main.tri) is a real
+consumer of all three releases. Its `tricu.project` selects `tricu-base` as
+`foundation.tri`, `tricu-file-io` as `fileio.tri`, and `arboricx-inspection` as
+`inspection.tri`. Use project resolve/fetch/bind/check above, then compile
+that directory using the application store containing the fetched modules; the
+registry may be offline. The compiler itself still needs its selected toolchain.
+
+After importing the resulting inspector bundle indexed into `APP_STORE`, run its
+manifest (not a release bundle hash):
+
+```sh
+result/bin/tricu arboricx run --store "$APP_STORE" \
+ --module "$INSPECTOR_MANIFEST" --name main --arg "$INPUT_BUNDLE" \
+ --io --no-result --allow-read "$INPUT_BUNDLE"
+```
+
+Output is a line-oriented, tab-separated report:
+
+```text
+arboricx.inspect.v1
+bundle-sha256<TAB>whole-file SHA-256
+stored-nodes<TAB>physical record count
+export<TAB>name<TAB>root index<TAB>kind<TAB>ABI
+```
+
+The report preserves export order. Fields percent-escape `%` and bytes outside
+printable ASCII, including UTF-8, tabs, newlines and terminal controls. It hashes
+the same in-memory byte snapshot it validates; it does not reopen the input for
+hashing. It validates every export and stored node, including unused ones, but
+never reconstructs/applies an exported tree or expands shared logical occurrences.
+Malformed bundles and file permission/read failures produce a nonzero process
+exit and no successful report. No writes or network permissions are needed.
+
+`inspectBundle` returns the library Result protocol: failure is a numeric codec
+diagnostic; success is `(stored node count, export records)`, with an empty Result
+remainder. An export record is `(name bytes, (u32 root bytes, (kind bytes, ABI bytes)))`.
+`renderSummary` accepts that validated success payload, not an arbitrary tree;
+`describeError` gives a human-readable diagnostic category. Success hides the internal decoder cursor; failure retains the codec remainder
+as opaque diagnostic context, not an incremental parsing continuation. Internal
+helper definitions are not exported.
+
+This is structural validation and byte identity, **not** publisher authentication,
+build provenance, or a promise that a program is safe to execute. The first
+inspector reads/parses the whole file in memory. It is not a streaming inspector
+or a hostile-input resource sandbox; use external process/time/heap limits for
+untrusted large files. Current formats and supported ABIs follow the underlying
+native Arboricx reader. The [acceptance workflow](validation.md.html#published-library-consumer-opt-in)
+checks the actual compiled libraries against an independent Haskell decoder,
+including inspection of the published libraries and the inspector itself.
diff --git a/docs/storage.md b/docs/storage.md
@@ -182,6 +182,64 @@ The index retains the latest result per module name; historical CAS objects can
outlive that index. These rules preserve namespace checks and exact dependency
identity through reuse.
+### Retained inputs and independent reproduction
+
+`tricu provenance` wraps the existing native `arboricx.build-receipt.v1` graph;
+it does not create a competing receipt format or change executable identity.
+See the [capture/verify/rebuild workflow](modules.md.html#retained-build-inputs-and-reproduction).
+
+A portable input pack contains a `build-inputs` descriptor and a standard CAS
+under `store/objects`. The descriptor is authenticated with the domain
+`arboricx.build-inputs.v1`; retain/share its full hash independently of its path.
+Its canonical LF/tab format records an entry name, the native root receipt,
+a compiler manifest, sorted unit-name/receipt pairs, and optional project
+metadata pins. The inventory must exactly match the reachable native local-receipt
+graph; unit paths are derived as `NAME.tri`, never accepted as arbitrary paths.
+
+Native receipts remain authoritative for each unit's raw source digest, declared
+compiler identity, options, output, ordered local edges and imported tree pins.
+The pack supplies previously missing preimages: raw sources under
+`arboricx.source.v1` and the supported profile string under
+`arboricx.build-options.v1`. Source snapshots must match the existing receipt,
+not a freshly invented claim about changed source. Each receipt's complete pin
+group must match its referenced module interface, and local output/receipt links
+are checked recursively. Capturing a selected `--receipt` does not need a cache;
+without that option, the native cache is only a locator and its key/output fields
+must agree with the authenticated receipt.
+
+The first profile is the indexed, stable flat-workspace compiler route with
+explicit toolchain selection. Its native compiler identity is the indexed object
+hash of `sourceBuildGraph`; the retained compiler manifest must select that exact
+export. The descriptor retains a runnable compiler facade, not evidence that the
+producer originally used that particular facade or transport bundle. Default
+packaged-compiler builds currently record a whole-bundle identity instead; rebuild
+with an explicit toolchain before capturing them in this profile. Other option
+profiles/frontends are rejected rather than silently interpreted as stable builds.
+
+Optional project declaration/lock bytes have domains
+`arboricx.project-declaration.v1` and `arboricx.project-lock.v1`. They are
+capture-time selection context, not compiler-read inputs retroactively recorded
+by native receipts. Their selections must agree, and reached generated binding
+snapshots must match the lock. Unused project selections are retained as context
+but their libraries are not required inputs to this entry's rebuild.
+
+Claimed outputs are retained as comparison evidence, separate from executable
+inputs. Rebuilds seed only immutable external imports into a new application CAS
+and the selected compiler into a separate tool CAS. No producer cache, alias,
+receipt, checkpoint, or output-only object is copied into that application store.
+An output that is also a genuine external input (such as a header-only re-export)
+is necessarily available as an input. The compiler-returned receipt and its new
+local graph are authenticated after execution; root receipt/module identity and
+export structures are compared against the pack.
+
+Verification establishes byte integrity and receipt-link consistency, **not** that
+the compiler actually produced the claimed result. A successful fresh rebuild is
+a local observation that the selected compiler reproduced the native receipt and
+module. Neither authenticates a publisher, signs an attestation, nor proves unique
+source history. Several source histories or retained contexts may produce the
+same executable. Pack hashes and native receipt hashes remain separate from
+module and distribution-bundle identities.
+
### Export checkpoints
`lib/source/export-cache.tri` offers a separate per-export checkpoint profile.
@@ -194,3 +252,37 @@ Restart still stages, parses, orders, resolves, and constructs the definition
environment. It skips materializing completed exports. Module manifests and
receipts publish after all required exports complete. See
[Compiler rebuilding](compiler-rebuilding.md.html) for closure audits and acceptance.
+
+## Exact package release index and locks
+
+Package release v1 uses the existing bundle transport and indexed native module
+identity; it does not introduce another executable or package CAS object. At
+`REGISTRY/packages/NAME/VERSION`, the canonical UTF-8 file contains two
+LF-terminated lines (fields separated by TAB):
+
+```text
+arboricx.package-release.v1
+NAME<TAB>VERSION<TAB>MODULE_MANIFEST_HASH<TAB>WHOLE_BUNDLE_HASH
+```
+
+The bundle's ordered exports must import into exactly the advertised native
+manifest. The publisher checks this without evaluating exports. Metadata is a
+trusted registry assertion, not a signed statement. Future package profiles must
+use an explicitly versioned format rather than interpreting this module pin as
+a richer package manifest.
+
+A client lock is a separate, canonical, three-line file:
+
+```text
+arboricx.package-lock.v1
+REGISTRY_NAME<TAB>ORIGIN_URL
+NAME<TAB>VERSION<TAB>MODULE_MANIFEST_HASH<TAB>WHOLE_BUNDLE_HASH
+```
+
+The package publisher uses same-directory temporary files, file sync, hard-link
+no-replace publication, byte comparison against a racing winner, and parent sync.
+Locks and generated workspace bindings use atomic replacement. This does not
+change the Haskell CAS adapter's existing durability/concurrency guarantees.
+Directories and ancestors remain trusted; there is no multi-file transaction,
+concurrent lock-update merge, or power-loss/crash-injection acceptance claim.
+See [package usage](modules.md.html#exact-version-packages-from-trusted-registries).
diff --git a/docs/tri-tools.md b/docs/tri-tools.md
@@ -78,6 +78,13 @@ with success/error labels. `--repeat` repeats an invocation after loading once;
pure applications may reuse an already evaluated value, so this is unsuitable
for independent fresh-run benchmarks.
+For stored IO commands that print their own output, `--io --no-result` suppresses
+the trailing host-rendered return value. Program output and errors are unchanged;
+`--no-result` without `--io`, or combined with `--result`, fails before execution.
+Repeat/profiling markers retain their existing behavior. Observation phases still
+occur with `--no-result`, but do not render or force the suppressed return value.
+Explicit `--profile-full-result` forcing remains independent of result rendering.
+
## IO and permissions
Select `--io` for interaction-tree execution. Grant file access with repeatable
@@ -129,6 +136,26 @@ force collection; allocation/live values reflect the most recent collection.
Report preparation, loading, compilation, execution, publication, and result
observation separately when comparing workloads.
+Stored `--profile` follows the ordinary renderer's demand. `invoke-end-N` marks
+completion of the IO driver (or creation of a still-lazy pure result), not full
+pure evaluation. `observe-begin-N` / `observe-end-N` bracket rendering and output;
+`observe-end-N` follows flushing stdout. Measure `invoke-begin-N` through
+`observe-end-N` for invocation plus selected output, including rendering/IO costs.
+Merged logs receive a separating stderr newline before the final JSON sample.
+
+For an explicitly different workload, add `--profile-full-result` (requires
+`--profile`). Separate `force-begin-N` / `force-end-N` samples bracket full-result
+forcing before rendering. This traversal memoizes completed physical nodes within
+one invocation, so repeated DAG branches are not expanded. It still demands fields
+that `--result` discards: infinite or partial results can diverge or exhaust
+resources. No forced collection, reduction counter, or bounded-memory guarantee
+is implied.
+
+Previously, `--profile` implicitly forced the complete expanded result before
+`invoke-end`. Those historical intervals are not comparable to either new mode.
+Active workload probes label invocation-through-output measurements explicitly;
+use fresh processes, not `--repeat`, to avoid reusing an evaluated application.
+
## `.tri` equation alignment
`tricu format` runs a pinned `formatTri` program. Prepare the current formatter
diff --git a/docs/validation.md b/docs/validation.md
@@ -231,6 +231,42 @@ success/failure, and representative concurrent IO. Preserve limit-before-accepta
behavior, suffixes, errors, and complete receive/send/close traces. Measure time
and memory together, including concurrent workloads when assessing throughput.
+## Exact package registry checks
+
+`nix flake check` runs Haskell codec/publication tests (including concurrent
+identical publishers) and `test/package_registry.py` against a controlled
+loopback HTTP peer. This checks explicit trust, lock identity and mutation
+controls, redirects, metadata bounds, corrupt transport, module mismatches,
+offline reuse/binding, and local corruption. It does not compile source.
+
+For the full current-source vertical slice on the Haskell host:
+
+```sh
+nix build .#
+python3 test/package_registry.py result/bin/tricu \
+ --prepare-server /tmp/package-registry-candidate
+```
+
+The preparation directory must be new. Preparation compiles the current native
+server with the packaged stable compiler, retains source copies, source hashes,
+command and build log, and limits compilation to 900 seconds / 8 GiB heap. The
+runner then compiles a small two-export library, publishes it locally, serves it
+through the actual native HTTP routes, fetches into a fresh store, stops the
+server, generates a pinned workspace binding and compiles a consumer yielding
+`42`. Invalid package route components must receive HTTP 400. Runtime children
+have a 2 GiB heap limit and bounded subprocess deadlines.
+
+To reuse an already prepared server artifact:
+
+```sh
+python3 test/package_registry.py result/bin/tricu \
+ --native-server /tmp/package-registry-candidate/server.arboricx
+```
+
+That module must export `main root port`, returning the IO-wrapped server action.
+These checks use real loopback HTTP, not live HTTPS/certificate infrastructure,
+and do not claim hostile-filesystem containment or crash durability testing.
+
## Registry server pipelines (opt-in)
Prepare `tests.httpEffects` and `tests.registryEffects` explicitly through the
@@ -554,12 +590,76 @@ Its whole-job budget is five minutes, compilation 60 seconds, and ordinary calls
Use fresh processes, interleaved variants, discarded warmups, and recorded raw
samples. `arboricx run --repeat` can reuse an evaluated application and is not a
-fresh-invocation benchmark. Loading-to-WHNF and fully forced invocation measure
-different work; the latter includes deferred construction and profiling GC.
-Report preparation separately. Sampled RSS can miss short-lived peaks, and small
+fresh-invocation benchmark. Loading-to-WHNF, invocation-through-rendered-output,
+and explicit full-result forcing measure different work; the latter two include
+deferred evaluation and their respective observer costs. See the
+[profile phase definitions](tri-tools.md.html#profiling); ordinary `--profile`
+no longer forces discarded result fields. Report preparation separately. Sampled RSS can miss short-lived peaks, and small
timing differences do not establish general speedups. Partial matrices remain
incomplete. These probes neither generate compilers nor publish fixtures.
+### Pattern lowering comparison (opt-in)
+
+```sh
+python3 test/pattern_lowering.py result/bin/tricu \
+ --directory /tmp/tricu-pattern-candidate
+```
+
+The probe authenticates the shipped compiler and complete unchanged reference
+modules. It stages the source lowerer and rebuilds its affected
+compiler consumers through `source_unit`; receipts, source bytes, exported objects,
+and exact dependency pins are audited before execution. Only unchanged dependency
+boundaries use full retained-module facades. It never replaces accepted artifacts.
+
+The Haskell control driver is compiled through Nix. Checks reuse the 75-case
+independent equation corpus, exercise constructor/equality workloads on finite
+and poisoned inputs, and verify split-column ordering, nested failure, unreached
+pins and whole-child captures. Two exact code-generation targets compare ordinary
+equations with independently authored delayed dispatchers; the retained compiler
+must fail those optimization targets. Non-pattern controls must retain their
+original trees. The existing 46-case local-control suite also runs on the candidate.
+
+Fresh-process cost probes interleave reference/candidate executions, discard a
+warmup, and retain three samples per role. Identical explicit recursion and full
+output oracles isolate dispatch costs. Separate small compiler probes force the
+complete returned definition trees. Both use explicit full-result profiling:
+reduction plus DAG traversal, excluding load and rendering. Allocation is
+GC-sampled, not peak residency or a reduction counter. Reports retain raw profiles,
+source/compiler/artifact identities, receipts, controls, and medians.
+
+Limits: 20 minutes overall, 600 seconds for native compilation with an 8 GiB heap,
+180 seconds / 4 GiB for the Haskell controls, and 20-second / 512 MiB workload
+calls (30 seconds / 1 GiB for construction probes). `--resume` authenticates native
+checkpoints and preserves the previous report; it is diagnostic reuse, not a fresh
+generation. `--skip-costs` retains semantic/integrity controls without timing runs.
+These checks do not replace the compiler generation/self-rebuild acceptance gate.
+
+### Sharing characterization (opt-in)
+
+```sh
+python3 test/sharing_probes.py result/bin/tricu \
+ --directory /tmp/tricu-sharing-probe
+```
+
+Use a fresh directory. The probe compiles tiny import-free programs with the
+shipped stable compiler and runs only the Haskell host. It compares a bound shared
+computation with two separately written calls, checks their complete small outputs
+against an independent oracle, and compares a shared DAG with itself using an
+ordinary TC structural comparator (not Haskell `Eq` or an optimized equality
+primitive). Input DAGs contain depth+1 physical nodes and 2^(depth+1)-1 logical
+occurrences. It also checks that ordinary profiling ignores a divergent Result
+remainder while explicit full-result forcing hits a resource bound.
+
+Runs use fresh processes, three samples by default, a 512 MiB child heap cap,
+20-second commands (three seconds for the expected noncompletion), and a
+180-second whole-job budget. `report.json` retains commands, source/artifact hashes,
+manifest pins, raw profiles, and medians. The measured interval is `force-begin`
+through `force-end`: reduction plus sharing-aware traversal, excluding loading
+and rendering. Allocation counters are GC-sampled; they are not reduction counts
+or peak residency. The probes characterize these examples, not universal binding
+sharing, eager compatibility, or a general compiler speedup. They change no
+published compiler/support artifacts.
+
The Haskell recovery/shape-test cohort pins six immutable sources through
`recoveryFoundationSources` in `test/Spec.hs`. It stays separate from current
portable foundations, whose syntax the recovery parser cannot necessarily read.
@@ -718,3 +818,134 @@ binary IO parity, permissions and invalid encoder scalars. Existing scalar-list
fixtures do not establish file decoding. Limits are 8192 MiB, 900 seconds for
compilation, 180 seconds per smoke and 1500 seconds for the whole job. This is
not a generation or fixture-publication gate.
+
+## Published-library consumer (opt-in)
+
+Builds three curated libraries through the shipped stable compiler, publishes
+exact releases in a new **local** registry directory, fetches them into an empty
+consumer CAS over a controlled GET peer using one project declaration/lock, closes
+that server, and binds/checks/compiles
+`demos/package-inspector/main.tri` offline. No producer source or CAS is copied
+into the consumer workspace. The native registry route itself is covered by the
+separate package-registry checks above.
+
+```sh
+nix build .#
+nix build .#inspectorOracle --out-link result-inspector-oracle
+WORK=$(mktemp -d)
+nix shell --inputs-from . nixpkgs#python3 -c python3 test/library_inspector.py \
+ result/bin/tricu --oracle result-inspector-oracle/bin/inspector-oracle \
+ --directory "$WORK/inspection"
+```
+
+`inspectorOracle` is a test-only Haskell executable using `Wire.decodeBundle` and
+`verifyBundle`; it never calls the compiled inspector or evaluates inspected
+exports. Complete reports must match, including whole-file hash, record count,
+export order, root indices, kind/ABI and percent escaping. Fixtures cover invalid
+headers, truncation, malformed manifests, invalid unselected roots/ABIs, duplicate
+exports/nodes, forward edges, terminal-control/UTF-8 names, and 41 stored nodes
+representing more than a trillion logical occurrences. An IO export that aborts
+if executed is inspected successfully. The published libraries and inspector
+itself are inputs too. Missing files and denied reads must fail nonzero.
+
+On the validation host, the full 18-input publication/consumer check took about
+five minutes: offline consumer compilation was about 1.5 seconds, while inspecting
+the roughly 221 KiB inspection library or consumer bundle took about a minute
+each. These are observations, not performance guarantees. The in-memory portable
+parser is significantly more expensive than the native Haskell report oracle.
+
+It then publishes a **local test-only** foundation revision with one additional
+export, changes only the `foundation` selection, and explicitly updates that one
+binding. The other two lock rows must stay byte-identical. Stale checks and binding
+before fetch fail. After fetching, the registry is stopped again and the unchanged
+consumer is rebound, checked and recompiled offline; its output must still match
+the independent oracle. The update diff and resulting inspector identity are
+recorded; this test release is not added to the site's public index. With the
+registry still closed, `project explain` must report verified local evidence and
+`project diff` must identify the added `revision` export and structurally identical
+common foundation exports. Both human-readable reports are retained in the JSON.
+
+The preparation retains artifacts, the initial/current project locks and generated
+bindings/ownership state, separate producer and
+consumer stores, compiler/source hashes, command logs and outcomes in
+`report.json`. This is local validation evidence, not signed/distributed provenance.
+Current bounds: 8 GiB GHC heap per tricu child, 900-second compile deadline,
+180-second inspection deadline and 2400-second overall command budget. Heap bounds
+are not total process RSS bounds. It remains opt-in; default checks exercise cheap
+CLI `--no-result` controls and site-staging immutability without recompilation.
+
+`test/project_dependencies.py` runs in the default host-policy check with small
+wire fixtures and a controlled GET peer. It covers frozen/no-network resolution,
+scoped updates/additions/removals, late-resolution failure preserving the whole
+lock, registry equivocation (including renames), origin mismatch, missing/corrupt
+CAS objects, authored/edited/symlink binding collisions, journal recovery, removal
+of owned bindings only, input/output collisions and offline retries. No library
+compilation or public network access is involved; each CLI child has a 1 GiB GHC
+heap limit and a 30-second deadline. Haskell tests separately check the metadata
+codecs, canonicalization, name bounds and duplicate/conflicting selections.
+
+`test/project_inspection.py` is another default host-policy check. Independently
+assembled wire/CAS fixtures prove ordered structural equality despite changed
+physical numbering/object hashes, unequal trees, 81-node exponential DAGs,
+unreachable records, interface/ABI/kind changes, terminal-safe metadata, and
+missing/corrupt/malformed evidence even under unchanged pins. Files and mtimes are
+snapshotted around every report; absent stores must remain absent. Each CLI child
+has a 512 MiB GHC heap guard and a 30-second deadline. Small Haskell comparisons
+also agree with independently evaluated constructor equality; no large DAG is
+unfolded to produce an expected answer. A focused byte-budget test confirms
+that repeated object references share inspection results and additional objects
+become unavailable when the retained-byte allowance is exhausted.
+
+The default command never changes checked-in site files. To additionally stage
+accepted library releases for the existing static-site/dependency-pack pipeline,
+add `--stage-site ext/demo-site` to a fresh run. Staging authenticates the bundle
+bytes, preserves existing versions, refuses conflicts, writes bundles before
+index entries, and does not deploy or contact a public registry. `--version LABEL`
+selects a new exact release label for the library set; changing executable bytes
+under an existing name/version is rejected. Use an exclusively owned local site
+workspace; staging is not a concurrent index-merging protocol.
+
+
+## Retained provenance (opt-in)
+
+First retain a successful published-library inspector run as described above.
+Then capture/rebuild the actual three library modules and inspector:
+
+```sh
+nix build .#
+WORK=$(mktemp -d)
+nix shell --inputs-from . nixpkgs#python3 -c python3 test/provenance.py \
+ result/bin/tricu --accepted "$INSPECTOR_ACCEPTANCE_DIRECTORY" \
+ --directory "$WORK/provenance"
+```
+
+The harness copies producer workspaces/stores to a disposable location, captures
+four self-contained input packs, **deletes that producer copy**, and rebuilds in
+fresh directories from the packs only. It runs outside the checkout, with the
+packaged compiler data path deliberately unavailable. No registry is started.
+Each native module and compiler-returned receipt must reproduce exactly. The
+initial validation host took about 3.5 minutes total: roughly 31/35/139 seconds
+for foundation/file IO/inspection libraries and 2 seconds for the inspector.
+These are observations, not performance guarantees.
+
+An additional genuine build adds an ignored source comment: source, receipt and
+pack identities must change while the executable stays identical. That history
+must also reproduce after its disposable producer is deleted.
+
+A separate negative fixture changes the inspector's source and native receipt to
+claim the old output, authenticates every changed object, and includes a matching
+poisoned producer cache. Byte/receipt verification must succeed as **claims only**;
+a fresh rebuild must ignore that cache, produce different code, and report a
+mismatch. This guards the distinction between integrity and actual reproduction.
+Artifacts, pack pins, commands, timings and outcomes are retained in `report.json`.
+Children use an 8 GiB GHC heap ceiling, 900-second compiler deadline and bounded
+subprocess deadlines within a 2400-second overall budget; these are not RSS bounds.
+
+The default `test/provenance.py` run (wired into `nix flake check`) does not compile
+source. Independent wire/CAS/native-receipt fixtures cover no-execution verification
+with a non-compiler tree, stale source refusal, explicit receipt selection without
+a cache, immutable capture destinations, incorrect pins, missing/corrupt sources,
+options, receipts and executables, malformed inventories, path traversal,
+symlinked CAS directories, an unauthorized compiler, and verification after all
+producer files have been removed. Verification snapshots assert no file/mtime
+changes. Each child has a 512 MiB GHC heap ceiling and a 30-second deadline.
diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md
@@ -4,11 +4,11 @@ Explore Tree Calculus, run portable programs, compile tricu source, and export
stored definitions in the browser. The site lives in `ext/demo-site` and uses
TypeScript compiled to browser ES modules.
-The four panels share one page:
+The three panels share one page:
- **Ecosystem** introduces the language and its libraries.
-- **Playground** applies arguments to a pinned package definition.
-- **Compiler** turns a standalone source unit into callable trees.
+- **Playground** selects named/versioned packages, applies arguments to published
+ definitions, and compiles editable demos or scratch programs in one workspace.
- **Export Definition** writes a stored tree without running it.
## Programs and controls
@@ -44,6 +44,71 @@ host-effect policy too when reproducing execution. A remote recipient can run th
same closed tree in its evaluator. This site loads authenticated local packages;
Conversation IO and repository hosts illustrate effect-protocol adaptation.
+### Published libraries
+
+The index also includes `tricu-base`, `tricu-file-io`, and
+`arboricx-inspection` at `0.1.0`. These are reusable native library releases, not
+curated editable demos. The same verified download and stored-definition export
+controls apply. IO exports construct actions; the browser does not acquire native
+filesystem permissions by downloading them.
+
+The native [offline bundle inspector](../../docs/modules.md.html#first-published-libraries-an-offline-bundle-inspector)
+consumes all three through one native project declaration/lock. Its [opt-in acceptance/staging command](../../docs/validation.md.html#published-library-consumer-opt-in)
+uses the shipped stable compiler and a clean offline consumer build before
+adding library pins/assets to this site's existing release/seed pipeline.
+`prepare-index.py` retains these entries when refreshing curated demo releases.
+No library compilation or source dependency resolution happens during site build,
+seeding, or browser package selection.
+
+### Published packages and local drafts
+
+The package selector reads `packages.json`, a versioned static index of exact
+name/version → native manifest + whole-bundle pins. Demo buttons are shortcuts;
+the raw hash input still accepts arbitrary browser-compatible bundles. Selecting a
+release or following a selection link never runs a program. Browsing arbitrary
+packages does not imply editable source or browser module resolution.
+
+Curated demos have a source editor. Exact original source means **Run published**:
+the authenticated bundle is executed, not recompiled. An edit switches to
+**Local draft** and **Compile & run draft**, with text and color distinguishing
+its authorship. Editing discards any previously compiled heap and result. A failed
+draft never falls back to an earlier draft or the published executable. Restore
+published source reinstates the original bundle path. Compile-only remains
+available, and subsequent draft runs reuse the compiled definitions.
+
+The first compilation in a page session requires confirmation: the compiler is
+itself tricu running as Tree Calculus in the browser, not JavaScript compilation.
+It can take minutes and substantial memory. An elapsed indicator and Stop remain
+available; source is retained after cancellation or failure. There is no default
+compilation work/time cutoff. Standard retains application memory-related guards;
+Use browser limits explicitly bypasses execution ceilings, with a warning that
+the browser may crash or reload the tab. Advanced controls are optional, not a prerequisite
+for running the published demos.
+
+**Download published .arboricx** returns the original, bounded, hash-verified bundle
+bytes, not a re-encoding, applied result or local draft. Textual exports likewise
+contain a selected stored definition. These actions and published sharing are
+disabled while authoring a draft; restore the published source first. Draft bundle
+publication/export is not part of this profile. **Download saved source** is a
+separate recovery action: it downloads your authored text, not executable trees.
+
+Draft edits are saved to one `localStorage` recovery slot on this origin, and
+saved again synchronously before constructing the compiler Worker. Source,
+selected export, arguments, output mode and curated demo context are retained;
+compiled heaps and risky resource settings are not. The latest draft replaces
+that slot. **Recover saved draft** restores an editor in Standard without running
+or compiling anything. Curated recovery requires the same published demo hash
+and environment identifier still in the catalogue; missing context is an explicit
+error, not a fallback to another environment. Saved source can still be downloaded.
+**Forget saved draft** removes the recovery copy without clearing the editor.
+Later editing or compilation saves it again.
+
+If storage is blocked, full or invalid, the UI reports that recovery is unavailable.
+A failed save before compilation requires separate explicit permission to proceed
+without recovery. Local storage is best-effort, not a durable backup: private
+browsing, browser cleanup, crashes or other tabs writing the same slot can lose
+or replace it. Keep important programs elsewhere too. Nothing is uploaded.
+
### Arguments, results, and IO
Choose an export, ordered JSON **typed literal** arguments, an explicit result
@@ -119,17 +184,16 @@ before unfolding; oversized expansions fail with a suggestion to use DAG.
DAG output has an 8 MiB UTF-8 limit. Both traversal and writing are iterative,
including deeply nested trees. Stop, reset, replacement, and pane changes retain
the existing Worker termination boundary; export allows 10 active seconds.
-These output limits remain in force when execution limits are disabled.
+These output limits remain in force when Use browser limits is selected.
## Compile source
-The **Compiler** panel downloads the authenticated reference
-compiler bundle only when you click Compile. It invokes
-`source_unit.compileStableModuleDefinitions` with a small default contract environment
-and a standalone source unit. There are no workspace imports or publication
+The unified **Playground** downloads the authenticated reference compiler bundle
+only after explicit compilation consent. It invokes
+`source_unit.compileStableModuleDefinitions` with an explicit environment and a standalone source unit. There are no workspace imports or publication
operations. The packaged compiler uses stable lowering.
-Examples: identity, checked identity, pair constructor, two dependency-ordered definitions, and a
+Scratch examples: identity, checked identity, pair constructor, two dependency-ordered definitions, and a
raw `tricuIO` line-input program. Compile produces a bounded definition selector;
apply a selected definition using the same literal/result/IO codecs as ordinary
packages. The Worker retains compiled **handles**, never serialized program trees.
@@ -150,9 +214,7 @@ available for user definitions. Contract names are ordinary source names and mus
be distinct from top-level definitions in this compiler profile.
The checked-identity example is `id x =@nat? x`, applied to natural 42. Both argument
-and result checks use `id x@nat? =@nat? x`; more guards cost more work, so larger
-examples can require increasing the work field (the dual check is just above the
-default 80m allowance). Failed annotations return error Results; Tree graph shows
+and result checks use `id x@nat? =@nat? x`; more guards cost more work, so larger examples can take longer and need more memory. Failed annotations return error Results; Tree graph shows
the failure tag/payload. `id =@nat? (x : x)` checks the **function tree**, not its
future output. That tree fails the canonical-natural contract, as do redundant high-zero
representations. Contracts inspect tree structure at runtime.
@@ -161,11 +223,18 @@ The full binding list is in `src/contracts.ts`. Compiler and application share o
heap; phase collection retains compiled handles. The independent Haskell captures
cover ordinary source, annotations, failure propagation, and collection contracts.
+Curated compilation environments are fixed by `environments.json`, including the
+exact compiler bundle pin. Intro, conversation, microscope and RGB compile with
+an empty environment. Practical demos receive a prebaked bundle of closed base
+and support helpers, prepared through Haskell using the same pinned compiler as
+their published programs. Its digest and exact binding names are checked before
+use. No browser import resolver or host filesystem access is introduced. Scratch
+programs retain the default contract environment described above.
+
Compiler input is limited to 16,384 Unicode scalars. Diagnostic labels are limited
-to 512 scalars, and displayed definitions to 16 names of 128 scalars each. Compiling
-larger examples may require increasing the work budget: the local-recursion and
-`case` conformance case uses an explicit 500m profile rather than the UI's 80m.
-Input size alone does not bound compilation work.
+to 512 scalars, and displayed definitions to 16 names of 128 scalars each. Input size alone does not bound compilation work or memory. Long compilation
+is permitted; a large live graph can still reach Standard's memory guard.
+Use browser limits removes that application ceiling but not engine/OS limits.
One compilation supports repeated
applications in the same Worker; each application resets its work allowance to
@@ -177,13 +246,12 @@ is idle too. Invalid literal JSON can be corrected without recompiling. Successf
runs retain every compiled definition and collect run temporaries; change arguments,
output mode or definition selection and run again. Owned IO collection retains the
definition roots too, so replaying an IO definition requests fresh input/actions.
-Errors discard the session; the active-time deadline resets for each run. Selection links never
-fetch the compiler or automatically execute source. Ordinary examples still need
+Errors discard the session; the active-time deadline resets for each run. Selection links never fetch the compiler or automatically execute source. Ordinary examples still need
no compiler download.
## Sessions and execution limits
-The shared pane opens on **Ecosystem**. **Playground**, **Compiler**, and
+The shared pane opens on **Ecosystem**. **Playground** and
**Export Definition** are explicit
choices, with keyboard Arrow keys/Home/End and ARIA tab/panel relationships.
Inactive panels are inert and removed from layout. The shared pane follows the
@@ -199,34 +267,40 @@ Switching panels stops in-flight package work, retaining selections and complete
exports; errors appear in the export panel rather than a hidden execution panel.
Compiler downloading/execution starts with Compile.
-Both runners have an **Execution limits** editor for live nodes, work units,
-evaluator frames, and active seconds. Defaults come from runtime constants; zero
-seconds means unlimited time. Positive whole-number budgets and nonnegative time
-are validated in the UI and again at the Worker boundary. **Apply limits** starts
-a fresh session; draft or invalid entries preserve the current heap. **Restore
-defaults** resets fields and profile. **Disable execution limits** bypasses all
-four, remembering the last applied finite values. Values persist for this page
-session; they are not supplied by selection links or package metadata.
-
-Compiler defaults: 6m live nodes, 80m work, 100k frames, time unlimited. Package
+The visible profile choice starts in **Standard**. Selecting **Use browser
+limits** explicitly bypasses node, evaluator-frame, work and active-time execution
+ceilings. The adjacent warning explains that the tab may crash or reload; no
+percentage of device RAM is reserved or guaranteed. Garbage collection and Stop
+remain available in both profiles. Browser-limit selection is not persisted across
+page reloads and is not recovered from draft storage.
+
+The **Advanced** editors customize Standard's live nodes, work units, evaluator
+frames and active seconds. Zero work or seconds means no corresponding cutoff;
+node and frame settings remain positive and finite. Values are validated again
+at the Worker boundary, even when the profile bypasses them. **Apply limits**
+starts a fresh session; invalid edits preserve the current heap. **Restore
+defaults** restores Standard and its defaults. Browser limits disables these
+editors but remembers the last applied Standard settings for switching back.
+Profiles/settings are not supplied by selection links or package metadata.
+
+Standard compiler defaults: 6m live nodes, no work cutoff, 100k frames, time unlimited. Package
defaults: 250k live nodes, 20m work, 50k frames, 10 active seconds. Each compile/run
gets its own active deadline; input/step/idle pauses exclude time. Long deadlines
use chunks that fit browser timers. Packages retain an independent 10-second
-loading deadline in all profiles. All package profiles
-enable tracing GC and exclusive owned IO roots; thresholds adapt to small node
-budgets. Settings changes discard/reload the heap while preserving source and
+loading deadline in all profiles. All package profiles enable tracing GC and exclusive owned IO roots; thresholds
+adapt to small node budgets. Resource-setting changes discard/reload the heap while preserving source and
selected inputs. Download/schema/literal/IO/output/handle guards remain. Stop and
leaving the pane end running jobs; browser memory restrictions apply.
## Ordinary package resource and safety boundaries
-Download and format limits remain enabled when execution limits are disabled.
+Download and format limits remain enabled when Use browser limits is selected.
Every bundle must match its whole-file SHA-256. A bundle selected for both compiler
and ordinary-package roles must satisfy the smaller download cap.
- Ordinary package downloads: 1 MiB (compiler-only profile: 2 MiB); bundles: 100,000 physical indexed nodes, 4,096 exports/roots.
- Graph: 250,000 live nodes (including thunks), 20,000,000 work units,
- 50,000 evaluation frames by default, editable in the settings. Loading/observers/codecs consume the session budget.
+ 50,000 evaluation frames by default, editable under Advanced. Loading/observers/codecs consume the session budget.
- Inputs: at most 16 arguments; list nesting 16 /4,096 items; argument JSON
16,000 characters; ternary literal 4,096 nodes; text 4,096 scalars. Natural
inputs are bounded decimal strings; decoded naturals are capped at 4,096 bits.
@@ -239,7 +313,15 @@ and ordinary-package roles must satisfy the smaller download cap.
or suspension; this is not a hard real-time guarantee. Stop/reset/replacement
terminates the Worker and does not execute cleanup/finalizers.
-These bound program work, graph growth, and output: not total browser memory.
+Standard bounds graph growth and optionally program work; output bounds apply
+in both profiles. Neither is a byte-level total browser memory cap. They cannot promise that every full demo fits in the default
+compiler memory guard. In this slice, a complete shipping-demo recompilation
+hit the 6m-node guard after approximately seven minutes in the Node validation
+host (about 1.2 GiB sampled RSS); smaller drafts using its prebaked environment
+passed in Chromium. This is a memory limit, not a compilation timeout or a
+reason to hide the source. It records Standard behavior, not an acceptance result
+for full shipping recompilation under browser limits. Published demos remain
+available without compilation.
JS objects, decoded bundle records, hash buffers, queues, and GC have overhead.
A 32-node DAG denoting 4,294,967,295 logical nodes loads/returns cheaply and is
not rejected for its logical size. Actual programs may still traverse all
@@ -257,10 +339,9 @@ nix build .#arboricxSiteDeps --out-link result-site-deps
result-site-deps/bin/arboricx-seed-site "${REGISTRY_STORE:?select an absolute persistent store path}"
```
-`arboricxSite` contains HTML, CSS, strict freshly compiled JS, `catalogue.json`,
-and `compiler.json`. It has **no executable bundles**. A small `nix-support`
+`arboricxSite` contains HTML, CSS, strict freshly compiled JS, `catalogue.json`, `packages.json`, `environments.json`, and `compiler.json`. It has **no executable bundles**. A small `nix-support`
reference retains its matching dependency pack in the Nix closure for rollback.
-`arboricxSiteDeps` contains the nine pinned active demo/compiler bundles, a
+`arboricxSiteDeps` contains the thirteen pinned active demo/compiler/environment/library bundles, a
`share/arboricx-site-deps/dependencies.json` manifest (whole-byte SHA-256, size,
uses), and `bin/arboricx-seed-site`. Release building checks every dependency's
actual bytes without recompiling programs or relabeling compiler fixtures.
@@ -290,8 +371,10 @@ It is distinct from the CLI's domain-separated module/export CAS. The seeder
verifies every source and every existing target before adding missing files,
serializes cooperating seeders with a store lock, and installs fsynced complete
files with atomic no-clobber links. Matching files retain inode/mtime; corrupt or
-symlink objects fail rather than being overwritten. No aliases/manifests need
-registration, no HTTPS upload is performed, and old objects are never deleted.
+symlink objects fail rather than being overwritten. No mutable aliases need registration. The pack also seeds immutable
+`packages/NAME/VERSION` records after their bundle objects, using the same
+no-clobber checks. Conflicting existing versions fail before installing missing
+objects. No HTTPS upload is performed, and old objects/releases are never deleted.
The target filesystem must support hard links; the store and its ancestor
permissions are controlled by the operator. New directories are subject to the
service umask; new bundle objects are read-only/world-readable.
@@ -306,10 +389,12 @@ Serving site assets before successful seeding risks transient missing pins.
Rollback restores the paired site/dependency release; newer/older disk objects
remain available to cached pages and shared selections. Keep the registry's
persistent host-store mount at `/app/store`; don't put seeds beneath that image
-path because the mount hides them. The container image/server need no changes.
+path because the mount hides them. Rebuild the native server from the current source to enable its exact-release
+GET endpoint. The browser uses the static index and pinned bundle routes; native
+CLI resolution uses those seeded release records.
For development or deployment checks, the opt-in Chromium harness provides a
-local registry with all nine dependencies and deliberately denies static bundle
+local registry with all demo/compiler/environment dependencies and deliberately denies static bundle
paths. Production registry/container deployment and non-Chromium behavior are
separate from those tests.
@@ -396,7 +481,7 @@ compiler IO also lists all saved definitions as additional retained roots.
Stats separate live cells (`nodes`), cumulative cell allocations (`allocations`),
peak live cells, collections, reclaimed cells, and collector work. Finite limits/fuel
-apply to collection too; the compiler with limits disabled instead relies
+apply to collection too; the compiler in Use browser limits instead relies
on manual Stop/Worker termination and actual browser limits. No budget reset.
These are graph-cell metrics, not total JavaScript object/heap byte measurements.
A genuinely large live result can still exhaust the live-cell cap; constructor
@@ -411,8 +496,8 @@ arithmetic reuse the accepted library instead of duplicating it.
pinned compiler bundle, then closes these handles into each demo. Demos record the
support-source digest and base names alongside actual compiler/host identities.
No runtime library fetching or additional evaluator primitives are needed.
-Copying a practical source unit into Compiler requires supplying these helpers
-too; the compiler's default environment contains only its contract profile.
+Editing a practical demo selects its prebaked helper environment automatically.
+Scratch programs instead receive only the default contract environment.
## Build / regenerate / validate
@@ -421,14 +506,16 @@ No npm installation. Tooling comes from the repository's locked nixpkgs:
```sh
./ext/demo-site/build.sh # strict TS → dist/*.js (TypeScript 5.9.3)
./ext/demo-site/build.sh --check # fresh temporary compile; exact shipped-JS parity
-nix shell --inputs-from . nixpkgs#nodejs -c node --test ext/demo-site/test/*.test.mjs
+nix shell --inputs-from . nixpkgs#nodejs -c python3 ext/demo-site/test/run-bounded.py \
+ --seconds 60 -- node --max-old-space-size=512 --test --test-concurrency=1 ext/demo-site/test/*.test.mjs
# Heavy compiler comparisons: explicit opt-in, not included in *.test.mjs
nix shell --inputs-from . nixpkgs#nodejs -c node --max-old-space-size=768 ext/demo-site/test/compiler-conformance.mjs
nix shell --inputs-from . nixpkgs#nodejs -c node --max-old-space-size=768 ext/demo-site/test/contracts-conformance.mjs
-nix shell --inputs-from . nixpkgs#nodejs -c node ext/demo-site/test/browser.mjs chromium
+nix shell --inputs-from . nixpkgs#nodejs -c python3 ext/demo-site/test/run-bounded.py \
+ --seconds 300 -- node --max-old-space-size=512 ext/demo-site/test/browser.mjs chromium
# Optional narrow screenshot as a final argument:
# node ext/demo-site/test/browser.mjs chromium /tmp/arboricx-browser.png
-# Also writes a compiler-panel-only screenshot beside it.
+# Also writes workspace and export screenshots beside it.
# Optional deterministic cost screen against a caller-selected retained prior site:
nix shell --inputs-from . nixpkgs#nodejs -c node --max-old-space-size=768 \
ext/demo-site/test/demo-cost.mjs "${REFERENCE_SITE:?select a retained prior site directory}"
@@ -443,6 +530,36 @@ profile plus `--no-sandbox` for container compatibility; only run them against
this trusted local fixture site. Non-Chromium and real mobile performance are
not yet validated. Browser tests exercise a local registry, not production.
+The bounded runner uses a Linux per-process writable-data limit, an aggregate
+process-group RSS watchdog and a whole-job deadline. The watchdog is sampled,
+not a cgroup guarantee. Run Node tests serially. Binary artifact checks compare
+hashes rather than expanding giant assertion diffs. The site compiler is
+verified against its retained publication metadata and source snapshots, not
+assumed equal to the repository's latest compiler fixture.
+
+To refresh named releases and prebaked environments after preparing demos:
+
+```sh
+python3 ext/demo-site/prepare-index.py result/bin/tricu
+# Changed executable bytes require an explicit new version:
+python3 ext/demo-site/prepare-index.py result/bin/tricu --version shipping=0.2.0
+```
+
+Old index entries and bundles are retained. `release.py` includes all indexed
+releases in the dependency pack, so both current and historical selections can
+be seeded before site activation. Publication metadata is separate from source
+and runnable presets. These commands are administrative preparation, not public
+network publication.
+
+Long full-source draft comparisons are explicitly opt-in (optional final argument
+selects a single demo). They use independent Haskell result captures, keep the
+normal compiler memory guard, and can fail at that guard on large examples:
+
+```sh
+nix shell --inputs-from . nixpkgs#nodejs -c python3 ext/demo-site/test/run-bounded.py \
+ --seconds 600 -- node --max-old-space-size=1024 ext/demo-site/test/editable-conformance.mjs intro
+```
+
To genuinely recompile curated packages / Haskell goldens:
```sh
diff --git a/ext/demo-site/bundles/1052124d79942aad560a3343ba92aace597e5d31c52ab76460e674db30aa5771.arboricx b/ext/demo-site/bundles/1052124d79942aad560a3343ba92aace597e5d31c52ab76460e674db30aa5771.arboricx
Binary files differ.
diff --git a/ext/demo-site/bundles/321ddc5508ec8b9c2bdcdd0c9bcf6db1eaa91160ce2827cc2bd9b6efb9e231d7.arboricx b/ext/demo-site/bundles/321ddc5508ec8b9c2bdcdd0c9bcf6db1eaa91160ce2827cc2bd9b6efb9e231d7.arboricx
Binary files differ.
diff --git a/ext/demo-site/bundles/7dd76cb9f5df4ee3f49cae5fd09428810b8e11b9ce31902eb39e883d1362c7a6.arboricx b/ext/demo-site/bundles/7dd76cb9f5df4ee3f49cae5fd09428810b8e11b9ce31902eb39e883d1362c7a6.arboricx
Binary files differ.
diff --git a/ext/demo-site/bundles/9b5195255e1f86ef55a6fe4b0d7d617663b28e3b582af352b9c44ef19c07b3be.arboricx b/ext/demo-site/bundles/9b5195255e1f86ef55a6fe4b0d7d617663b28e3b582af352b9c44ef19c07b3be.arboricx
Binary files differ.
diff --git a/ext/demo-site/catalogue.json b/ext/demo-site/catalogue.json
@@ -64,7 +64,8 @@
"output": "tree",
"mode": "pure"
}
- }
+ },
+ "environment": "empty"
},
{
"id": "greet",
@@ -76,7 +77,8 @@
"arguments": [],
"output": "string",
"mode": "io",
- "presets": {}
+ "presets": {},
+ "environment": "empty"
},
{
"id": "microscope",
@@ -128,7 +130,8 @@
"output": "bool",
"mode": "pure"
}
- }
+ },
+ "environment": "empty"
},
{
"id": "rgb",
@@ -149,7 +152,8 @@
],
"output": "string",
"mode": "pure",
- "presets": {}
+ "presets": {},
+ "environment": "empty"
},
{
"id": "shipping",
@@ -211,7 +215,8 @@
"output": "record",
"mode": "pure"
}
- }
+ },
+ "environment": "practical"
},
{
"id": "events",
@@ -288,7 +293,8 @@
"output": "record",
"mode": "pure"
}
- }
+ },
+ "environment": "practical"
},
{
"id": "registration",
@@ -340,7 +346,8 @@
"output": "record",
"mode": "pure"
}
- }
+ },
+ "environment": "practical"
},
{
"id": "migration",
@@ -405,6 +412,7 @@
"output": "record",
"mode": "pure"
}
- }
+ },
+ "environment": "practical"
}
]
diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js
@@ -1,9 +1,11 @@
import { createTreeExport } from './export-app.js';
import { activePane } from './tabs.js';
+import { DRAFT_KEY, readDraft, writeDraft } from './draft-storage.js';
import { readSettings } from './limits-ui.js';
import { ActiveClock } from './clock.js';
import { PACKAGE_ACTIVE_MS, activeMilliseconds } from './execution.js';
-import './compiler-app.js';
+import { sourceText } from './compiler.js';
+import { packageIndex } from './packages.js';
import { bundleHash, fetchBytes } from './bundle.js';
import { Graph } from './graph.js';
import { line, literals, outputType } from './codecs.js';
@@ -13,6 +15,17 @@ function el(id) {
throw new Error(`Missing element ${id}`);
return element;
}
+const sourceEditor = el('source-editor'), compileButton = el('compile');
+const restoreButton = el('restore-source'), downloadButton = el('download-bundle');
+let draft = false, scratch = false, compiled = false, busy = false, originalSource;
+let compilationStats = '', preferredName = 'id';
+let compilerAcknowledged = false, requestedRun = false;
+const consent = el('compile-consent');
+let compilerTimer;
+function stopCompilerActivity() { clearInterval(compilerTimer); compilerTimer = undefined; el('compile-activity').hidden = true; }
+el('confirm-compile').onclick = () => { compilerAcknowledged = true; consent.close(); compileDraft(requestedRun); };
+el('cancel-compile').onclick = () => consent.close();
+let releases = [];
const hashInput = el('bundle-hash'), exportsInput = el('export');
const argsInput = el('arguments'), resultCodec = el('output-type');
const mode = el('mode'), steps = el('step-mode');
@@ -21,7 +34,7 @@ let settings = readSettings('package');
const deadline = new ActiveClock(() => finish('Stopped: active execution time limit exceeded.', true));
const shareButton = el('share');
const treeExport = createTreeExport(message => {
- if (!loaded || !selection)
+ if (draft || !loaded || !selection)
return;
if (worker)
startExport(message, value => worker?.postMessage(value));
@@ -39,25 +52,47 @@ let loaded = false, worker, pending;
let automaticRun = false;
let savedRun;
let preferredExport;
+let downloadAfterLoad = false;
const traceRows = new Map();
function pauseClock() { deadline.pause(); }
function clock() { deadline.start(); }
function runClock() { deadline.reset(activeMilliseconds(settings)); clock(); }
-function controls(busy) {
- runButton.disabled = busy || !loaded;
- stopButton.disabled = !busy;
- exportsInput.disabled = busy || !loaded;
+function controls(working) {
+ busy = working;
+ recoveryButtons();
+ runButton.disabled = busy || (draft ? !sourceEditor.value.trim() : !loaded);
+ runButton.textContent = draft ? (compiled ? 'Run local draft' : 'Compile & run draft') : 'Run published';
+ runButton.classList.toggle('draft', draft);
+ compileButton.disabled = busy || !draft;
+ sourceEditor.disabled = busy;
+ restoreButton.disabled = busy || originalSource === undefined || !draft;
+ downloadButton.disabled = busy || draft || !loaded;
+ el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified';
+ el('authorship').classList.toggle('draft', draft);
+ sourceEditor.classList.toggle('draft', draft);
+ el('compiler-settings').hidden = !draft;
+ el('compiler-resource-choice').hidden = !draft;
+ el('package-resource-choice').hidden = draft;
+ el('compiler-disclosure').hidden = !draft;
+ el('export-draft-note').hidden = !draft;
+ el('package-settings').hidden = draft;
+ for (const button of document.querySelectorAll('[data-compiler-example]'))
+ button.disabled = busy;
+ stopButton.disabled = !busy && !(draft && worker);
+ exportsInput.disabled = busy || (draft ? !compiled : !loaded);
argsInput.disabled = busy;
mode.disabled = busy;
resultCodec.disabled = busy;
steps.disabled = busy;
- shareButton.disabled = busy || !loaded;
- treeExport.controls(busy, loaded);
+ shareButton.disabled = busy || draft || !loaded;
+ treeExport.controls(busy, loaded && !draft);
}
function dispose() {
+ stopCompilerActivity();
pauseClock();
worker?.terminate();
worker = undefined;
+ compiled = false;
pending = undefined;
inputForm.hidden = true;
nextButton.disabled = true;
@@ -75,7 +110,11 @@ function clearOutput() {
lineInput.value = '';
}
function finish(message, error = false) {
- if (!loaded)
+ if (error && /Graph allocation limit|evaluation stack limit|Graph stack limit/u.test(message))
+ message = `Stopped to protect memory. Your source is unchanged. Standard's application guard was reached. You can explicitly choose Use browser limits to try a larger graph, at the risk of a tab crash or reload. Technical detail: ${message}`;
+ else if (error && /Graph fuel limit/u.test(message))
+ message = `Stopped at the optional work guard. Your source is unchanged; see Advanced to adjust it. Technical detail: ${message}`;
+ if (!draft && !loaded)
el('package-info').textContent = `Package not loaded. ${message}`;
if (treeExport.pending || (activePane() === 'export-definition' && !loaded))
treeExport.fail(message);
@@ -103,7 +142,6 @@ function startExport(message, send) {
function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) {
if (!selection)
return;
- window.dispatchEvent(new Event('package-start'));
dispose();
settings = readSettings('package');
deadline.reset(PACKAGE_ACTIVE_MS);
@@ -136,6 +174,23 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) {
lineInput.focus();
}
break;
+ case 'downloaded': {
+ if (data.hash !== selection?.hash || draft)
+ break;
+ pauseClock();
+ controls(false);
+ el('status').textContent = 'Ready';
+ const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' }));
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = `${data.hash}.arboricx`;
+ document.body.append(anchor);
+ anchor.click();
+ anchor.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ el('result').textContent = `Downloaded original verified bundle ${data.hash}. No draft or applied result included.`;
+ break;
+ }
case 'loaded': {
currentVerified = true;
loaded = true;
@@ -152,9 +207,19 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) {
preferredExport = undefined;
treeExport.selection(exportsInput);
el('package-info').textContent = `SHA-256 verified · ${data.nodes.toLocaleString()} stored DAG nodes · ${data.exports.length} export(s) · ${data.hash}`;
+ if (!preserveInputs && !automaticRun && !exportAfterLoad && !downloadAfterLoad) {
+ originalSource = selected?.environment !== undefined && data.source !== null ? data.source : undefined;
+ sourceEditor.value = originalSource ?? '';
+ sourceEditor.hidden = originalSource === undefined;
+ el('source-notes').hidden = originalSource !== undefined;
+ }
el('source-label').textContent = data.source === null ? 'Package metadata' : 'Curated tricu / stable';
el('source').textContent = data.source ?? `Verified package metadata\n${JSON.stringify(data.metadata, null, 2)}\n\nChoose an export, its arguments and a result interpretation to apply this tree.`;
- if (exportAfterLoad) {
+ if (downloadAfterLoad) {
+ downloadAfterLoad = false;
+ send({ type: 'download' });
+ }
+ else if (exportAfterLoad) {
pauseClock();
startExport(exportAfterLoad, send);
}
@@ -238,6 +303,10 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) {
send({ type: 'load', ...selection, ...settings });
}
function select(entry) {
+ draft = false;
+ scratch = false;
+ originalSource = undefined;
+ downloadAfterLoad = false;
dispose();
clearOutput();
clearTreeExport();
@@ -245,6 +314,10 @@ function select(entry) {
loaded = false;
selection = { hash: entry.hash, localId: entry.id };
hashInput.value = entry.hash;
+ sourceEditor.hidden = true;
+ el('source-notes').hidden = false;
+ const currentVersion = el('package-version').value;
+ showRelease(releases.find(r => r.hash === entry.hash && r.demo === entry.id && r.version === currentVersion) ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id));
setPreset(entry);
el('source').textContent = 'Verifying package…';
for (const button of document.querySelectorAll('[data-demo]'))
@@ -252,11 +325,18 @@ function select(entry) {
newWorker(false);
}
el('load').onclick = () => {
+ draft = false;
+ scratch = false;
+ originalSource = undefined;
+ downloadAfterLoad = false;
dispose();
clearOutput();
clearTreeExport();
loaded = false;
try {
+ sourceEditor.hidden = true;
+ el('source-notes').hidden = false;
+ showRelease();
const hash = bundleHash(hashInput.value);
selected = undefined;
selection = { hash };
@@ -272,10 +352,17 @@ el('load').onclick = () => {
finish(error instanceof Error ? error.message : String(error), true);
}
};
-hashInput.oninput = () => { el('package-info').textContent = 'Load this package to verify its hash and choose a definition.'; clearTreeExport(); dispose(); loaded = false; selection = undefined; selected = undefined; controls(false); el('status').textContent = 'Load package'; };
-exportsInput.onchange = () => { clearTreeExport(); treeExport.selection(exportsInput); if (selected?.presets?.[exportsInput.value])
+hashInput.oninput = () => { showRelease(); draft = false; scratch = false; originalSource = undefined; sourceEditor.hidden = true; el('source-notes').hidden = false; el('package-info').textContent = 'Load this package to verify its hash and choose a definition.'; clearTreeExport(); dispose(); loaded = false; selection = undefined; selected = undefined; controls(false); el('status').textContent = 'Load package'; };
+exportsInput.onchange = () => { preferredName = exportsInput.value; clearTreeExport(); treeExport.selection(exportsInput); if (!scratch && selected?.presets?.[exportsInput.value])
setPreset(selected, exportsInput.value); };
runButton.onclick = () => {
+ if (draft) {
+ if (compiled)
+ runDraft();
+ else
+ compileDraft(true);
+ return;
+ }
if (!loaded || !selection)
return;
clearOutput();
@@ -284,7 +371,6 @@ runButton.onclick = () => {
throw new Error('Argument JSON limit exceeded');
const args = literals(JSON.parse(argsInput.value)), output = outputType(resultCodec.value);
savedRun = { type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value === 'io' ? 'io' : 'pure', step: steps.checked };
- window.dispatchEvent(new Event('package-start'));
if (worker) {
controls(true);
runClock();
@@ -327,9 +413,9 @@ inputForm.onsubmit = event => {
nextButton.onclick = () => { if (pending?.kind === 'step')
resume(); };
stopButton.onclick = () => finish('Stopped. Session discarded.', true);
-el('reset').onclick = () => { dispose(); clearOutput(); el('status').textContent = loaded ? 'Ready' : 'Load package'; };
+el('reset').onclick = () => { dispose(); clearOutput(); el('status').textContent = draft ? 'Ready to compile' : loaded ? 'Ready' : 'Load package'; };
shareButton.onclick = async () => {
- if (!selection)
+ if (draft || !selection)
return;
const url = new URL(location.href), fragment = new URLSearchParams({ bundle: selection.hash, export: exportsInput.value });
if (selection.localId)
@@ -344,10 +430,350 @@ shareButton.onclick = async () => {
}
};
window.addEventListener('pagehide', dispose);
-window.addEventListener('compiler-start', () => { if (worker) {
+function populatePackages() {
+ const packages = el('package-name');
+ packages.replaceChildren();
+ const placeholder = document.createElement('option');
+ placeholder.value = '';
+ placeholder.textContent = 'Choose a package';
+ packages.append(placeholder);
+ for (const name of new Set(releases.map(r => r.name))) {
+ const option = document.createElement('option');
+ option.value = name;
+ option.textContent = name;
+ packages.append(option);
+ }
+ packages.onchange = () => {
+ const versions = el('package-version');
+ versions.replaceChildren();
+ el('load-release').disabled = !packages.value;
+ for (const release of releases.filter(r => r.name === packages.value)) {
+ const option = document.createElement('option');
+ option.value = release.version;
+ option.textContent = release.version;
+ versions.append(option);
+ }
+ };
+ packages.dispatchEvent(new Event('change'));
+}
+function showRelease(release) {
+ const packages = el('package-name');
+ packages.value = release?.name ?? '';
+ packages.dispatchEvent(new Event('change'));
+ el('package-version').value = release?.version ?? '';
+ el('release-info').textContent = release ? `${release.title} · ${release.name}@${release.version} · module ${release.manifest}` : 'Direct bundle pin (no name/version selected).';
+}
+el('load-release').onclick = () => {
+ const name = el('package-name').value, version = el('package-version').value;
+ const release = releases.find(r => r.name === name && r.version === version);
+ if (!release)
+ return;
+ const demo = entries.find(e => e.id === release.demo && e.hash === release.hash);
+ if (demo)
+ select(demo);
+ else {
+ hashInput.value = release.hash;
+ el('load').click();
+ }
+ showRelease(release);
+};
+downloadButton.onclick = () => {
+ if (draft || !loaded || !selection)
+ return;
+ if (worker) {
+ controls(true);
+ deadline.reset(PACKAGE_ACTIVE_MS);
+ clock();
+ worker.postMessage({ type: 'download' });
+ }
+ else {
+ downloadAfterLoad = true;
+ newWorker(false, true);
+ }
+};
+function recoveryButtons() {
+ try {
+ const saved = readDraft(localStorage);
+ el('recover-draft').hidden = !saved;
+ el('forget-draft').hidden = !saved;
+ el('download-draft-source').hidden = !saved;
+ el('recover-draft').disabled = busy || (!!saved?.demo && entries.length === 0);
+ if (saved && !el('draft-save-status').textContent)
+ el('draft-save-status').textContent = 'A saved draft is available on this device. Recover it to continue editing; recovery does not run code.';
+ }
+ catch {
+ el('recover-draft').hidden = true;
+ el('download-draft-source').hidden = true;
+ el('forget-draft').hidden = false;
+ el('draft-save-status').textContent = 'Draft recovery is unavailable. Keep a separate copy of your source. You can try Forget saved draft to remove invalid saved data.';
+ }
+}
+function saveDraft() {
+ if (!draft)
+ return true;
+ try {
+ writeDraft(localStorage, { schema: 1, source: sourceEditor.value, name: exportsInput.value || preferredName,
+ arguments: argsInput.value, output: resultCodec.value, mode: mode.value === 'io' ? 'io' : 'pure',
+ ...(!scratch && selected?.environment !== undefined ? { demo: { hash: selected.hash, environment: selected.environment } } : {}) });
+ el('draft-save-status').textContent = 'Draft saved on this device for recovery (latest draft only). Browser storage may be cleared; keep important work elsewhere too.';
+ recoveryButtons();
+ return true;
+ }
+ catch {
+ el('draft-save-status').textContent = 'Draft not saved: local storage is unavailable or this draft exceeds recovery limits. Copy your source somewhere safe before compiling.';
+ return false;
+ }
+}
+el('recover-draft').onclick = () => {
+ try {
+ const saved = readDraft(localStorage);
+ if (!saved)
+ return;
+ const entry = saved.demo ? entries.find(e => e.hash === saved.demo.hash && e.environment === saved.demo.environment) : undefined;
+ if (saved.demo && !entry)
+ throw new Error('The saved demo environment is no longer in this catalogue. Use Download saved source to recover your text; it has not been compiled against a substitute environment.');
+ const output = outputType(saved.output);
+ dispose();
+ clearOutput();
+ clearTreeExport();
+ draft = true;
+ scratch = !saved.demo;
+ selected = entry;
+ originalSource = undefined;
+ selection = undefined;
+ loaded = false;
+ showRelease(entry ? releases.find(r => r.hash === entry.hash) : undefined);
+ hashInput.value = entry?.hash ?? '';
+ el('package-info').textContent = entry ? `Recovered draft based on ${entry.title}. No published executable loaded.` : 'Recovered scratch program. No published package selected.';
+ sourceEditor.value = saved.source;
+ sourceEditor.hidden = false;
+ el('source-notes').hidden = true;
+ preferredName = saved.name;
+ exportsInput.replaceChildren();
+ argsInput.value = saved.arguments;
+ resultCodec.value = output;
+ mode.value = saved.mode;
+ // A recovered draft does not inherit a previously selected risky profile.
+ const profile = el('compiler-uncapped');
+ profile.checked = false;
+ profile.dispatchEvent(new Event('change'));
+ el('source-label').textContent = entry ? `Recovered draft · ${entry.title}` : 'Recovered scratch program · contract helpers';
+ controls(false);
+ el('status').textContent = 'Recovered draft: not compiled';
+ }
+ catch (error) {
+ el('draft-save-status').textContent = error instanceof Error ? error.message : String(error);
+ }
+};
+el('download-draft-source').onclick = () => {
+ try {
+ const saved = readDraft(localStorage);
+ if (!saved)
+ return;
+ const url = URL.createObjectURL(new Blob([saved.source], { type: 'text/plain;charset=utf-8' }));
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = 'local-draft.tri';
+ anchor.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+ catch {
+ el('draft-save-status').textContent = 'Saved source is unavailable.';
+ }
+};
+el('forget-draft').onclick = () => {
+ try {
+ localStorage.removeItem(DRAFT_KEY);
+ el('draft-save-status').textContent = 'Saved recovery copy removed. Editor contents are unchanged.';
+ recoveryButtons();
+ }
+ catch {
+ el('draft-save-status').textContent = 'Unable to remove saved draft from local storage.';
+ }
+};
+recoveryButtons();
+sourceEditor.oninput = () => {
+ preferredName = exportsInput.value || preferredName;
+ dispose();
+ clearOutput();
+ clearTreeExport();
+ exportsInput.replaceChildren();
+ draft = scratch || sourceEditor.value !== originalSource;
+ controls(false);
+ if (draft)
+ saveDraft();
+ if (!draft && selection) {
+ preferredExport = preferredName;
+ newWorker(false, true);
+ }
+ else
+ el('status').textContent = 'Ready to compile';
+};
+restoreButton.onclick = () => {
+ if (originalSource === undefined)
+ return;
+ sourceEditor.value = originalSource;
+ sourceEditor.dispatchEvent(new Event('input'));
+};
+compileButton.onclick = () => compileDraft(false);
+function compileDraft(runAfterCompile) {
+ if (!draft)
+ return;
+ if (!compilerAcknowledged) {
+ requestedRun = runAfterCompile;
+ el('compile-profile-note').textContent = readSettings('compiler').uncapped ? 'Selected: Use browser limits: execution guards off.' : 'Selected: Standard: application guards on.';
+ consent.showModal();
+ return;
+ }
+ preferredName = exportsInput.value || preferredName;
dispose();
- el('status').textContent = loaded ? 'Ready' : 'Load package';
-} });
+ clearOutput();
+ clearTreeExport();
+ exportsInput.replaceChildren();
+ try {
+ sourceText(sourceEditor.value);
+ if (!saveDraft() && !window.confirm('Your draft could not be saved locally. Copy it somewhere safe first. Compile anyway, accepting that a tab crash may lose it?'))
+ return;
+ settings = readSettings('compiler');
+ const current = new Worker(new URL('./compiler-worker.js', import.meta.url), { type: 'module' });
+ worker = current;
+ controls(true);
+ deadline.reset(activeMilliseconds(settings));
+ clock();
+ el('status').textContent = 'Compiling';
+ el('compiler-stats').textContent = 'Loading pinned compiler and demo environment…';
+ const started = performance.now();
+ const activity = () => { el('compile-activity').hidden = false; el('compile-activity').textContent = `Compiling trees locally · ${Math.floor((performance.now() - started) / 1000)} seconds elapsed · Stop is available`; };
+ activity();
+ compilerTimer = setInterval(activity, 1000);
+ current.onmessage = ({ data }) => {
+ if (worker !== current)
+ return;
+ switch (data.type) {
+ case 'compiled':
+ stopCompilerActivity();
+ pauseClock();
+ compiled = true;
+ for (const name of data.names) {
+ const option = document.createElement('option');
+ option.value = name;
+ option.textContent = name;
+ exportsInput.append(option);
+ }
+ if (data.names.includes(preferredName))
+ exportsInput.value = preferredName;
+ controls(false);
+ el('status').textContent = 'Compiled';
+ compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${settings.uncapped ? 'browser limits; execution guards off' : settings.limits.fuel === 0 ? 'no work limit; memory guard active' : 'bounded profile'} · ${data.stats.work.toLocaleString()} work units`;
+ el('compiler-stats').textContent = compilationStats;
+ if (runAfterCompile)
+ runDraft();
+ break;
+ case 'state':
+ if (data.state === 'running') {
+ clock();
+ pending = undefined;
+ el('status').textContent = compiled ? 'Running' : 'Compiling';
+ }
+ else {
+ pauseClock();
+ pending = { id: data.id, kind: data.state };
+ inputForm.hidden = data.state !== 'input';
+ nextButton.disabled = data.state !== 'step';
+ el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action';
+ }
+ break;
+ case 'trace': {
+ const row = document.createElement('li');
+ row.textContent = data.phase === 'before' ? `${data.event.step} ${data.event.name}: ${data.event.detail}` : `→ ${data.event.value}`;
+ el('trace').append(row);
+ el('trace-empty').hidden = true;
+ break;
+ }
+ case 'output':
+ el('terminal').textContent += data.text;
+ break;
+ case 'done':
+ pauseClock();
+ pending = undefined;
+ inputForm.hidden = true;
+ nextButton.disabled = true;
+ controls(false);
+ el('status').textContent = 'Finished';
+ if (data.result.type === 'tree' || data.result.type === 'list') {
+ el('graph-result').hidden = false;
+ el('graph-result').textContent = JSON.stringify(data.result.value, null, 2);
+ el('result').textContent = `Returned ${data.result.type}: tree graph`;
+ }
+ else
+ el('result').textContent = `Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`;
+ el('compiler-stats').textContent = `${compilationStats} · ${data.stats.work.toLocaleString()} cumulative work · ${Number.isFinite(data.stats.fuel) ? `${data.stats.fuel.toLocaleString()} run work remaining` : 'unlimited run work'}`;
+ break;
+ case 'error':
+ finish(data.message, true);
+ break;
+ }
+ };
+ current.onerror = event => { if (worker === current) {
+ event.preventDefault();
+ finish(event.message || 'Compiler Worker failed.', true);
+ } };
+ current.postMessage({ type: 'compile', source: sourceEditor.value, ...settings,
+ ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) });
+ }
+ catch (error) {
+ finish(error instanceof Error ? error.message : String(error), true);
+ }
+}
+function runDraft() {
+ if (!compiled || !worker)
+ return;
+ try {
+ if (argsInput.value.length > 16_000)
+ throw new Error('Argument JSON limit exceeded');
+ const args = literals(JSON.parse(argsInput.value)), output = outputType(resultCodec.value);
+ clearOutput();
+ controls(true);
+ runClock();
+ el('status').textContent = 'Running';
+ worker.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked });
+ }
+ catch (error) {
+ el('result').textContent = error instanceof Error ? error.message : String(error);
+ el('result').className = 'error';
+ }
+}
+const examples = {
+ identity: { name: 'id', source: 'id = (x : x)', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' },
+ contracts: { name: 'id', source: 'id x =@nat? x', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' },
+ pair: { name: 'pair', source: 'pair a b = t a b', args: [{ type: 'number', value: '7' }, { type: 'string', value: 'hi' }], output: 'tree', mode: 'pure' },
+ definitions: { name: 'answer', source: 'id = (x : x)\nanswer = id 42', args: [], output: 'number', mode: 'pure' },
+ input: { name: 'io', source: 'io=t "tricuIO" (t 1 (t 11 t))', args: [], output: 'string', mode: 'io' },
+};
+for (const button of document.querySelectorAll('[data-compiler-example]'))
+ button.onclick = () => {
+ const example = examples[button.dataset['compilerExample']];
+ if (!example)
+ return;
+ dispose();
+ clearOutput();
+ clearTreeExport();
+ scratch = true;
+ draft = true;
+ originalSource = undefined;
+ exportsInput.replaceChildren();
+ preferredName = example.name;
+ sourceEditor.hidden = false;
+ el('source-notes').hidden = true;
+ sourceEditor.value = example.source;
+ argsInput.value = JSON.stringify(example.args, null, 2);
+ resultCodec.value = example.output;
+ mode.value = example.mode;
+ saveDraft();
+ controls(false);
+ el('source-label').textContent = 'Scratch program · contract helpers';
+ el('status').textContent = 'Ready to compile';
+ };
function loadFragment() {
const fragment = new URLSearchParams(location.hash.slice(1));
const requested = fragment.get('bundle');
@@ -358,6 +784,10 @@ function loadFragment() {
if (local)
select(local);
else {
+ showRelease();
+ draft = false;
+ scratch = false;
+ originalSource = undefined;
dispose();
clearOutput();
clearTreeExport();
@@ -376,7 +806,8 @@ window.addEventListener('hashchange', () => {
if (!entries.length || !packagePane())
return;
try {
- loadFragment();
+ if (new URLSearchParams(location.hash.slice(1)).has('bundle'))
+ loadFragment();
}
catch (error) {
loaded = false;
@@ -408,8 +839,14 @@ async function initializePackages() {
outputType(v['output']);
if (v['mode'] !== 'pure' && v['mode'] !== 'io')
throw new Error('Invalid catalogue mode');
+ if (v['environment'] !== undefined && (typeof v['environment'] !== 'string' || v['environment'].length > 128))
+ throw new Error('Invalid demo environment');
return { ...v, hash };
});
+ const indexBytes = await fetchBytes(new URL('../packages.json', import.meta.url), 1_048_576);
+ releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes)));
+ populatePackages();
+ recoveryButtons();
for (const entry of entries) {
const button = document.createElement('button');
button.type = 'button';
@@ -419,7 +856,7 @@ async function initializePackages() {
button.onclick = () => select(entry);
el('demo-picker').append(button);
}
- if (!packagePane())
+ if (!packagePane() || selection || draft)
return;
if (!loadFragment()) {
const initial = entries.find(e => e.id === 'intro') ?? entries[0];
@@ -438,9 +875,9 @@ window.addEventListener('pane-change', () => {
// Moving between panels cancels active work, but retains verified selection and
// completed exports. Move the single package chooser rather than mirror inputs.
if (worker) {
- if (runButton.disabled)
+ if (busy)
finish('Stopped. Start a new operation in the selected tab.', true);
- else {
+ else if (!draft) {
dispose();
el('status').textContent = loaded ? 'Ready' : 'Load package';
}
@@ -449,19 +886,28 @@ window.addEventListener('pane-change', () => {
document.querySelector(`#${activePane()} [data-package-slot]`).append(el('package-selection'));
if (!initialized)
void initializePackages();
- else if (entries.length && !selection) {
+ else if (!draft && entries.length && !selection) {
if (!loadFragment()) {
const initial = entries.find(e => e.id === 'intro') ?? entries[0];
if (initial)
select(initial);
}
}
- else if (entries.length && !loaded && selection)
+ else if (!draft && entries.length && !loaded && selection)
newWorker(false);
}
});
window.addEventListener('limits-change', event => {
- if (event.detail !== 'package')
+ const runner = event.detail;
+ if (runner === 'compiler') {
+ if (draft) {
+ dispose();
+ clearOutput();
+ el('status').textContent = 'Ready to compile';
+ }
+ return;
+ }
+ if (runner !== 'package' || draft)
return;
preferredExport = exportsInput.value || preferredExport;
dispose();
diff --git a/ext/demo-site/dist/compiler-app.js b/ext/demo-site/dist/compiler-app.js
@@ -1,242 +0,0 @@
-import { readSettings } from './limits-ui.js';
-import { activeMilliseconds } from './execution.js';
-import { ActiveClock } from './clock.js';
-import { activePane } from './tabs.js';
-import { Graph } from './graph.js';
-import { literals, outputType, line } from './codecs.js';
-import { sourceText } from './compiler.js';
-function el(id) {
- const node = document.getElementById(`compiler-${id}`);
- if (!node)
- throw new Error(`Missing compiler ${id}`);
- return node;
-}
-const source = el('source');
-let settings = readSettings('compiler');
-const deadline = new ActiveClock(() => finish('Stopped: active execution time limit exceeded.', true));
-const compileButton = el('compile'), runButton = el('run'), stopButton = el('stop');
-const names = el('export'), args = el('arguments'), output = el('output');
-const mode = el('mode'), step = el('step'), next = el('next');
-const input = el('input-form'), lineInput = el('line');
-let worker, ready = false, busy = false, preferredName = 'id';
-let compilationStats = '';
-let pending;
-function controls() {
- compileButton.disabled = busy;
- runButton.disabled = busy || !ready;
- stopButton.disabled = !worker;
- source.disabled = busy;
- names.disabled = busy || !ready;
- args.disabled = busy;
- output.disabled = busy;
- mode.disabled = busy;
- step.disabled = busy;
- for (const button of document.querySelectorAll('[data-compiler-example]'))
- button.disabled = busy;
-}
-function dispose() {
- deadline.pause();
- worker?.terminate();
- worker = undefined;
- ready = false;
- busy = false;
- pending = undefined;
- input.hidden = true;
- next.disabled = true;
- controls();
-}
-function clear() {
- el('terminal').textContent = '';
- el('result').textContent = '';
- el('result').className = '';
- el('graph').textContent = '';
- el('graph').hidden = true;
- el('trace').textContent = '';
- lineInput.value = '';
-}
-function finish(message, error = false, retain = false) {
- if (retain) {
- deadline.pause();
- busy = false;
- pending = undefined;
- input.hidden = true;
- next.disabled = true;
- controls();
- }
- else
- dispose();
- el('status').textContent = error ? 'Stopped' : 'Finished';
- el('result').textContent = message;
- el('result').className = error ? 'error' : '';
-}
-function invalidate() {
- dispose();
- names.replaceChildren();
- el('stats').textContent = 'Choose or edit source, then compile.';
- el('status').textContent = 'Ready to compile';
-}
-function send(message) { worker?.postMessage(message); }
-compileButton.onclick = () => {
- dispose();
- clear();
- names.replaceChildren();
- try {
- settings = readSettings('compiler');
- sourceText(source.value); // Validate only; encode exclusively in the Worker.
- window.dispatchEvent(new Event('compiler-start'));
- const current = new Worker(new URL('./compiler-worker.js', import.meta.url), { type: 'module' });
- worker = current;
- busy = true;
- controls();
- deadline.reset(activeMilliseconds(settings));
- deadline.start();
- el('status').textContent = 'Compiling';
- el('stats').textContent = 'Loading the tricu compiler → compiling definitions…';
- current.onmessage = ({ data }) => {
- if (worker !== current)
- return;
- switch (data.type) {
- case 'state':
- if (data.state === 'running') {
- deadline.start();
- pending = undefined;
- el('status').textContent = ready ? 'Running' : 'Compiling';
- }
- else {
- deadline.pause();
- pending = { id: data.id, kind: data.state };
- input.hidden = data.state !== 'input';
- next.disabled = data.state !== 'step';
- el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action';
- if (data.state === 'input')
- lineInput.focus();
- }
- break;
- case 'compiled':
- deadline.pause();
- ready = true;
- busy = false;
- for (const name of data.names) {
- const option = document.createElement('option');
- option.value = name;
- option.textContent = name;
- names.append(option);
- }
- if (data.names.includes(preferredName))
- names.value = preferredName;
- controls();
- el('status').textContent = 'Compiled';
- el('stats').textContent = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms including load · ${data.stats.nodes.toLocaleString()} live nodes · ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${data.stats.collections} GC(s) · ${settings.uncapped ? 'execution limits disabled' : 'bounded profile'} · ${data.stats.work.toLocaleString()} work units`;
- compilationStats = el('stats').textContent ?? '';
- el('result').textContent = 'Your definitions are ready. Select one, choose its arguments and run it. Use [] when its inputs are already applied.';
- break;
- case 'trace':
- el('trace').textContent += data.phase === 'before' ? `${data.event.step} ${data.event.name}: ${data.event.detail}\n` : ` → ${data.event.value}\n`;
- break;
- case 'output':
- el('terminal').textContent += data.text;
- break;
- case 'done':
- if (data.result.type === 'tree' || data.result.type === 'list') {
- el('graph').hidden = false;
- el('graph').textContent = JSON.stringify(data.result.value, null, 2);
- finish(`Returned ${data.result.type}: tree graph`, false, true);
- }
- else
- finish(`Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`, false, true);
- el('stats').textContent = `${compilationStats} · current ${data.stats.nodes.toLocaleString()} live nodes / ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.work.toLocaleString()} cumulative work · ${Number.isFinite(data.stats.fuel) ? `${data.stats.fuel.toLocaleString()} run work remaining` : 'unlimited run work'} · ready to run again`;
- break;
- case 'error':
- finish(data.message, true);
- break;
- }
- };
- current.onerror = event => { if (worker === current) {
- event.preventDefault();
- finish(event.message || 'Compiler Worker failed.', true);
- } };
- send({ type: 'compile', source: source.value, ...settings });
- }
- catch (error) {
- finish(error instanceof Error ? error.message : String(error), true);
- }
-};
-runButton.onclick = () => {
- if (!ready || !worker)
- return;
- try {
- if (args.value.length > 16_000)
- throw new Error('Argument JSON limit exceeded');
- const arguments_ = literals(JSON.parse(args.value)), codec = outputType(output.value);
- clear();
- busy = true;
- controls();
- deadline.reset(activeMilliseconds(settings));
- deadline.start();
- el('status').textContent = 'Running';
- send({ type: 'run', export: names.value, arguments: arguments_, output: codec, mode: mode.value === 'io' ? 'io' : 'pure', step: step.checked });
- }
- catch (error) {
- // Invalid literal input does not discard a successfully compiled heap.
- el('result').textContent = error instanceof Error ? error.message : String(error);
- el('result').className = 'error';
- }
-};
-function resume(value) {
- if (!pending || !worker)
- return;
- send(value === undefined ? { type: 'resume', id: pending.id } : { type: 'resume', id: pending.id, value });
- deadline.start();
- pending = undefined;
- input.hidden = true;
- next.disabled = true;
- el('status').textContent = 'Running';
-}
-input.onsubmit = event => {
- event.preventDefault();
- if (pending?.kind !== 'input')
- return;
- try {
- line(new Graph({ nodes: 16_000, fuel: 100_000 }), lineInput.value);
- resume(lineInput.value);
- }
- catch (error) {
- el('result').textContent = error instanceof Error ? error.message : String(error);
- el('result').className = 'error';
- }
-};
-next.onclick = () => { if (pending?.kind === 'step')
- resume(); };
-stopButton.onclick = () => finish('Stopped. Compiler session discarded.', true);
-el('reset').onclick = () => { invalidate(); clear(); };
-source.oninput = () => { invalidate(); clear(); };
-window.addEventListener('limits-change', event => { if (event.detail === 'compiler') {
- invalidate();
- clear();
-} });
-const examples = {
- identity: { name: 'id', source: 'id = (x : x)', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' },
- contracts: { name: 'id', source: 'id x =@nat? x', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' },
- pair: { name: 'pair', source: 'pair a b = t a b', args: [{ type: 'number', value: '7' }, { type: 'string', value: 'hi' }], output: 'tree', mode: 'pure' },
- definitions: { name: 'answer', source: 'id = (x : x)\nanswer = id 42', args: [], output: 'number', mode: 'pure' },
- input: { name: 'io', source: 'io=t "tricuIO" (t 1 (t 11 t))', args: [], output: 'string', mode: 'io' },
-};
-for (const button of document.querySelectorAll('[data-compiler-example]'))
- button.onclick = () => {
- const key = button.dataset['compilerExample'], example = examples[key];
- if (!example)
- return;
- invalidate();
- clear();
- preferredName = example.name;
- source.value = example.source;
- args.value = JSON.stringify(example.args, null, 2);
- output.value = example.output;
- mode.value = example.mode;
- };
-window.addEventListener('package-start', () => { if (worker)
- finish('Switched to the package playground. Compile again to start a new compiler session.'); });
-window.addEventListener('pane-change', () => { if (activePane() !== 'compiler' && busy)
- finish('Stopped. Open Compiler and compile again to start a new session.', true); });
-window.addEventListener('pagehide', dispose);
-controls();
diff --git a/ext/demo-site/dist/compiler-worker.js b/ext/demo-site/dist/compiler-worker.js
@@ -1,8 +1,9 @@
+import { environmentProfile } from './packages.js';
import { executionSettings, executionBudget, collectionPolicy } from './execution.js';
import { contractEnvironment } from './contracts.js';
import { Graph } from './graph.js';
import { COMPILER_BUNDLE_LIMIT, bundleHash, fetchBundle, fetchBytes, loadBundle } from './bundle.js';
-import { literals, makeLiteral, outputType, view } from './codecs.js';
+import { literals, makeLiteral, outputType, view, list, text } from './codecs.js';
import { runIO } from './io.js';
import { COMPILER_EXPORT, sourceLiteral, compiledDefinitions } from './compiler.js';
const scope = globalThis;
@@ -19,7 +20,7 @@ function request(kind) {
const id = ++requestId;
return new Promise((resolve, reject) => { pending = { id, kind, resolve, reject }; send({ type: 'state', state: kind, id }); });
}
-async function compile(source, settings) {
+async function compile(source, settings, profileName) {
phase = 'compiling';
running();
const start = performance.now();
@@ -37,7 +38,27 @@ async function compile(source, settings) {
const library = loadBundle(graph, bundle), compiler = library.get(COMPILER_EXPORT);
if (compiler === undefined)
throw new Error('Missing stable compiler entrypoint');
- const environment = contractEnvironment(graph, library);
+ let environment;
+ if (profileName === undefined)
+ environment = contractEnvironment(graph, library);
+ else {
+ const bytes = await fetchBytes(new URL('environments.json', base), 32_768);
+ const selected = environmentProfile(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)), profileName);
+ if (selected.compiler !== hash)
+ throw new Error('Demo environment/compiler pin mismatch');
+ const bindings = [];
+ if (selected.profile) {
+ const support = await fetchBundle(new URL(`/_arboricx/bundle/hash/${selected.profile.hash}`, base), selected.profile.hash);
+ const values = loadBundle(graph, support);
+ for (const name of selected.profile.names) {
+ const tree = values.get(name);
+ if (tree === undefined)
+ throw new Error(`Missing demo environment binding ${name}`);
+ bindings.push(graph.fork(text(graph, name), tree));
+ }
+ }
+ environment = list(graph, bindings);
+ }
exports = compiledDefinitions(graph, graph.apply(compiler, [environment, input]));
graph.collect(exports.values()); // Safe phase boundary; compiler/input temporaries are no longer caller roots.
phase = 'ready';
@@ -86,7 +107,9 @@ scope.onmessage = ({ data }) => {
let operation;
try {
if (message['type'] === 'compile' && phase === 'empty') {
- operation = compile(message['source'], executionSettings(message['limits'], 'compiler', message['uncapped'] === undefined ? false : message['uncapped']));
+ if (message['environment'] !== undefined && (typeof message['environment'] !== 'string' || message['environment'].length > 128))
+ throw new Error('Invalid demo environment');
+ operation = compile(message['source'], executionSettings(message['limits'], 'compiler', message['uncapped'] === undefined ? false : message['uncapped']), message['environment']);
}
else if (message['type'] === 'run' && phase === 'ready') {
if (typeof message['export'] !== 'string' || message['export'].length > 128)
diff --git a/ext/demo-site/dist/compiler.js b/ext/demo-site/dist/compiler.js
@@ -1,8 +1,6 @@
import { text, readText, readList, readNatural } from './codecs.js';
export const COMPILER_EXPORT = 'source_unit.compileStableModuleDefinitions';
-export const COMPILER_BUDGET = Object.freeze({ nodes: 6_000_000, fuel: 80_000_000, stack: 100_000 });
-// Explicitly selected host profile; keeps GC but removes evaluation ceilings.
-export const UNCAPPED_COMPILER_BUDGET = Object.freeze({ nodes: Infinity, fuel: Infinity, stack: Infinity });
+export const COMPILER_BUDGET = Object.freeze({ nodes: 6_000_000, fuel: Infinity, stack: 100_000 });
export const COMPILER_COLLECTION = Object.freeze({ threshold: 250_000 });
export const SOURCE_LIMIT = 16_384;
export function sourceText(source) {
diff --git a/ext/demo-site/dist/draft-storage.js b/ext/demo-site/dist/draft-storage.js
@@ -0,0 +1,38 @@
+// One local recovery slot, never executable state or an automatic run request.
+// Browser limits are intentionally NOT persisted: recovery starts in Standard.
+export const DRAFT_KEY = 'arboricx.local-draft.v1';
+export function savedDraft(raw) {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
+ throw new Error('Invalid saved draft');
+ const v = raw;
+ for (const [key, max] of [['source', 32768], ['name', 256], ['arguments', 16000], ['output', 32]])
+ if (typeof v[key] !== 'string' || v[key].length > max)
+ throw new Error('Invalid saved draft ' + key);
+ if (v['schema'] !== 1 || (v['mode'] !== 'pure' && v['mode'] !== 'io'))
+ throw new Error('Invalid saved draft version/mode');
+ let demo;
+ if (v['demo'] !== undefined) {
+ const d = v['demo'];
+ if (!d || typeof d !== 'object' || typeof d['hash'] !== 'string' || !/^[0-9a-f]{64}$/u.test(d['hash']) || typeof d['environment'] !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(d['environment']))
+ throw new Error('Invalid saved demo context');
+ demo = { hash: d['hash'], environment: d['environment'] };
+ }
+ return { schema: 1, source: v['source'], name: v['name'], arguments: v['arguments'],
+ output: v['output'], mode: v['mode'], ...(demo ? { demo } : {}) };
+}
+export function readDraft(storage) {
+ const text = storage.getItem(DRAFT_KEY);
+ if (text === null)
+ return undefined;
+ if (text.length > 100000)
+ throw new Error('Saved draft too large');
+ return savedDraft(JSON.parse(text));
+}
+export function writeDraft(storage, draft) {
+ const text = JSON.stringify(savedDraft(draft));
+ if (text.length > 100000)
+ throw new Error('Saved draft too large');
+ storage.setItem(DRAFT_KEY, text);
+ if (storage.getItem(DRAFT_KEY) !== text)
+ throw new Error('Draft storage verification failed');
+}
diff --git a/ext/demo-site/dist/execution.js b/ext/demo-site/dist/execution.js
@@ -1,12 +1,11 @@
import { DEFAULT_BUDGET } from './graph.js';
import { COMPILER_BUDGET } from './compiler.js';
export const PACKAGE_ACTIVE_MS = 10_000;
-export const UNLIMITED_EXECUTION = Object.freeze({ nodes: Infinity, fuel: Infinity, stack: Infinity });
export const EXECUTION_COLLECTION = Object.freeze({ threshold: 250_000 });
export const PACKAGE_COLLECTION = Object.freeze({ threshold: 100_000 });
export const PACKAGE_BUDGET = Object.freeze({ ...DEFAULT_BUDGET, fuel: 20_000_000 });
export function defaultLimits(runner) {
- return { ...(runner === 'package' ? PACKAGE_BUDGET : COMPILER_BUDGET), seconds: runner === 'package' ? 10 : 0 };
+ return { ...(runner === 'package' ? PACKAGE_BUDGET : COMPILER_BUDGET), fuel: runner === 'package' ? PACKAGE_BUDGET.fuel : 0, seconds: runner === 'package' ? 10 : 0 };
}
export function executionSettings(raw, runner, uncapped = false) {
if (typeof uncapped !== 'boolean')
@@ -17,16 +16,16 @@ export function executionSettings(raw, runner, uncapped = false) {
const input = values;
for (const key of ['nodes', 'fuel', 'stack', 'seconds']) {
const value = input[key];
- if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < (key === 'seconds' ? 0 : 1)
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < (key === 'seconds' || key === 'fuel' ? 0 : 1)
|| (key === 'seconds' && !Number.isSafeInteger(value * 1000)))
- throw new Error(`${key}: enter a ${key === 'seconds' ? 'nonnegative' : 'positive'} whole number`);
+ throw new Error(`${key}: enter a ${key === 'seconds' || key === 'fuel' ? 'nonnegative' : 'positive'} whole number`);
}
return { limits: { nodes: input['nodes'], fuel: input['fuel'],
stack: input['stack'], seconds: input['seconds'] }, uncapped };
}
export function executionBudget(settings) {
const { nodes, fuel, stack } = settings.limits;
- return settings.uncapped ? UNLIMITED_EXECUTION : { nodes, fuel, stack };
+ return settings.uncapped ? { nodes: Infinity, fuel: Infinity, stack: Infinity } : { nodes, fuel: fuel === 0 ? Infinity : fuel, stack };
}
export function activeMilliseconds(settings) {
return settings.uncapped || settings.limits.seconds === 0 ? Infinity : settings.limits.seconds * 1000;
diff --git a/ext/demo-site/dist/limits-ui.js b/ext/demo-site/dist/limits-ui.js
@@ -14,13 +14,13 @@ for (const runner of ['package', 'compiler']) {
const form = document.createElement('form');
form.noValidate = true;
form.className = 'limits-editor';
- for (const [key, label] of [['nodes', 'Live nodes'], ['fuel', 'Work units'], ['stack', 'Evaluator frames'], ['seconds', 'Active seconds (0 = unlimited)']]) {
+ for (const [key, label] of [['nodes', 'Live nodes'], ['fuel', 'Work units (0 = no work limit)'], ['stack', 'Evaluator frames'], ['seconds', 'Active seconds (0 = unlimited)']]) {
const wrapper = document.createElement('div'), caption = document.createElement('label'), input = document.createElement('input');
const hint = document.createElement('span');
hint.className = 'hint';
input.id = `${runner}-limit-${key}`;
input.type = 'number';
- input.min = key === 'seconds' ? '0' : '1';
+ input.min = key === 'seconds' || key === 'fuel' ? '0' : '1';
input.step = '1';
input.inputMode = 'numeric';
input.required = true;
@@ -49,10 +49,11 @@ for (const runner of ['package', 'compiler']) {
function update() {
const settings = readSettings(runner);
const customized = Object.entries(defaults).some(([key, value]) => settings.limits[key] !== value);
- summary.textContent = `Execution limits: ${settings.uncapped ? 'disabled' : customized ? 'custom' : 'defaults'}`;
+ summary.textContent = `Advanced: ${settings.uncapped ? 'browser limits; execution guards off' : customized ? 'custom resource guards' : 'resource guards and diagnostics'}`;
for (const input of fields.values())
input.disabled = settings.uncapped;
apply.disabled = settings.uncapped;
+ document.getElementById(`${runner}-resource-mode`).textContent = settings.uncapped ? 'Use browser limits' : 'Standard';
}
function publish(settings) {
committed.set(runner, settings);
@@ -62,7 +63,7 @@ for (const runner of ['package', 'compiler']) {
update();
window.dispatchEvent(new CustomEvent('limits-change', { detail: runner }));
}
- form.oninput = () => { error.textContent = ''; summary.textContent = 'Execution limits: pending changes'; };
+ form.oninput = () => { error.textContent = ''; summary.textContent = 'Advanced: unapplied resource settings'; };
form.onsubmit = event => {
event.preventDefault();
try {
diff --git a/ext/demo-site/dist/packages.js b/ext/demo-site/dist/packages.js
@@ -0,0 +1,37 @@
+import { bundleHash } from './bundle.js';
+const component = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
+function record(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value))
+ throw new Error('Invalid metadata object');
+ return value;
+}
+export function packageIndex(value) {
+ const input = record(value);
+ if (input['schema'] !== 1 || !Array.isArray(input['releases']) || input['releases'].length > 4096)
+ throw new Error('Invalid package index');
+ const seen = new Set();
+ return input['releases'].map((raw) => {
+ const r = record(raw);
+ if (typeof r['name'] !== 'string' || !component.test(r['name']) || typeof r['version'] !== 'string' || !component.test(r['version']) ||
+ typeof r['title'] !== 'string' || r['title'].length > 256 || (r['demo'] !== undefined && (typeof r['demo'] !== 'string' || !component.test(r['demo']))))
+ throw new Error('Invalid release');
+ const key = `${r['name']}@${r['version']}`;
+ if (seen.has(key))
+ throw new Error('Duplicate package version');
+ seen.add(key);
+ return { name: r['name'], version: r['version'], title: r['title'], hash: bundleHash(r['hash']), manifest: bundleHash(r['manifest']),
+ ...(r['demo'] === undefined ? {} : { demo: r['demo'] }) };
+ });
+}
+export function environmentProfile(value, name) {
+ const input = record(value), profiles = record(input['profiles']);
+ if (input['schema'] !== 1 || !Object.hasOwn(profiles, name))
+ throw new Error('Unknown demo environment');
+ const compiler = bundleHash(input['compiler']), raw = profiles[name];
+ if (raw === null)
+ return { compiler, profile: null };
+ const profile = record(raw), names = profile['names'];
+ if (!Array.isArray(names) || names.length > 128 || names.some(n => typeof n !== 'string' || !/^[A-Za-z_][A-Za-z0-9_?$%'.-]*$/u.test(n) || n.length > 128) || new Set(names).size !== names.length)
+ throw new Error('Invalid environment bindings');
+ return { compiler, profile: { hash: bundleHash(profile['hash']), names: names } };
+}
diff --git a/ext/demo-site/dist/worker.js b/ext/demo-site/dist/worker.js
@@ -1,7 +1,7 @@
import { exportTree, treeFormat } from './tree-export.js';
import { executionSettings, executionBudget, collectionPolicy, PACKAGE_COLLECTION } from './execution.js';
import { Graph } from './graph.js';
-import { bundleHash, fetchBundle, fetchBytes, loadBundle, sha256 } from './bundle.js';
+import { bundleHash, decodeBundle, fetchBytes, loadBundle, sha256 } from './bundle.js';
import { literals, makeLiteral, outputType, view } from './codecs.js';
import { runIO } from './io.js';
const scope = globalThis;
@@ -41,8 +41,11 @@ async function load(hash, localId) {
throw new Error('Catalogue source integrity failure');
source = entry['source'];
}
- const bundle = await fetchBundle(url, hash);
- stored = { hash, bundle };
+ const bytes = await fetchBytes(url);
+ if (await sha256(bytes) !== hash)
+ throw new Error('Bundle SHA-256 mismatch');
+ const bundle = decodeBundle(bytes);
+ stored = { hash, bundle, bytes };
const budget = executionBudget(settings);
graph = new Graph(budget, collectionPolicy(budget, PACKAGE_COLLECTION.threshold));
exports = loadBundle(graph, bundle);
@@ -88,6 +91,10 @@ scope.onmessage = ({ data }) => {
waiter.resolve(typeof message['value'] === 'string' ? message['value'] : '');
return;
}
+ if (message['type'] === 'download' && phase === 'ready' && stored) {
+ send({ type: 'downloaded', hash: stored.hash, bytes: stored.bytes });
+ return;
+ }
if (message['type'] === 'export' && phase === 'ready' && stored) {
try {
if (typeof message['export'] !== 'string' || message['export'].length > 512)
diff --git a/ext/demo-site/environments.json b/ext/demo-site/environments.json
@@ -0,0 +1,32 @@
+{
+ "schema": 1,
+ "compiler": "64c193d2d00c66963de67c5ff2a3af405eae3878c70b2268e44468e821198b6d",
+ "profiles": {
+ "empty": null,
+ "practical": {
+ "hash": "321ddc5508ec8b9c2bdcdd0c9bcf6db1eaa91160ce2827cc2bd9b6efb9e231d7",
+ "names": [
+ "isNat?",
+ "lte_?",
+ "add",
+ "and?",
+ "not?",
+ "equal?",
+ "or?",
+ "contains?",
+ "true",
+ "false",
+ "natural?",
+ "lessEq",
+ "addBinary",
+ "scalar?",
+ "text?",
+ "stringField",
+ "numberField",
+ "boolField",
+ "failure"
+ ],
+ "sourceSha256": "267dd96923b17f67cdce383cf464a11c4d379c8a893f05e8ea273d515283f0a2"
+ }
+ }
+}
diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html
@@ -24,20 +24,41 @@
<div class="workspace-tabs" role="tablist" aria-label="Explore">
<button id="tab-how" type="button" role="tab" aria-controls="how" aria-selected="true" data-pane="how">Ecosystem</button>
<button id="tab-playground" type="button" role="tab" aria-controls="playground" aria-selected="false" tabindex="-1" data-pane="playground">Playground</button>
- <button id="tab-compiler" type="button" role="tab" aria-controls="compiler" aria-selected="false" tabindex="-1" data-pane="compiler">Compiler</button>
<button id="tab-export-definition" type="button" role="tab" aria-controls="export-definition" aria-selected="false" tabindex="-1" data-pane="export-definition">Export Definition</button>
</div>
<div class="workspace-panels">
<section id="playground" role="tabpanel" aria-labelledby="tab-playground" tabindex="0" aria-hidden="true" inert class="workspace-panel">
<div class="section-heading"><div><p class="eyebrow">Real packages, local execution</p><h2>The tree playground.</h2></div><span class="badge">Inspect · apply · interact</span></div>
- <p class="muted">Count a tree's nodes, compare its structure, validate a color, or have a conversation. Each example is a tricu program packaged as Arboricx. Choose an export and its arguments, then see what the tree does.</p>
- <details class="execution-limits"><summary id="package-limits-summary">Execution limits: defaults</summary><div class="limits-content"><h3>Session settings</h3><div id="package-limits-defaults"></div><label><input id="uncapped" type="checkbox"> Disable execution limits</label><p class="hint">Apply values to start a fresh session, or disable execution limits. Stop ends the session; browser memory limits apply. Garbage collection stays active with limits disabled.</p><p class="hint">Package input: 1 MiB bundle and 100,000 stored nodes; loading allows 10 active seconds. IO: up to 128 actions, 256 characters per line and 16,384 output characters. Tree size counts every occurrence, including shared branches.</p></div></details>
+ <p class="muted">Count a tree's nodes, compare its structure, validate a color, or have a conversation. Load a published package to run, download or export its definitions. Curated demos include editable source and a pinned compilation environment. Editing creates a local draft, never a published release.</p>
+ <div id="package-resource-choice" class="resource-choice">
+ <p>Execution profile: <strong id="package-resource-mode">Standard</strong></p>
+ <label><input id="uncapped" type="checkbox" aria-describedby="package-resource-warning"> Use browser limits</label>
+ <p id="package-resource-warning" class="hint">Standard stops at application resource guards. Use browser limits allows larger programs, but may crash or reload the tab. Stop and garbage collection remain available; this does not reserve RAM.</p>
+ </div>
+ <details id="package-settings" class="execution-limits"><summary id="package-limits-summary">Advanced: resource guards and diagnostics</summary><div class="limits-content"><h3>Session settings</h3><div id="package-limits-defaults"></div><p class="hint">These optional controls customize Standard. They are bypassed in Use browser limits; download, format and IO safeguards are not. Garbage collection stays active in both profiles.</p><p class="hint">Package input: 1 MiB bundle and 100,000 stored nodes; loading allows 10 active seconds. IO: up to 128 actions, 256 characters per line and 16,384 output characters. Tree size counts every occurrence, including shared branches.</p></div></details>
+ <div id="compiler-resource-choice" class="resource-choice" hidden>
+ <p>Execution profile: <strong id="compiler-resource-mode">Standard</strong></p>
+ <label><input id="compiler-uncapped" type="checkbox" aria-describedby="compiler-resource-warning"> Use browser limits</label>
+ <p id="compiler-resource-warning" class="hint">Standard stops at application resource guards. Use browser limits allows larger programs, but may crash or reload the tab. Stop and garbage collection remain available; this does not reserve RAM.</p>
+ </div>
+ <details id="compiler-settings" class="execution-limits" hidden><summary id="compiler-limits-summary">Advanced: resource guards and diagnostics</summary><div class="limits-content"><h3>Session settings</h3><div id="compiler-limits-defaults"></div><p class="hint">These optional controls customize Standard. They are bypassed in Use browser limits; download, format and IO safeguards are not. Garbage collection stays active in both profiles.</p><p class="hint">Source: 16,384 characters and up to 16 definitions. IO uses the same text limits as the playground. Compile once, then run definitions with new arguments. Compilation and each run have separate work and active-time allowances. Standard enforces live-node and frame limits; counters remain cumulative.</p></div></details>
<div data-package-slot><div id="package-selection">
+ <div class="package-controls"><label for="package-name">Package index</label><div class="input-row"><select id="package-name" aria-label="Package"></select><select id="package-version" aria-label="Exact version"></select><button id="load-release" type="button">Load release</button></div><p id="release-info" class="hint">Named releases, pinned to exact bytes.</p></div>
<div id="demo-picker" class="demo-picker" role="group" aria-label="Curated packages"></div>
<div class="package-controls"><label for="bundle-hash">Bundle SHA-256 (whole file)</label><div class="input-row"><input id="bundle-hash" maxlength="64" spellcheck="false" autocomplete="off"><button id="load" type="button">Load package</button></div><p id="package-info" class="hint">Loading curated catalogue…</p></div>
</div></div>
<div class="lab">
- <section class="source-panel" aria-labelledby="source-title"><div class="panel-heading"><h3 id="source-title">Package notes / source</h3><span id="source-label">tricu programs</span></div><pre><code id="source">Loading…</code></pre></section>
+ <section class="source-panel compiler-source" aria-labelledby="source-title"><div class="panel-heading"><h3 id="source-title">Source / package notes</h3><span id="source-label">tricu programs</span></div>
+ <p id="compiler-disclosure" class="hint" hidden>The compiler is a tricu program: trees compiling source into trees, not JavaScript compilation. This can take minutes and use substantial memory. Stop at any time; your source stays here. Standard can stop large drafts at its memory guard. Use browser limits allows larger graphs but risks a tab crash or reload.</p><p id="compile-activity" class="hint" role="status" hidden></p>
+ <div class="draft-recovery"><p id="draft-save-status" class="hint" role="status"></p><button type="button" id="recover-draft" hidden>Recover saved draft</button><button type="button" id="download-draft-source" hidden>Download saved source</button><button type="button" id="forget-draft" hidden>Forget saved draft</button></div>
+ <p id="authorship" role="status">Published package</p>
+ <label class="sr-only" for="source-editor">Editable demo source (up to 16,384 characters)</label>
+ <textarea id="source-editor" rows="16" maxlength="32768" spellcheck="false" hidden></textarea>
+ <pre id="source-notes"><code id="source">Loading…</code></pre>
+ <div class="controls"><button id="compile" type="button" disabled>Compile draft only</button><button id="restore-source" type="button" disabled>Restore published source</button></div>
+ <details class="scratch-examples"><summary>New scratch program · contract helpers included</summary><p class="hint">The compiler is just a tree too. Choose a standalone example, or edit a supplied demo using its pinned environment. No general module resolution.</p><div class="demo-picker" aria-label="Compiler source examples"><button type="button" data-compiler-example="identity">Identity</button><button type="button" data-compiler-example="contracts">Checked identity</button><button type="button" data-compiler-example="pair">Build a pair</button><button type="button" data-compiler-example="definitions">Two definitions</button><button type="button" data-compiler-example="input">Line input</button></div></details>
+ <details class="compiler-metrics"><summary>Compilation statistics</summary><p id="compiler-stats" class="hint">Compilation starts only when requested.</p></details>
+ </section>
<section class="execution-panel" aria-labelledby="execution-title">
<div class="panel-heading"><h3 id="execution-title">Apply a tree</h3><span id="status" role="status">Loading</span></div>
<div class="application-fields">
@@ -50,13 +71,13 @@
[{"type":"bytes","value":[0,128,255]}]
[{"type":"tree","value":"21100"}]
[{"type":"list","value":[
- {"type":"number","value":"256"}]}]</pre><p class="hint">Use [] for a definition that already contains its inputs. Otherwise, list arguments in application order. Ternary trees use 0 for a leaf, 1 followed by a child for a stem, and 2 followed by two children for a fork. Numbers are nonnegative decimal strings.</p></details>
+ {"type":"number","value":"256"}]}]</pre><p class="hint">Use [] for its arguments when a definition that already contains its inputs. Otherwise, list arguments in application order. Ternary trees use 0 for a leaf, 1 followed by a child for a stem, and 2 followed by two children for a fork. Numbers are nonnegative decimal strings.</p></details>
<div class="codec-row"><div><label for="output-type">Result interpretation</label><select id="output-type"><option value="tree">Tree graph</option><option value="string">String</option><option value="number">Natural number</option><option value="bool">Boolean</option><option value="bytes">Bytes</option><option value="list">List of trees</option><option value="record">Scalar record</option></select></div><div><label for="mode">Execution mode</label><select id="mode"><option value="pure">Pure value</option><option value="io">Text / line IO</option></select></div></div>
</div>
<pre id="terminal" aria-label="Program output" tabindex="0"></pre>
<form id="input-form" hidden><label for="line-input">The program is waiting for one line (up to 256 characters)</label><div class="input-row"><input id="line-input" maxlength="512" autocomplete="off"><button type="submit">Send ↵</button></div></form>
<p id="result" role="status"></p><pre id="graph-result" hidden aria-label="Bounded result graph"></pre>
- <div class="controls"><button type="button" id="run" class="primary" disabled>Run locally</button><button type="button" id="stop" disabled>Stop</button><button type="button" id="reset">Reset</button><button type="button" id="share" disabled>Copy selection link</button></div>
+ <div class="controls"><button type="button" id="run" class="primary" disabled>Run published</button><button type="button" id="stop" disabled>Stop</button><button type="button" id="reset">Reset</button><button type="button" id="share" disabled>Copy published selection link</button><button type="button" id="download-bundle" disabled>Download published .arboricx</button></div>
<div class="step-controls"><label><input type="checkbox" id="step-mode"> Pause before each IO action</label><button type="button" id="next" disabled>Next action →</button></div>
<p class="hint"><a href="#export-definition" data-open-pane="export-definition">Export this definition →</a></p>
</section>
@@ -67,7 +88,7 @@
</section>
<section id="export-definition" role="tabpanel" aria-labelledby="tab-export-definition" tabindex="0" aria-hidden="true" inert class="workspace-panel">
<div class="section-heading"><div><p class="eyebrow">Stored trees, ready to take with you</p><h2>Export a definition.</h2></div><span class="badge">Preview · copy · download</span></div>
- <p class="muted">Save a named tree from a verified Arboricx package. Nothing is run and no arguments are applied. Package and definition selection are shared with Playground.</p>
+ <p class="muted">Save a named tree from a verified Arboricx package. Nothing is run and no arguments are applied. Package and definition selection are shared with Playground. Exports and bundle downloads contain published trees, never local drafts; restore the published source first when editing.</p>
<div data-package-slot></div>
<div class="lab tree-export">
<section class="execution-panel" aria-labelledby="tree-settings-title">
@@ -78,6 +99,7 @@
<p id="tree-format-help" class="hint"></p>
</div>
<div class="controls"><button type="button" id="export-tree" class="primary" disabled>Export definition</button><button type="button" id="stop-tree" disabled>Stop</button></div>
+ <p id="export-draft-note" class="hint" hidden>A local draft is selected. Return to Playground and restore the published source to export its stored definitions.</p>
<p id="tree-provenance" class="hint" role="status">Choose a package and definition.</p>
<p class="hint">Expanded formats are limited to 2 MiB; DAG preserves sharing and allows up to 8 MiB. Each load or export allows 10 active seconds. These files contain tree structure, not an Arboricx bundle or the original program source.</p>
</section>
@@ -89,44 +111,23 @@
</section>
</div>
</section>
- <section id="compiler" role="tabpanel" aria-labelledby="tab-compiler" tabindex="0" aria-hidden="true" inert class="workspace-panel">
- <div class="section-heading"><div><p class="eyebrow">The compiler is just a tree too</p><h2>Source in.<br>Trees out.</h2></div><span class="badge">Stable lowering</span></div>
- <p class="muted">The tricu compiler is written in tricu and runs as Tree Calculus. Write definitions here, compile them into trees, then run or inspect the result.</p>
-
- <details class="execution-limits"><summary id="compiler-limits-summary">Execution limits: defaults</summary><div class="limits-content"><h3>Session settings</h3><div id="compiler-limits-defaults"></div><label><input id="compiler-uncapped" type="checkbox"> Disable execution limits</label><p class="hint">Apply values to start a fresh session, or disable execution limits. Stop ends the session; browser memory limits apply. Garbage collection stays active with limits disabled.</p><p class="hint">Source: 16,384 characters and up to 16 definitions. IO uses the same text limits as the playground. Compile once, then run definitions with new arguments. Compilation and each run have separate work and active-time allowances. Live-node and frame limits stay enforced; counters remain cumulative.</p></div></details>
- <div class="lab">
- <section class="source-panel compiler-source"><div class="panel-heading"><h3>Write a standalone program</h3><span>Contract helpers included</span></div>
- <label for="compiler-source">tricu source (up to 16,384 characters)</label><textarea id="compiler-source" rows="7" maxlength="32768" spellcheck="false">id = (x : x)</textarea>
- <div class="demo-picker" aria-label="Compiler source examples"><button type="button" data-compiler-example="identity">Identity</button><button type="button" data-compiler-example="contracts">Checked identity</button><button type="button" data-compiler-example="pair">Build a pair</button><button type="button" data-compiler-example="definitions">Two definitions</button><button type="button" data-compiler-example="input">Line input</button></div>
- <div class="controls"><button id="compiler-compile" type="button" class="primary">Compile locally</button><button id="compiler-stop" type="button" disabled>Stop / discard session</button><button id="compiler-reset" type="button">Reset</button></div>
- <details class="compiler-metrics"><summary>Compilation statistics</summary><p id="compiler-stats" class="hint">tricu compiler · stable lowering</p></details>
- </section>
- <section class="execution-panel"><div class="panel-heading"><h3>Apply the compiled tree</h3><span id="compiler-status" role="status">Ready to compile</span></div>
- <div class="application-fields"><label for="compiler-export">Compiled definition</label><select id="compiler-export" disabled></select>
- <label for="compiler-arguments">Arguments (JSON array)</label><textarea id="compiler-arguments" rows="4" maxlength="16000" spellcheck="false">[{"type":"number","value":"42"}]</textarea>
- <div class="codec-row"><div><label for="compiler-output">Result interpretation</label><select id="compiler-output"><option value="number">Natural number</option><option value="tree">Tree graph</option><option value="string">String</option><option value="bool">Boolean</option><option value="bytes">Bytes</option><option value="list">List of trees</option><option value="record">Scalar record</option></select></div><div><label for="compiler-mode">Execution mode</label><select id="compiler-mode"><option value="pure">Pure value</option><option value="io">Text / line IO</option></select></div></div></div>
- <pre id="compiler-terminal" aria-label="Compiled program output"></pre>
- <form id="compiler-input-form" hidden><label for="compiler-line">One line (up to 256 characters)</label><div class="input-row"><input id="compiler-line" maxlength="512" autocomplete="off"><button type="submit">Send ↵</button></div></form>
- <p id="compiler-result" role="status"></p><pre id="compiler-graph" hidden aria-label="Compiled result graph"></pre>
- <div class="controls"><button id="compiler-run" type="button" class="primary" disabled>Run compiled definition</button></div>
- <div class="step-controls"><label><input id="compiler-step" type="checkbox"> Pause before each IO action</label><button id="compiler-next" type="button" disabled>Next action →</button></div>
- </section>
- </div>
- <p class="hint">Use [] for its arguments when the definition already applies its inputs. Choose the result interpretation that matches the returned value.</p>
- <details><summary>Compiler / execution trace</summary><pre id="compiler-trace"></pre></details>
-
- </section>
<section id="how" role="tabpanel" aria-labelledby="tab-how" tabindex="0" aria-hidden="false" class="workspace-panel explanation is-active"><p class="eyebrow">One calculus, a growing ecosystem</p><h2>Trees all the way down.</h2><div class="explanation-grid">
<article><h3>Portable Arboricx packages.</h3><p>Arboricx bundles hold named exports and shared tree structure. Content-addressed storage gives trees and modules stable identities. Package a function, move it to another compatible host and apply it there.</p><p><a href="https://git.eversole.co/James/tricu/file/docs/modules.md.html">Explore modules and packages</a></p></article>
<article><h3>A web server made of trees.</h3><p>The ecosystem includes HTTP support and an Arboricx registry written in tricu. Tree programs handle requests, build responses and work with the content store through host IO. The same language used for the examples can serve its own packages.</p><p><a href="https://git.eversole.co/James/tricu/file/lib/arboricx/server.tri.html">Explore the registry server</a></p></article>
- <article><h3>A compiler that compiles itself.</h3><p>Lexing, parsing, name resolution and lambda elimination are tricu programs. The compiler can compile its own source into Arboricx packages. The Compiler tab runs that packaged compiler so you can follow the journey from source to tree.</p><p><a href="https://git.eversole.co/James/tricu/file/docs/compiler.md.html">Explore the self-hosting compiler</a></p></article>
+ <article><h3>A compiler that compiles itself.</h3><p>Lexing, parsing, name resolution and lambda elimination are tricu programs. The compiler can compile its own source into Arboricx packages. The Playground runs that packaged compiler so you can follow the journey from source to tree.</p><p><a href="https://git.eversole.co/James/tricu/file/docs/compiler.md.html">Explore the self-hosting compiler</a></p></article>
<article><h3>Programs that inspect programs.</h3><p>Tree Calculus combines computation with structural inspection. Leaf, Stem and Fork represent both functions and data. A program can examine another program's shape, count its nodes or compare its structure. Try the tree microscope in the Playground tab.</p><p><a href="https://treecalcul.us/">Explore Tree Calculus</a></p></article>
<article><h3>IO as an interaction tree.</h3><p>A tricu program describes actions and their continuations as trees. The host responds to each request and execution proceeds with that response. The conversation example puts text output, line input and composition into practice.</p><p><a href="https://git.eversole.co/James/tricu/file/lib/io.tri.html">Explore the IO library</a></p></article>
<article><h3>Familiar syntax.</h3><p>tricu brings names, lambdas, recursion, pattern matching and familiar literals to Tree Calculus. Write <code>id = (x : x)</code>, compose functions and organize modules. The compiler turns that source into executable trees.</p><p><a href="https://git.eversole.co/James/tricu/file/docs/language.md.html">Read the source language guide</a></p></article>
</div></section>
</div>
</section>
- <noscript><p class="wrap">Enable JavaScript to use Playground, Compiler and Export Definition.</p></noscript>
+ <dialog id="compile-consent" aria-labelledby="compile-consent-title" aria-describedby="compile-consent-copy">
+ <h2 id="compile-consent-title">Run the tree compiler?</h2>
+ <p id="compile-consent-copy">This compiler is itself written in tricu and evaluated as Tree Calculus in your browser. Compiling source into trees can take minutes and use substantial CPU and memory. It is much more expensive than running a published demo.</p>
+ <p>You can Stop at any time. Standard keeps application guards; Use browser limits removes execution ceilings and may crash or reload the tab. Neither profile guarantees total browser memory use. We save your draft locally before compilation for recovery; browser storage can still be cleared or unavailable. Your draft is not published.</p><p id="compile-profile-note"></p>
+ <div class="controls"><button id="confirm-compile" type="button" class="primary">I understand, compile locally</button><button id="cancel-compile" type="button">Not now</button></div>
+ </dialog>
+ <noscript><p class="wrap">Enable JavaScript to use Playground and Export Definition.</p></noscript>
</main>
<footer class="wrap"><p class="attribution"><a href="https://github.com/barry-jay-personal/typed_tree_calculus/blob/main/typed_program_analysis.pdf">Tree Calculus</a> was discovered by <a href="https://github.com/barry-jay-personal/blog">Barry Jay</a>. <a href="https://johannes-bader.com/">Johannes Bader</a> suggested the <a href="https://olydis.medium.com/a-visual-introduction-to-tree-calculus-2f4a34ceffc2">Triage Calculus</a> rules and created <a href="https://treecalcul.us">treecalcul.us</a>.</p><p>Arboricx and tricu are research experiments.</p><p><a href="https://git.eversole.co/James/tricu/file/ext/demo-site/README.md.html">Demo documentation</a></p></footer>
</body>
diff --git a/ext/demo-site/packages.json b/ext/demo-site/packages.json
@@ -0,0 +1,90 @@
+{
+ "schema": 1,
+ "releases": [
+ {
+ "name": "intro",
+ "version": "0.1.0",
+ "hash": "6d46e48b8aa41bde5d086804c4ee8e2ed4d355ce9c4b302f20cc709cbfd4d2e0",
+ "manifest": "91553c5027cc4a6a30d0b5f73f4a97623fc1570cb078a1dae0112fbc4086e0af",
+ "title": "Start here",
+ "demo": "intro"
+ },
+ {
+ "name": "greet",
+ "version": "0.1.0",
+ "hash": "d68a817b6028f5a27801504bb24bbed2a1519bebb0a67443711e35e57a708b4c",
+ "manifest": "c629bd933b94979f38c9a651ad49fa007c569be98f175f51da1d20fc72d99e50",
+ "title": "A conversation",
+ "demo": "greet"
+ },
+ {
+ "name": "microscope",
+ "version": "0.1.0",
+ "hash": "de3dd6da1be97037119e9bc39a89b117776a48ba34cad2aa7ceda4fcf3590994",
+ "manifest": "27d697ebeaf90515d92e5055c38b9e172ed023d9294d5351348d39842919ed99",
+ "title": "Tree microscope",
+ "demo": "microscope"
+ },
+ {
+ "name": "rgb",
+ "version": "0.1.0",
+ "hash": "0fb1586a9fd76eeef0ba966cdbb3fa6ba5165080bd75e4a33b7f28edd1e05152",
+ "manifest": "4a5f96686666391d1c12c632ed6fcbaee5838ee13774f1408072d43f3171bb1a",
+ "title": "RGB validator",
+ "demo": "rgb"
+ },
+ {
+ "name": "shipping",
+ "version": "0.1.0",
+ "hash": "c96e9797a7fb4ee67134490f8111b0831556978f0db66679116b6a791ec30ccf",
+ "manifest": "884902684591424eb44806b7e128aa0e0dbd013a96d0b844f9c9e948c15a9daf",
+ "title": "Shipping policies",
+ "demo": "shipping"
+ },
+ {
+ "name": "events",
+ "version": "0.1.0",
+ "hash": "15884be05fe8f01e32da4918830116f3239e9ffecdaf8630ba42741f16d8d5f7",
+ "manifest": "a70ab64fd12d24e0a12d51131fd4cd89f3cf3fa8cceb173e8981edbe14c07a43",
+ "title": "Event adapters",
+ "demo": "events"
+ },
+ {
+ "name": "registration",
+ "version": "0.1.0",
+ "hash": "98bbe850bd4342190abcdaaaa5686ba670bf947d97bbcc78ad44ff958b6ffb91",
+ "manifest": "07a28c7ef94d8add920fe5741dd69eee72ec4a0a0aac6309485a3c777088ee77",
+ "title": "Registration validator",
+ "demo": "registration"
+ },
+ {
+ "name": "migration",
+ "version": "0.1.0",
+ "hash": "cd1ed2a28c971e93532f3a02fe976e574ef124dcda7843db15fc14ba4ca5f0dc",
+ "manifest": "2878cd1986376dd8a2642681e5dec2e446f0157ab79d1649fd60df991deadbf6",
+ "title": "Profile migrations",
+ "demo": "migration"
+ },
+ {
+ "name": "tricu-base",
+ "version": "0.1.0",
+ "hash": "7dd76cb9f5df4ee3f49cae5fd09428810b8e11b9ce31902eb39e883d1362c7a6",
+ "manifest": "16f18ebe1eaa9231cf4ec2124baa6afa4a189d56f711d7da370fb6547de3fd11",
+ "title": "Foundation: lists, Results and decimal formatting"
+ },
+ {
+ "name": "tricu-file-io",
+ "version": "0.1.0",
+ "hash": "9b5195255e1f86ef55a6fe4b0d7d617663b28e3b582af352b9c44ef19c07b3be",
+ "manifest": "333f45c2ad7f36af74888a3b7488e4f58edb67d3621ce4ed8cf0865c6e1d6d98",
+ "title": "File IO and hashing actions"
+ },
+ {
+ "name": "arboricx-inspection",
+ "version": "0.1.0",
+ "hash": "1052124d79942aad560a3343ba92aace597e5d31c52ab76460e674db30aa5771",
+ "manifest": "36bf9b4c5827a90d161baeee8d5da1c26532b352b25958a50bb7e842ca4430b3",
+ "title": "Arboricx inspection: validate without execution"
+ }
+ ]
+}
diff --git a/ext/demo-site/prepare-index.py b/ext/demo-site/prepare-index.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""Publish the site's exact releases and prebaked demo environment via Haskell.
+
+Run through a Nix-built tricu. Does not change existing demo/compiler bundles.
+Rerun after prepare.py or an explicit compiler update; inspect resulting pins.
+"""
+import argparse
+import json
+from pathlib import Path
+import re
+import subprocess
+import tempfile
+from prepare import HERE, ROOT, digest, reference_compiler, pair, string, of_string
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('tricu', type=Path)
+ parser.add_argument('--version', action='append', default=[], metavar='NAME=VERSION', help='Explicit new release label for a changed demo')
+ args = parser.parse_args()
+ versions = dict(value.split('=', 1) for value in args.version)
+ host = args.tricu.resolve()
+ catalogue = json.loads((HERE / 'catalogue.json').read_text())
+ compiler = json.loads((HERE / 'compiler.json').read_text())
+ with tempfile.TemporaryDirectory(prefix='site-index-') as tmp:
+ tmp = Path(tmp)
+ store = tmp / 'store'
+
+ def run(*words):
+ reply = subprocess.run([str(host), *map(str, words), '+RTS', '-N1', '-M8192m', '-RTS'],
+ cwd=ROOT, capture_output=True, timeout=180)
+ if reply.returncode:
+ raise RuntimeError((reply.stdout + reply.stderr).decode())
+ return reply.stdout.decode().strip()
+
+ output = run('arboricx', 'import', '--store', store, '--file', reference_compiler())
+ manifest = re.search(r'Created module alias .* -> ([0-9a-f]{64})', output)[1]
+ names = ['isNat?', 'lte_?', 'add', 'and?', 'not?', 'equal?', 'or?', 'contains?']
+ base = {n: run('arboricx', 'run', '--store', store, '--module', manifest,
+ '--name', 'base.' + n, '--format', 'ternary') for n in names}
+ environment = tmp / 'environment.tree'
+ environment.write_bytes(bytes(map(int, ''.join('22' + of_string(n) + tree for n, tree in base.items()) + '0')))
+ source = (HERE / 'demos/practical-support.tri').read_bytes()
+ reply = run('arboricx', 'run', '--store', store, '--module', manifest,
+ '--name', compiler['export'], '--arg-tree-file', environment, '--arg', source.decode(), '--format', 'ternary')
+ success, payload = pair(reply)
+ assert success == '10', reply[:200]
+ definitions, _ = pair(payload)
+ bindings = dict(base)
+ while definitions != '0':
+ binding, definitions = pair(definitions)
+ name, tree = pair(binding)
+ bindings[string(name)] = tree
+
+ def put(domain, data):
+ pin = digest(domain.encode() + b'\0' + data)
+ path = store / 'objects' / pin[:3] / pin
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(data)
+ return pin
+
+ rows = []
+ for name, tree in bindings.items():
+ pin = put('arboricx.tree-term.v1', bytes(map(int, tree)))
+ rows.append(f'export\t{name}\tarboricx.tree-term.v1\t{pin}\tarboricx.abi.tree.v1\n')
+ pin = put('arboricx.module-manifest.v1', ('arboricx.module-manifest.v1\n' + ''.join(rows)).encode())
+ file = tmp / 'environment.arboricx'
+ run('arboricx', 'export', '--store', store, '--module', pin, '--output', file)
+ data = file.read_bytes()
+ assert len(data) <= 1_048_576
+ support_hash = digest(data)
+ profiles = dict(schema=1, compiler=compiler['hash'], profiles={
+ 'empty': None,
+ 'practical': dict(hash=support_hash, names=list(bindings), sourceSha256=digest(source)),
+ })
+ old_index = HERE / 'packages.json'
+ previous = json.loads(old_index.read_text())['releases'] if old_index.exists() else []
+ releases = list(previous)
+ for entry in catalogue:
+ # Operator-owned release labels. Refuse changed contents at a label;
+ # add/change a version explicitly rather than silently repointing it.
+ matches = [r for r in previous if r.get('demo') == entry['id'] and r['hash'] == entry['hash']]
+ version = versions.get(entry['id'], matches[-1]['version'] if matches else '0.1.0')
+ name = entry['id']
+ output = run('package', 'publish', '--root', tmp / 'registry', '--name', name,
+ '--version', version, '--file', HERE / 'bundles' / (entry['hash'] + '.arboricx'))
+ _, row = output.splitlines()
+ _, _, module, bundle = row.split('\t')
+ release = dict(name=name, version=version, hash=bundle, manifest=module, title=entry['title'], demo=entry['id'])
+ existing = next((r for r in previous if (r['name'], r['version']) == (name, version)), None)
+ if existing and (existing['hash'], existing['manifest']) != (bundle, module):
+ raise ValueError('Immutable release changed; choose a new version explicitly: ' + name)
+ if existing:
+ releases[releases.index(existing)] = release
+ else:
+ releases.append(release)
+ entry['environment'] = 'practical' if entry['id'] in ['shipping', 'events', 'registration', 'migration'] else 'empty'
+ (HERE / 'bundles' / (support_hash + '.arboricx')).write_bytes(data)
+ (HERE / 'environments.json').write_text(json.dumps(profiles, indent=2) + '\n')
+ (HERE / 'packages.json').write_text(json.dumps(dict(schema=1, releases=releases), indent=2) + '\n')
+ (HERE / 'catalogue.json').write_text(json.dumps(catalogue, ensure_ascii=False, indent=2) + '\n')
+ print(f'Prepared {len(releases)} exact releases and pinned practical environment {support_hash}')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ext/demo-site/release.nix b/ext/demo-site/release.nix
@@ -8,7 +8,7 @@ let
top = builtins.head (pkgs.lib.splitString "/" relative);
in builtins.elem top [
"src" "dist" "index.html" "style.css" "catalogue.json"
- "compiler.json" "tsconfig.json"
+ "compiler.json" "packages.json" "environments.json" "tsconfig.json"
];
};
dependencies = pkgs.runCommand "arboricx-site-deps" {
@@ -17,6 +17,7 @@ let
mkdir -p $out/share
python3 ${./release.py} \
--catalogue ${./catalogue.json} --compiler ${./compiler.json} \
+ --index ${./packages.json} --environments ${./environments.json} \
--bundles ${./bundles} --output $out/share/arboricx-site-deps
cp ${./seed.py} $out/share/arboricx-site-deps/seed.py
makeWrapper ${pkgs.python3}/bin/python3 $out/bin/arboricx-seed-site \
@@ -31,7 +32,7 @@ let
tsc -p tsconfig.json --outDir "$TMPDIR/dist"
diff -ru dist "$TMPDIR/dist"
mkdir -p $out
- cp index.html style.css catalogue.json compiler.json $out/
+ cp index.html style.css catalogue.json compiler.json packages.json environments.json $out/
cp -r "$TMPDIR/dist" $out/dist
# Keep the dependency pack in this output's Nix closure, not its webroot.
mkdir -p $out/nix-support
diff --git a/ext/demo-site/release.py b/ext/demo-site/release.py
@@ -8,11 +8,28 @@ import re
import shutil
-def dependencies(catalogue, compiler, bundles):
+def dependencies(catalogue, compiler, bundles, index, environments):
uses = {}
limits = {}
entries = [(entry, 1_048_576) for entry in json.loads(catalogue.read_text())]
- entries.append((dict(json.loads(compiler.read_text()), id='compiler'), 2 * 1_048_576))
+ compiler = json.loads(compiler.read_text())
+ entries.append((dict(compiler, id='compiler'), 2 * 1_048_576))
+ if environments['schema'] != 1 or environments['compiler'] != compiler['hash']:
+ raise ValueError('Environment/compiler mismatch')
+ for name, profile in environments['profiles'].items():
+ if profile is not None:
+ entries.append((dict(hash=profile['hash'], id='environment:' + name), 1_048_576))
+ if index['schema'] != 1 or len(index['releases']) > 4096:
+ raise ValueError('Invalid package index')
+ seen = set()
+ for release in index['releases']:
+ name, version = release['name'], release['version']
+ if any(not isinstance(v, str) or not re.fullmatch('[A-Za-z0-9][A-Za-z0-9._-]{0,127}', v) for v in [name, version]):
+ raise ValueError('Invalid release path')
+ if (name, version) in seen or not re.fullmatch('[0-9a-f]{64}', release['manifest']):
+ raise ValueError('Invalid/duplicate release')
+ seen.add((name, version))
+ entries.append((dict(hash=release['hash'], id='release:' + name + '@' + version), 1_048_576))
for entry, limit in entries:
digest = entry['hash']
if not isinstance(digest, str) or not re.fullmatch('[0-9a-f]{64}', digest):
@@ -35,16 +52,29 @@ def main():
parser.add_argument('--catalogue', type=Path, required=True)
parser.add_argument('--compiler', type=Path, required=True)
parser.add_argument('--bundles', type=Path, required=True)
+ parser.add_argument('--index', type=Path, required=True)
+ parser.add_argument('--environments', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
- rows = dependencies(args.catalogue, args.compiler, args.bundles)
+ index = json.loads(args.index.read_text())
+ environments = json.loads(args.environments.read_text())
+ rows = dependencies(args.catalogue, args.compiler, args.bundles, index, environments)
args.output.mkdir(parents=True, exist_ok=False)
for row in rows:
target = args.output / 'objects' / row['hash'][:3] / (row['hash'] + '.arboricx')
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(args.bundles / (row['hash'] + '.arboricx'), target)
+ releases = []
+ for release in index['releases']:
+ name, version = release['name'], release['version']
+ payload = ('arboricx.package-release.v1\n' + '\t'.join([name, version, release['manifest'], release['hash']]) + '\n').encode()
+ relative = 'packages/' + name + '/' + version
+ path = args.output / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(payload)
+ releases.append(dict(path=relative, hash=hashlib.sha256(payload).hexdigest(), size=len(payload)))
(args.output / 'dependencies.json').write_text(
- json.dumps(dict(schema=1, bundles=rows), indent=2) + '\n')
+ json.dumps(dict(schema=1, bundles=rows, releases=releases), indent=2) + '\n')
if __name__ == '__main__':
diff --git a/ext/demo-site/seed.py b/ext/demo-site/seed.py
@@ -58,6 +58,25 @@ def seed(pack, root):
relative = Path('objects') / digest[:3] / (digest + '.arboricx')
verify(pack / relative, digest, size) # Entire pack checked before installing.
rows.append((relative, digest, size))
+ # Authenticate immutable release records before creating the target. All
+ # bundle rows precede release rows, so no release advertises absent bytes.
+ release_seen = set()
+ for row in manifest.get('releases', []):
+ relative, digest, size = row['path'], row['hash'], row['size']
+ if (not isinstance(relative, str) or not re.fullmatch(r'packages/[A-Za-z0-9][A-Za-z0-9._-]{0,127}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}', relative)
+ or relative in release_seen or not re.fullmatch('[0-9a-f]{64}', digest)
+ or type(size) is not int or not 0 < size <= 4096):
+ raise ValueError('Invalid release record')
+ release_seen.add(relative)
+ verify(pack / relative, digest, size)
+ header, fields = (pack / relative).read_text().splitlines()
+ name, version, module, bundle = fields.split('\t')
+ expected = f'arboricx.package-release.v1\n{name}\t{version}\t{module}\t{bundle}\n'.encode()
+ if (header != 'arboricx.package-release.v1' or relative != f'packages/{name}/{version}'
+ or not re.fullmatch('[0-9a-f]{64}', module) or bundle not in seen
+ or (pack / relative).read_bytes() != expected):
+ raise ValueError('Release does not match its bundled dependency')
+ rows.append((Path(relative), digest, size))
root.mkdir(parents=True, exist_ok=True)
if root.is_symlink() or not root.is_dir():
raise ValueError('Expected real registry store directory')
@@ -69,7 +88,8 @@ def seed(pack, root):
directory(root / 'objects')
# Validate ALL existing objects before adding any missing dependency.
for relative, digest, size in rows:
- directory(root / 'objects' / digest[:3])
+ directory(root / relative.parts[0])
+ directory(root / relative.parts[0] / relative.parts[1])
target = root / relative
if target.exists() or target.is_symlink():
verify(target, digest, size)
diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts
@@ -1,9 +1,12 @@
import { createTreeExport } from './export-app.js';
import { activePane } from './tabs.js';
+import { DRAFT_KEY, readDraft, writeDraft } from './draft-storage.js';
import { readSettings } from './limits-ui.js';
import { ActiveClock } from './clock.js';
import { PACKAGE_ACTIVE_MS, activeMilliseconds } from './execution.js';
-import './compiler-app.js';
+import { sourceText } from './compiler.js';
+import { packageIndex, type Release } from './packages.js';
+import type { CompilerMessage } from './compiler-protocol.js';
import { bundleHash, fetchBytes, type BundleHash } from './bundle.js';
import { Graph } from './graph.js';
import { line, literals, outputType, type OutputType } from './codecs.js';
@@ -11,6 +14,17 @@ import type { CatalogueEntry, ClientMessage, WorkerMessage, RequestId } from './
function el<T extends HTMLElement = HTMLElement>(id: string): T {
const element = document.getElementById(id); if (!element) throw new Error(`Missing element ${id}`); return element as T;
}
+const sourceEditor = el<HTMLTextAreaElement>('source-editor'), compileButton = el<HTMLButtonElement>('compile');
+const restoreButton = el<HTMLButtonElement>('restore-source'), downloadButton = el<HTMLButtonElement>('download-bundle');
+let draft = false, scratch = false, compiled = false, busy = false, originalSource: string | undefined;
+let compilationStats = '', preferredName = 'id';
+let compilerAcknowledged = false, requestedRun = false;
+const consent = el<HTMLDialogElement>('compile-consent');
+let compilerTimer: ReturnType<typeof setInterval> | undefined;
+function stopCompilerActivity(): void { clearInterval(compilerTimer); compilerTimer = undefined; el('compile-activity').hidden = true; }
+el<HTMLButtonElement>('confirm-compile').onclick = () => { compilerAcknowledged = true; consent.close(); compileDraft(requestedRun); };
+el<HTMLButtonElement>('cancel-compile').onclick = () => consent.close();
+let releases: Release[] = [];
const hashInput = el<HTMLInputElement>('bundle-hash'), exportsInput = el<HTMLSelectElement>('export');
const argsInput = el<HTMLTextAreaElement>('arguments'), resultCodec = el<HTMLSelectElement>('output-type');
const mode = el<HTMLSelectElement>('mode'), steps = el<HTMLInputElement>('step-mode');
@@ -19,7 +33,7 @@ let settings = readSettings('package');
const deadline = new ActiveClock(() => finish('Stopped: active execution time limit exceeded.', true));
const shareButton = el<HTMLButtonElement>('share');
const treeExport = createTreeExport(message => {
- if (!loaded || !selection) return;
+ if (draft || !loaded || !selection) return;
if (worker) startExport(message, value => worker?.postMessage(value));
else newWorker(false, true, message);
}, name => {
@@ -33,19 +47,39 @@ let loaded = false, worker: Worker | undefined, pending: { kind: 'input' | 'step
let automaticRun = false;
let savedRun: Extract<ClientMessage, { type: 'run' }> | undefined;
let preferredExport: string | undefined;
+let downloadAfterLoad = false;
const traceRows = new Map<number, HTMLLIElement>();
function pauseClock(): void { deadline.pause(); }
function clock(): void { deadline.start(); }
function runClock(): void { deadline.reset(activeMilliseconds(settings)); clock(); }
-function controls(busy: boolean): void {
- runButton.disabled = busy || !loaded; stopButton.disabled = !busy;
- exportsInput.disabled = busy || !loaded; argsInput.disabled = busy;
+function controls(working: boolean): void {
+ busy = working;
+ recoveryButtons();
+ runButton.disabled = busy || (draft ? !sourceEditor.value.trim() : !loaded);
+ runButton.textContent = draft ? (compiled ? 'Run local draft' : 'Compile & run draft') : 'Run published';
+ runButton.classList.toggle('draft', draft);
+ compileButton.disabled = busy || !draft;
+ sourceEditor.disabled = busy;
+ restoreButton.disabled = busy || originalSource === undefined || !draft;
+ downloadButton.disabled = busy || draft || !loaded;
+ el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified';
+ el('authorship').classList.toggle('draft', draft);
+ sourceEditor.classList.toggle('draft', draft);
+ el('compiler-settings').hidden = !draft;
+ el('compiler-resource-choice').hidden = !draft;
+ el('package-resource-choice').hidden = draft;
+ el('compiler-disclosure').hidden = !draft;
+ el('export-draft-note').hidden = !draft;
+ el('package-settings').hidden = draft;
+ for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.disabled = busy;
+ stopButton.disabled = !busy && !(draft && worker);
+ exportsInput.disabled = busy || (draft ? !compiled : !loaded); argsInput.disabled = busy;
mode.disabled = busy; resultCodec.disabled = busy; steps.disabled = busy;
- shareButton.disabled = busy || !loaded;
- treeExport.controls(busy, loaded);
+ shareButton.disabled = busy || draft || !loaded;
+ treeExport.controls(busy, loaded && !draft);
}
function dispose(): void {
- pauseClock(); worker?.terminate(); worker = undefined; pending = undefined;
+ stopCompilerActivity(); pauseClock(); worker?.terminate(); worker = undefined; compiled = false; pending = undefined;
inputForm.hidden = true; nextButton.disabled = true; controls(false);
}
function clearOutput(): void {
@@ -54,7 +88,9 @@ function clearOutput(): void {
el('trace').replaceChildren(); traceRows.clear(); el('trace-empty').hidden = false; lineInput.value = '';
}
function finish(message: string, error = false): void {
- if (!loaded) el('package-info').textContent = `Package not loaded. ${message}`;
+ if (error && /Graph allocation limit|evaluation stack limit|Graph stack limit/u.test(message)) message = `Stopped to protect memory. Your source is unchanged. Standard's application guard was reached. You can explicitly choose Use browser limits to try a larger graph, at the risk of a tab crash or reload. Technical detail: ${message}`;
+ else if (error && /Graph fuel limit/u.test(message)) message = `Stopped at the optional work guard. Your source is unchanged; see Advanced to adjust it. Technical detail: ${message}`;
+ if (!draft && !loaded) el('package-info').textContent = `Package not loaded. ${message}`;
if (treeExport.pending || (activePane() === 'export-definition' && !loaded)) treeExport.fail(message);
dispose(); el('status').textContent = error ? 'Stopped' : 'Finished';
el('result').textContent = message; el('result').className = error ? 'error' : '';
@@ -70,7 +106,6 @@ function startExport(message: Extract<ClientMessage, { type: 'export' }>, send:
}
function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoad?: Extract<ClientMessage, { type: 'export' }>): void {
if (!selection) return;
- window.dispatchEvent(new Event('package-start'));
dispose(); settings = readSettings('package'); deadline.reset(PACKAGE_ACTIVE_MS); automaticRun = runAfterLoad;
const current = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); worker = current;
let currentVerified = false;
@@ -88,6 +123,16 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa
if (data.state === 'input') lineInput.focus();
}
break;
+ case 'downloaded': {
+ if (data.hash !== selection?.hash || draft) break;
+ pauseClock(); controls(false); el('status').textContent = 'Ready';
+ const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' }));
+ const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${data.hash}.arboricx`;
+ document.body.append(anchor); anchor.click(); anchor.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ el('result').textContent = `Downloaded original verified bundle ${data.hash}. No draft or applied result included.`;
+ break;
+ }
case 'loaded': {
currentVerified = true; loaded = true;
const oldExport = exportAfterLoad?.export ?? (automaticRun ? savedRun?.export : preferredExport ?? selected?.export);
@@ -98,9 +143,15 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa
if (oldExport && data.exports.some(e => e.name === oldExport)) exportsInput.value = oldExport;
preferredExport = undefined; treeExport.selection(exportsInput);
el('package-info').textContent = `SHA-256 verified · ${data.nodes.toLocaleString()} stored DAG nodes · ${data.exports.length} export(s) · ${data.hash}`;
+ if (!preserveInputs && !automaticRun && !exportAfterLoad && !downloadAfterLoad) {
+ originalSource = selected?.environment !== undefined && data.source !== null ? data.source : undefined;
+ sourceEditor.value = originalSource ?? ''; sourceEditor.hidden = originalSource === undefined;
+ el('source-notes').hidden = originalSource !== undefined;
+ }
el('source-label').textContent = data.source === null ? 'Package metadata' : 'Curated tricu / stable';
el('source').textContent = data.source ?? `Verified package metadata\n${JSON.stringify(data.metadata, null, 2)}\n\nChoose an export, its arguments and a result interpretation to apply this tree.`;
- if (exportAfterLoad) { pauseClock(); startExport(exportAfterLoad, send); }
+ if (downloadAfterLoad) { downloadAfterLoad = false; send({ type: 'download' }); }
+ else if (exportAfterLoad) { pauseClock(); startExport(exportAfterLoad, send); }
else if (automaticRun && savedRun) { pauseClock(); runClock(); automaticRun = false; send(savedRun); }
else { if (!preserveInputs && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); pauseClock(); controls(false); el('status').textContent = 'Ready'; }
break;
@@ -140,32 +191,38 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa
send({ type: 'load', ...selection, ...settings });
}
function select(entry: CatalogueEntry): void {
+ draft = false; scratch = false; originalSource = undefined; downloadAfterLoad = false;
dispose(); clearOutput(); clearTreeExport(); selected = entry; loaded = false;
selection = { hash: entry.hash, localId: entry.id }; hashInput.value = entry.hash;
+ sourceEditor.hidden = true; el('source-notes').hidden = false;
+ const currentVersion = el<HTMLSelectElement>('package-version').value;
+ showRelease(releases.find(r => r.hash === entry.hash && r.demo === entry.id && r.version === currentVersion) ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id));
setPreset(entry); el('source').textContent = 'Verifying package…';
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-demo]')) button.setAttribute('aria-pressed', String(button.dataset['demo'] === entry.id));
newWorker(false);
}
el<HTMLButtonElement>('load').onclick = () => {
+ draft = false; scratch = false; originalSource = undefined; downloadAfterLoad = false;
dispose(); clearOutput(); clearTreeExport(); loaded = false;
try {
+ sourceEditor.hidden = true; el('source-notes').hidden = false; showRelease();
const hash = bundleHash(hashInput.value); selected = undefined; selection = { hash };
for (const button of document.querySelectorAll('[data-demo]')) button.setAttribute('aria-pressed', 'false');
argsInput.value = '[]'; resultCodec.value = 'tree'; mode.value = 'pure';
el('source').textContent = 'Verifying registry package…'; newWorker(false);
} catch (error) { finish(error instanceof Error ? error.message : String(error), true); }
};
-hashInput.oninput = () => { el('package-info').textContent = 'Load this package to verify its hash and choose a definition.'; clearTreeExport(); dispose(); loaded = false; selection = undefined; selected = undefined; controls(false); el('status').textContent = 'Load package'; };
-exportsInput.onchange = () => { clearTreeExport(); treeExport.selection(exportsInput); if (selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); };
+hashInput.oninput = () => { showRelease(); draft = false; scratch = false; originalSource = undefined; sourceEditor.hidden = true; el('source-notes').hidden = false; el('package-info').textContent = 'Load this package to verify its hash and choose a definition.'; clearTreeExport(); dispose(); loaded = false; selection = undefined; selected = undefined; controls(false); el('status').textContent = 'Load package'; };
+exportsInput.onchange = () => { preferredName = exportsInput.value; clearTreeExport(); treeExport.selection(exportsInput); if (!scratch && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); };
runButton.onclick = () => {
+ if (draft) { if (compiled) runDraft(); else compileDraft(true); return; }
if (!loaded || !selection) return;
clearOutput();
try {
if (argsInput.value.length > 16_000) throw new Error('Argument JSON limit exceeded');
const args = literals(JSON.parse(argsInput.value) as unknown), output: OutputType = outputType(resultCodec.value);
savedRun = { type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value === 'io' ? 'io' : 'pure', step: steps.checked };
- window.dispatchEvent(new Event('package-start'));
- if (worker) { controls(true); runClock(); el('status').textContent = 'Running'; worker.postMessage(savedRun); }
+ if (worker) { controls(true); runClock(); el('status').textContent = 'Running'; worker.postMessage(savedRun); }
else newWorker(true);
} catch (error) { finish(error instanceof Error ? error.message : String(error), true); }
};
@@ -181,9 +238,9 @@ inputForm.onsubmit = event => {
};
nextButton.onclick = () => { if (pending?.kind === 'step') resume(); };
stopButton.onclick = () => finish('Stopped. Session discarded.', true);
-el<HTMLButtonElement>('reset').onclick = () => { dispose(); clearOutput(); el('status').textContent = loaded ? 'Ready' : 'Load package'; };
+el<HTMLButtonElement>('reset').onclick = () => { dispose(); clearOutput(); el('status').textContent = draft ? 'Ready to compile' : loaded ? 'Ready' : 'Load package'; };
shareButton.onclick = async () => {
- if (!selection) return;
+ if (draft || !selection) return;
const url = new URL(location.href), fragment = new URLSearchParams({ bundle: selection.hash, export: exportsInput.value });
if (selection.localId) fragment.set('demo', selection.localId);
url.hash = fragment.toString();
@@ -191,7 +248,182 @@ shareButton.onclick = async () => {
catch { el('result').textContent = url.href; }
};
window.addEventListener('pagehide', dispose);
-window.addEventListener('compiler-start', () => { if (worker) { dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package'; } });
+function populatePackages(): void {
+ const packages = el<HTMLSelectElement>('package-name');
+ packages.replaceChildren();
+ const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = 'Choose a package'; packages.append(placeholder);
+ for (const name of new Set(releases.map(r => r.name))) {
+ const option = document.createElement('option'); option.value = name; option.textContent = name; packages.append(option);
+ }
+ packages.onchange = () => {
+ const versions = el<HTMLSelectElement>('package-version'); versions.replaceChildren();
+ el<HTMLButtonElement>('load-release').disabled = !packages.value;
+ for (const release of releases.filter(r => r.name === packages.value)) {
+ const option = document.createElement('option'); option.value = release.version; option.textContent = release.version; versions.append(option);
+ }
+ };
+ packages.dispatchEvent(new Event('change'));
+}
+function showRelease(release?: Release): void {
+ const packages = el<HTMLSelectElement>('package-name'); packages.value = release?.name ?? ''; packages.dispatchEvent(new Event('change'));
+ el<HTMLSelectElement>('package-version').value = release?.version ?? '';
+ el('release-info').textContent = release ? `${release.title} · ${release.name}@${release.version} · module ${release.manifest}` : 'Direct bundle pin (no name/version selected).';
+}
+el<HTMLButtonElement>('load-release').onclick = () => {
+ const name = el<HTMLSelectElement>('package-name').value, version = el<HTMLSelectElement>('package-version').value;
+ const release = releases.find(r => r.name === name && r.version === version); if (!release) return;
+ const demo = entries.find(e => e.id === release.demo && e.hash === release.hash);
+ if (demo) select(demo);
+ else { hashInput.value = release.hash; el<HTMLButtonElement>('load').click(); }
+ showRelease(release);
+};
+downloadButton.onclick = () => {
+ if (draft || !loaded || !selection) return;
+ if (worker) { controls(true); deadline.reset(PACKAGE_ACTIVE_MS); clock(); worker.postMessage({ type: 'download' }); }
+ else { downloadAfterLoad = true; newWorker(false, true); }
+};
+function recoveryButtons(): void {
+ try {
+ const saved = readDraft(localStorage);
+ el('recover-draft').hidden = !saved; el('forget-draft').hidden = !saved; el('download-draft-source').hidden = !saved;
+ el<HTMLButtonElement>('recover-draft').disabled = busy || (!!saved?.demo && entries.length === 0);
+ if (saved && !el('draft-save-status').textContent) el('draft-save-status').textContent = 'A saved draft is available on this device. Recover it to continue editing; recovery does not run code.';
+ } catch {
+ el('recover-draft').hidden = true; el('download-draft-source').hidden = true; el('forget-draft').hidden = false;
+ el('draft-save-status').textContent = 'Draft recovery is unavailable. Keep a separate copy of your source. You can try Forget saved draft to remove invalid saved data.';
+ }
+}
+function saveDraft(): boolean {
+ if (!draft) return true;
+ try {
+ writeDraft(localStorage, { schema: 1, source: sourceEditor.value, name: exportsInput.value || preferredName,
+ arguments: argsInput.value, output: resultCodec.value, mode: mode.value === 'io' ? 'io' : 'pure',
+ ...(!scratch && selected?.environment !== undefined ? { demo: { hash: selected.hash, environment: selected.environment } } : {}) });
+ el('draft-save-status').textContent = 'Draft saved on this device for recovery (latest draft only). Browser storage may be cleared; keep important work elsewhere too.';
+ recoveryButtons(); return true;
+ } catch {
+ el('draft-save-status').textContent = 'Draft not saved: local storage is unavailable or this draft exceeds recovery limits. Copy your source somewhere safe before compiling.';
+ return false;
+ }
+}
+el<HTMLButtonElement>('recover-draft').onclick = () => {
+ try {
+ const saved = readDraft(localStorage); if (!saved) return;
+ const entry = saved.demo ? entries.find(e => e.hash === saved.demo!.hash && e.environment === saved.demo!.environment) : undefined;
+ if (saved.demo && !entry) throw new Error('The saved demo environment is no longer in this catalogue. Use Download saved source to recover your text; it has not been compiled against a substitute environment.');
+ const output = outputType(saved.output);
+ dispose(); clearOutput(); clearTreeExport();
+ draft = true; scratch = !saved.demo; selected = entry; originalSource = undefined; selection = undefined; loaded = false;
+ showRelease(entry ? releases.find(r => r.hash === entry.hash) : undefined); hashInput.value = entry?.hash ?? '';
+ el('package-info').textContent = entry ? `Recovered draft based on ${entry.title}. No published executable loaded.` : 'Recovered scratch program. No published package selected.';
+ sourceEditor.value = saved.source; sourceEditor.hidden = false; el('source-notes').hidden = true;
+ preferredName = saved.name; exportsInput.replaceChildren(); argsInput.value = saved.arguments; resultCodec.value = output; mode.value = saved.mode;
+ // A recovered draft does not inherit a previously selected risky profile.
+ const profile = el<HTMLInputElement>('compiler-uncapped'); profile.checked = false; profile.dispatchEvent(new Event('change'));
+ el('source-label').textContent = entry ? `Recovered draft · ${entry.title}` : 'Recovered scratch program · contract helpers';
+ controls(false); el('status').textContent = 'Recovered draft: not compiled';
+ } catch (error) { el('draft-save-status').textContent = error instanceof Error ? error.message : String(error); }
+};
+el<HTMLButtonElement>('download-draft-source').onclick = () => {
+ try {
+ const saved = readDraft(localStorage); if (!saved) return;
+ const url = URL.createObjectURL(new Blob([saved.source], { type: 'text/plain;charset=utf-8' }));
+ const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'local-draft.tri'; anchor.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ } catch { el('draft-save-status').textContent = 'Saved source is unavailable.'; }
+};
+el<HTMLButtonElement>('forget-draft').onclick = () => {
+ try { localStorage.removeItem(DRAFT_KEY); el('draft-save-status').textContent = 'Saved recovery copy removed. Editor contents are unchanged.'; recoveryButtons(); }
+ catch { el('draft-save-status').textContent = 'Unable to remove saved draft from local storage.'; }
+};
+recoveryButtons();
+sourceEditor.oninput = () => {
+ preferredName = exportsInput.value || preferredName;
+ dispose(); clearOutput(); clearTreeExport(); exportsInput.replaceChildren();
+ draft = scratch || sourceEditor.value !== originalSource;
+ controls(false);
+ if (draft) saveDraft();
+ if (!draft && selection) { preferredExport = preferredName; newWorker(false, true); }
+ else el('status').textContent = 'Ready to compile';
+};
+restoreButton.onclick = () => {
+ if (originalSource === undefined) return;
+ sourceEditor.value = originalSource; sourceEditor.dispatchEvent(new Event('input'));
+};
+compileButton.onclick = () => compileDraft(false);
+function compileDraft(runAfterCompile: boolean): void {
+ if (!draft) return;
+ if (!compilerAcknowledged) { requestedRun = runAfterCompile; el('compile-profile-note').textContent = readSettings('compiler').uncapped ? 'Selected: Use browser limits: execution guards off.' : 'Selected: Standard: application guards on.'; consent.showModal(); return; }
+ preferredName = exportsInput.value || preferredName;
+ dispose(); clearOutput(); clearTreeExport(); exportsInput.replaceChildren();
+ try {
+ sourceText(sourceEditor.value);
+ if (!saveDraft() && !window.confirm('Your draft could not be saved locally. Copy it somewhere safe first. Compile anyway, accepting that a tab crash may lose it?')) return;
+ settings = readSettings('compiler');
+ const current = new Worker(new URL('./compiler-worker.js', import.meta.url), { type: 'module' }); worker = current;
+ controls(true); deadline.reset(activeMilliseconds(settings)); clock(); el('status').textContent = 'Compiling';
+ el('compiler-stats').textContent = 'Loading pinned compiler and demo environment…';
+ const started = performance.now();
+ const activity = () => { el('compile-activity').hidden = false; el('compile-activity').textContent = `Compiling trees locally · ${Math.floor((performance.now() - started) / 1000)} seconds elapsed · Stop is available`; };
+ activity(); compilerTimer = setInterval(activity, 1000);
+ current.onmessage = ({ data }: MessageEvent<CompilerMessage>): void => {
+ if (worker !== current) return;
+ switch (data.type) {
+ case 'compiled':
+ stopCompilerActivity(); pauseClock(); compiled = true;
+ for (const name of data.names) { const option = document.createElement('option'); option.value = name; option.textContent = name; exportsInput.append(option); }
+ if (data.names.includes(preferredName)) exportsInput.value = preferredName;
+ controls(false); el('status').textContent = 'Compiled';
+ compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${settings.uncapped ? 'browser limits; execution guards off' : settings.limits.fuel === 0 ? 'no work limit; memory guard active' : 'bounded profile'} · ${data.stats.work.toLocaleString()} work units`;
+ el('compiler-stats').textContent = compilationStats;
+ if (runAfterCompile) runDraft();
+ break;
+ case 'state':
+ if (data.state === 'running') { clock(); pending = undefined; el('status').textContent = compiled ? 'Running' : 'Compiling'; }
+ else { pauseClock(); pending = { id: data.id, kind: data.state }; inputForm.hidden = data.state !== 'input'; nextButton.disabled = data.state !== 'step'; el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; }
+ break;
+ case 'trace': {
+ const row = document.createElement('li'); row.textContent = data.phase === 'before' ? `${data.event.step} ${data.event.name}: ${data.event.detail}` : `→ ${data.event.value}`; el('trace').append(row); el('trace-empty').hidden = true; break;
+ }
+ case 'output': el('terminal').textContent += data.text; break;
+ case 'done':
+ pauseClock(); pending = undefined; inputForm.hidden = true; nextButton.disabled = true; controls(false); el('status').textContent = 'Finished';
+ if (data.result.type === 'tree' || data.result.type === 'list') { el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(data.result.value, null, 2); el('result').textContent = `Returned ${data.result.type}: tree graph`; }
+ else el('result').textContent = `Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`;
+ el('compiler-stats').textContent = `${compilationStats} · ${data.stats.work.toLocaleString()} cumulative work · ${Number.isFinite(data.stats.fuel) ? `${data.stats.fuel.toLocaleString()} run work remaining` : 'unlimited run work'}`;
+ break;
+ case 'error': finish(data.message, true); break;
+ }
+ };
+ current.onerror = event => { if (worker === current) { event.preventDefault(); finish(event.message || 'Compiler Worker failed.', true); } };
+ current.postMessage({ type: 'compile', source: sourceEditor.value, ...settings,
+ ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) });
+ } catch (error) { finish(error instanceof Error ? error.message : String(error), true); }
+}
+function runDraft(): void {
+ if (!compiled || !worker) return;
+ try {
+ if (argsInput.value.length > 16_000) throw new Error('Argument JSON limit exceeded');
+ const args = literals(JSON.parse(argsInput.value) as unknown), output = outputType(resultCodec.value);
+ clearOutput(); controls(true); runClock(); el('status').textContent = 'Running';
+ worker.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked });
+ } catch (error) { el('result').textContent = error instanceof Error ? error.message : String(error); el('result').className = 'error'; }
+}
+const examples = {
+ identity: { name: 'id', source: 'id = (x : x)', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' },
+ contracts: { name: 'id', source: 'id x =@nat? x', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' },
+ pair: { name: 'pair', source: 'pair a b = t a b', args: [{ type: 'number', value: '7' }, { type: 'string', value: 'hi' }], output: 'tree', mode: 'pure' },
+ definitions: { name: 'answer', source: 'id = (x : x)\nanswer = id 42', args: [], output: 'number', mode: 'pure' },
+ input: { name: 'io', source: 'io=t "tricuIO" (t 1 (t 11 t))', args: [], output: 'string', mode: 'io' },
+};
+for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.onclick = () => {
+ const example = examples[button.dataset['compilerExample'] as keyof typeof examples]; if (!example) return;
+ dispose(); clearOutput(); clearTreeExport(); scratch = true; draft = true; originalSource = undefined;
+ exportsInput.replaceChildren(); preferredName = example.name;
+ sourceEditor.hidden = false; el('source-notes').hidden = true; sourceEditor.value = example.source;
+ argsInput.value = JSON.stringify(example.args, null, 2); resultCodec.value = example.output; mode.value = example.mode;
+ saveDraft(); controls(false); el('source-label').textContent = 'Scratch program · contract helpers'; el('status').textContent = 'Ready to compile';
+};
function loadFragment(): boolean {
const fragment = new URLSearchParams(location.hash.slice(1));
const requested = fragment.get('bundle');
@@ -200,6 +432,7 @@ function loadFragment(): boolean {
preferredExport = fragment.get('export') ?? undefined;
if (local) select(local);
else {
+ showRelease(); draft = false; scratch = false; originalSource = undefined;
dispose(); clearOutput(); clearTreeExport(); loaded = false; selected = undefined;
argsInput.value = '[]'; resultCodec.value = 'tree'; mode.value = 'pure';
selection = { hash }; hashInput.value = hash; newWorker(false);
@@ -208,7 +441,7 @@ function loadFragment(): boolean {
}
window.addEventListener('hashchange', () => {
if (!entries.length || !packagePane()) return;
- try { loadFragment(); }
+ try { if (new URLSearchParams(location.hash.slice(1)).has('bundle')) loadFragment(); }
catch (error) { loaded = false; finish(error instanceof Error ? error.message : String(error), true); }
});
function packagePane(): boolean { return activePane() === 'playground' || activePane() === 'export-definition'; }
@@ -227,13 +460,17 @@ async function initializePackages(): Promise<void> {
for (const key of ['id', 'title', 'source', 'sourceSha256', 'export']) if (typeof v[key] !== 'string') throw new Error('Invalid catalogue field');
const hash = bundleHash(v['hash']); literals(v['arguments']); outputType(v['output']);
if (v['mode'] !== 'pure' && v['mode'] !== 'io') throw new Error('Invalid catalogue mode');
+ if (v['environment'] !== undefined && (typeof v['environment'] !== 'string' || v['environment'].length > 128)) throw new Error('Invalid demo environment');
return { ...v, hash } as unknown as CatalogueEntry;
});
+ const indexBytes = await fetchBytes(new URL('../packages.json', import.meta.url), 1_048_576);
+ releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes)) as unknown);
+ populatePackages(); recoveryButtons();
for (const entry of entries) {
const button = document.createElement('button'); button.type = 'button'; button.dataset['demo'] = entry.id; button.textContent = entry.title;
button.setAttribute('aria-pressed', 'false'); button.onclick = () => select(entry); el('demo-picker').append(button);
}
- if (!packagePane()) return;
+ if (!packagePane() || selection || draft) return;
if (!loadFragment()) { const initial = entries.find(e => e.id === 'intro') ?? entries[0]; if (!initial) throw new Error('Empty catalogue'); select(initial); }
} catch (error) { initialized = false; loaded = false; finish(error instanceof Error ? error.message : String(error), true); }
}
@@ -241,18 +478,20 @@ window.addEventListener('pane-change', () => {
// Moving between panels cancels active work, but retains verified selection and
// completed exports. Move the single package chooser rather than mirror inputs.
if (worker) {
- if (runButton.disabled) finish('Stopped. Start a new operation in the selected tab.', true);
- else { dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package'; }
+ if (busy) finish('Stopped. Start a new operation in the selected tab.', true);
+ else if (!draft) { dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package'; }
}
if (packagePane()) {
document.querySelector(`#${activePane()} [data-package-slot]`)!.append(el('package-selection'));
if (!initialized) void initializePackages();
- else if (entries.length && !selection) { if (!loadFragment()) { const initial = entries.find(e => e.id === 'intro') ?? entries[0]; if (initial) select(initial); } }
- else if (entries.length && !loaded && selection) newWorker(false);
+ else if (!draft && entries.length && !selection) { if (!loadFragment()) { const initial = entries.find(e => e.id === 'intro') ?? entries[0]; if (initial) select(initial); } }
+ else if (!draft && entries.length && !loaded && selection) newWorker(false);
}
});
window.addEventListener('limits-change', event => {
- if ((event as CustomEvent<string>).detail !== 'package') return;
+ const runner = (event as CustomEvent<string>).detail;
+ if (runner === 'compiler') { if (draft) { dispose(); clearOutput(); el('status').textContent = 'Ready to compile'; } return; }
+ if (runner !== 'package' || draft) return;
preferredExport = exportsInput.value || preferredExport;
dispose(); loaded = false; clearOutput();
if (selection && packagePane()) newWorker(false, true);
diff --git a/ext/demo-site/src/compiler-app.ts b/ext/demo-site/src/compiler-app.ts
@@ -1,134 +0,0 @@
-import { readSettings } from './limits-ui.js';
-import { activeMilliseconds } from './execution.js';
-import { ActiveClock } from './clock.js';
-import { activePane } from './tabs.js';
-import { Graph } from './graph.js';
-import { literals, outputType, line } from './codecs.js';
-import { sourceText } from './compiler.js';
-import type { CompilerClient, CompilerMessage } from './compiler-protocol.js';
-import type { RequestId } from './protocol.js';
-function el<T extends HTMLElement = HTMLElement>(id: string): T {
- const node = document.getElementById(`compiler-${id}`); if (!node) throw new Error(`Missing compiler ${id}`); return node as T;
-}
-const source = el<HTMLTextAreaElement>('source');
-let settings = readSettings('compiler');
-const deadline = new ActiveClock(() => finish('Stopped: active execution time limit exceeded.', true));
-const compileButton = el<HTMLButtonElement>('compile'), runButton = el<HTMLButtonElement>('run'), stopButton = el<HTMLButtonElement>('stop');
-const names = el<HTMLSelectElement>('export'), args = el<HTMLTextAreaElement>('arguments'), output = el<HTMLSelectElement>('output');
-const mode = el<HTMLSelectElement>('mode'), step = el<HTMLInputElement>('step'), next = el<HTMLButtonElement>('next');
-const input = el<HTMLFormElement>('input-form'), lineInput = el<HTMLInputElement>('line');
-let worker: Worker | undefined, ready = false, busy = false, preferredName = 'id';
-let compilationStats = '';
-let pending: { id: RequestId; kind: 'input' | 'step' } | undefined;
-function controls(): void {
- compileButton.disabled = busy; runButton.disabled = busy || !ready;
- stopButton.disabled = !worker; source.disabled = busy;
- names.disabled = busy || !ready; args.disabled = busy; output.disabled = busy; mode.disabled = busy; step.disabled = busy;
- for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.disabled = busy;
-}
-function dispose(): void {
- deadline.pause(); worker?.terminate(); worker = undefined; ready = false; busy = false; pending = undefined;
- input.hidden = true; next.disabled = true; controls();
-}
-function clear(): void {
- el('terminal').textContent = ''; el('result').textContent = ''; el('result').className = '';
- el('graph').textContent = ''; el('graph').hidden = true; el('trace').textContent = ''; lineInput.value = '';
-}
-function finish(message: string, error = false, retain = false): void {
- if (retain) {
- deadline.pause(); busy = false; pending = undefined; input.hidden = true; next.disabled = true; controls();
- } else dispose(); el('status').textContent = error ? 'Stopped' : 'Finished'; el('result').textContent = message; el('result').className = error ? 'error' : '';
-}
-function invalidate(): void {
- dispose(); names.replaceChildren(); el('stats').textContent = 'Choose or edit source, then compile.';
- el('status').textContent = 'Ready to compile';
-}
-function send(message: CompilerClient): void { worker?.postMessage(message); }
-compileButton.onclick = () => {
- dispose(); clear(); names.replaceChildren();
- try {
- settings = readSettings('compiler');
- sourceText(source.value); // Validate only; encode exclusively in the Worker.
- window.dispatchEvent(new Event('compiler-start'));
- const current = new Worker(new URL('./compiler-worker.js', import.meta.url), { type: 'module' }); worker = current;
- busy = true; controls(); deadline.reset(activeMilliseconds(settings)); deadline.start(); el('status').textContent = 'Compiling';
- el('stats').textContent = 'Loading the tricu compiler → compiling definitions…';
- current.onmessage = ({ data }: MessageEvent<CompilerMessage>): void => {
- if (worker !== current) return;
- switch (data.type) {
- case 'state':
- if (data.state === 'running') { deadline.start(); pending = undefined; el('status').textContent = ready ? 'Running' : 'Compiling'; }
- else {
- deadline.pause(); pending = { id: data.id, kind: data.state }; input.hidden = data.state !== 'input'; next.disabled = data.state !== 'step';
- el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; if (data.state === 'input') lineInput.focus();
- }
- break;
- case 'compiled':
- deadline.pause(); ready = true; busy = false;
- for (const name of data.names) { const option = document.createElement('option'); option.value = name; option.textContent = name; names.append(option); }
- if (data.names.includes(preferredName)) names.value = preferredName;
- controls(); el('status').textContent = 'Compiled';
- el('stats').textContent = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms including load · ${data.stats.nodes.toLocaleString()} live nodes · ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${data.stats.collections} GC(s) · ${settings.uncapped ? 'execution limits disabled' : 'bounded profile'} · ${data.stats.work.toLocaleString()} work units`;
- compilationStats = el('stats').textContent ?? '';
- el('result').textContent = 'Your definitions are ready. Select one, choose its arguments and run it. Use [] when its inputs are already applied.';
- break;
- case 'trace':
- el('trace').textContent += data.phase === 'before' ? `${data.event.step} ${data.event.name}: ${data.event.detail}\n` : ` → ${data.event.value}\n`;
- break;
- case 'output': el('terminal').textContent += data.text; break;
- case 'done':
- if (data.result.type === 'tree' || data.result.type === 'list') {
- el('graph').hidden = false; el('graph').textContent = JSON.stringify(data.result.value, null, 2);
- finish(`Returned ${data.result.type}: tree graph`, false, true);
- } else finish(`Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`, false, true);
- el('stats').textContent = `${compilationStats} · current ${data.stats.nodes.toLocaleString()} live nodes / ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.work.toLocaleString()} cumulative work · ${Number.isFinite(data.stats.fuel) ? `${data.stats.fuel.toLocaleString()} run work remaining` : 'unlimited run work'} · ready to run again`;
- break;
- case 'error': finish(data.message, true); break;
- }
- };
- current.onerror = event => { if (worker === current) { event.preventDefault(); finish(event.message || 'Compiler Worker failed.', true); } };
- send({ type: 'compile', source: source.value, ...settings });
- } catch (error) { finish(error instanceof Error ? error.message : String(error), true); }
-};
-runButton.onclick = () => {
- if (!ready || !worker) return;
- try {
- if (args.value.length > 16_000) throw new Error('Argument JSON limit exceeded');
- const arguments_ = literals(JSON.parse(args.value) as unknown), codec = outputType(output.value);
- clear(); busy = true; controls(); deadline.reset(activeMilliseconds(settings)); deadline.start(); el('status').textContent = 'Running';
- send({ type: 'run', export: names.value, arguments: arguments_, output: codec, mode: mode.value === 'io' ? 'io' : 'pure', step: step.checked });
- } catch (error) {
- // Invalid literal input does not discard a successfully compiled heap.
- el('result').textContent = error instanceof Error ? error.message : String(error); el('result').className = 'error';
- }
-};
-function resume(value?: string): void {
- if (!pending || !worker) return;
- send(value === undefined ? { type: 'resume', id: pending.id } : { type: 'resume', id: pending.id, value });
- deadline.start(); pending = undefined; input.hidden = true; next.disabled = true; el('status').textContent = 'Running';
-}
-input.onsubmit = event => {
- event.preventDefault(); if (pending?.kind !== 'input') return;
- try { line(new Graph({ nodes: 16_000, fuel: 100_000 }), lineInput.value); resume(lineInput.value); }
- catch (error) { el('result').textContent = error instanceof Error ? error.message : String(error); el('result').className = 'error'; }
-};
-next.onclick = () => { if (pending?.kind === 'step') resume(); };
-stopButton.onclick = () => finish('Stopped. Compiler session discarded.', true);
-el<HTMLButtonElement>('reset').onclick = () => { invalidate(); clear(); };
-source.oninput = () => { invalidate(); clear(); };
-window.addEventListener('limits-change', event => { if ((event as CustomEvent<string>).detail === 'compiler') { invalidate(); clear(); } });
-const examples = {
- identity: { name: 'id', source: 'id = (x : x)', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' },
- contracts: { name: 'id', source: 'id x =@nat? x', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' },
- pair: { name: 'pair', source: 'pair a b = t a b', args: [{ type: 'number', value: '7' }, { type: 'string', value: 'hi' }], output: 'tree', mode: 'pure' },
- definitions: { name: 'answer', source: 'id = (x : x)\nanswer = id 42', args: [], output: 'number', mode: 'pure' },
- input: { name: 'io', source: 'io=t "tricuIO" (t 1 (t 11 t))', args: [], output: 'string', mode: 'io' },
-};
-for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.onclick = () => {
- const key = button.dataset['compilerExample'] as keyof typeof examples, example = examples[key]; if (!example) return;
- invalidate(); clear(); preferredName = example.name; source.value = example.source; args.value = JSON.stringify(example.args, null, 2); output.value = example.output; mode.value = example.mode;
-};
-window.addEventListener('package-start', () => { if (worker) finish('Switched to the package playground. Compile again to start a new compiler session.'); });
-window.addEventListener('pane-change', () => { if (activePane() !== 'compiler' && busy) finish('Stopped. Open Compiler and compile again to start a new session.', true); });
-window.addEventListener('pagehide', dispose);
-controls();
diff --git a/ext/demo-site/src/compiler-protocol.ts b/ext/demo-site/src/compiler-protocol.ts
@@ -2,7 +2,7 @@ import type { ExecutionLimits } from './execution.js';
import type { GraphStats } from './graph.js';
import type { OutputType } from './codecs.js';
import type { RequestId, WorkerMessage } from './protocol.js';
-export type CompilerClient = { type: 'compile'; source: unknown; uncapped?: boolean; limits?: ExecutionLimits }
+export type CompilerClient = { type: 'compile'; source: unknown; environment?: string; uncapped?: boolean; limits?: ExecutionLimits }
| { type: 'run'; export: string; arguments: unknown; output: OutputType; mode: 'pure' | 'io'; step: boolean }
| { type: 'resume'; id: RequestId; value?: string };
export type CompilerMessage = Exclude<WorkerMessage, { type: 'loaded' }>
diff --git a/ext/demo-site/src/compiler-worker.ts b/ext/demo-site/src/compiler-worker.ts
@@ -1,8 +1,9 @@
+import { environmentProfile } from './packages.js';
import { executionSettings, executionBudget, collectionPolicy, type ExecutionSettings } from './execution.js';
import { contractEnvironment } from './contracts.js';
import { Graph, type NodeId } from './graph.js';
import { COMPILER_BUNDLE_LIMIT, bundleHash, fetchBundle, fetchBytes, loadBundle } from './bundle.js';
-import { literals, makeLiteral, outputType, view } from './codecs.js';
+import { literals, makeLiteral, outputType, view, list, text } from './codecs.js';
import { runIO } from './io.js';
import { COMPILER_EXPORT, sourceLiteral, compiledDefinitions } from './compiler.js';
import type { CompilerClient, CompilerMessage } from './compiler-protocol.js';
@@ -20,7 +21,7 @@ function request(kind: 'step' | 'input'): Promise<string> {
const id = ++requestId as RequestId;
return new Promise((resolve, reject) => { pending = { id, kind, resolve, reject }; send({ type: 'state', state: kind, id }); });
}
-async function compile(source: unknown, settings: ExecutionSettings): Promise<void> {
+async function compile(source: unknown, settings: ExecutionSettings, profileName?: string): Promise<void> {
phase = 'compiling'; running(); const start = performance.now();
const budget = executionBudget(settings);
graph = new Graph(budget, collectionPolicy(budget));
@@ -33,7 +34,24 @@ async function compile(source: unknown, settings: ExecutionSettings): Promise<vo
const bundle = await fetchBundle(new URL(`/_arboricx/bundle/hash/${hash}`, base), hash, COMPILER_BUNDLE_LIMIT);
const library = loadBundle(graph, bundle), compiler = library.get(COMPILER_EXPORT);
if (compiler === undefined) throw new Error('Missing stable compiler entrypoint');
- const environment = contractEnvironment(graph, library);
+ let environment: NodeId;
+ if (profileName === undefined) environment = contractEnvironment(graph, library);
+ else {
+ const bytes = await fetchBytes(new URL('environments.json', base), 32_768);
+ const selected = environmentProfile(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown, profileName);
+ if (selected.compiler !== hash) throw new Error('Demo environment/compiler pin mismatch');
+ const bindings: NodeId[] = [];
+ if (selected.profile) {
+ const support = await fetchBundle(new URL(`/_arboricx/bundle/hash/${selected.profile.hash}`, base), selected.profile.hash);
+ const values = loadBundle(graph, support);
+ for (const name of selected.profile.names) {
+ const tree = values.get(name);
+ if (tree === undefined) throw new Error(`Missing demo environment binding ${name}`);
+ bindings.push(graph.fork(text(graph, name), tree));
+ }
+ }
+ environment = list(graph, bindings);
+ }
exports = compiledDefinitions(graph, graph.apply(compiler, [environment, input]));
graph.collect(exports.values()); // Safe phase boundary; compiler/input temporaries are no longer caller roots.
phase = 'ready'; send({ type: 'compiled', names: [...exports.keys()], stats: graph.stats, milliseconds: performance.now() - start });
@@ -69,7 +87,8 @@ scope.onmessage = ({ data }) => {
let operation: Promise<void>;
try {
if (message['type'] === 'compile' && phase === 'empty') {
- operation = compile(message['source'], executionSettings(message['limits'], 'compiler', message['uncapped'] === undefined ? false : message['uncapped']));
+ if (message['environment'] !== undefined && (typeof message['environment'] !== 'string' || message['environment'].length > 128)) throw new Error('Invalid demo environment');
+ operation = compile(message['source'], executionSettings(message['limits'], 'compiler', message['uncapped'] === undefined ? false : message['uncapped']), message['environment'] as string | undefined);
} else if (message['type'] === 'run' && phase === 'ready') {
if (typeof message['export'] !== 'string' || message['export'].length > 128) throw new Error('Invalid compiled selection');
operation = run({ type: 'run', export: message['export'], arguments: message['arguments'],
diff --git a/ext/demo-site/src/compiler.ts b/ext/demo-site/src/compiler.ts
@@ -1,9 +1,7 @@
import { Graph, type NodeId } from './graph.js';
import { text, readText, readList, readNatural } from './codecs.js';
export const COMPILER_EXPORT = 'source_unit.compileStableModuleDefinitions';
-export const COMPILER_BUDGET = Object.freeze({ nodes: 6_000_000, fuel: 80_000_000, stack: 100_000 });
-// Explicitly selected host profile; keeps GC but removes evaluation ceilings.
-export const UNCAPPED_COMPILER_BUDGET = Object.freeze({ nodes: Infinity, fuel: Infinity, stack: Infinity });
+export const COMPILER_BUDGET = Object.freeze({ nodes: 6_000_000, fuel: Infinity, stack: 100_000 });
export const COMPILER_COLLECTION = Object.freeze({ threshold: 250_000 });
export const SOURCE_LIMIT = 16_384;
export function sourceText(source: unknown): string {
diff --git a/ext/demo-site/src/draft-storage.ts b/ext/demo-site/src/draft-storage.ts
@@ -0,0 +1,39 @@
+// One local recovery slot, never executable state or an automatic run request.
+// Browser limits are intentionally NOT persisted: recovery starts in Standard.
+export const DRAFT_KEY = 'arboricx.local-draft.v1';
+export interface SavedDraft {
+ schema: 1;
+ source: string;
+ name: string;
+ arguments: string;
+ output: string;
+ mode: 'pure' | 'io';
+ demo?: { hash: string; environment: string };
+}
+export function savedDraft(raw: unknown): SavedDraft {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Invalid saved draft');
+ const v = raw as Record<string, unknown>;
+ for (const [key, max] of [['source', 32768], ['name', 256], ['arguments', 16000], ['output', 32]] as const)
+ if (typeof v[key] !== 'string' || v[key].length > max) throw new Error('Invalid saved draft ' + key);
+ if (v['schema'] !== 1 || (v['mode'] !== 'pure' && v['mode'] !== 'io')) throw new Error('Invalid saved draft version/mode');
+ let demo: SavedDraft['demo'];
+ if (v['demo'] !== undefined) {
+ const d = v['demo'] as Record<string, unknown>;
+ if (!d || typeof d !== 'object' || typeof d['hash'] !== 'string' || !/^[0-9a-f]{64}$/u.test(d['hash']) || typeof d['environment'] !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(d['environment'])) throw new Error('Invalid saved demo context');
+ demo = { hash: d['hash'], environment: d['environment'] };
+ }
+ return { schema: 1, source: v['source'] as string, name: v['name'] as string, arguments: v['arguments'] as string,
+ output: v['output'] as string, mode: v['mode'], ...(demo ? { demo } : {}) };
+}
+export function readDraft(storage: Pick<Storage, 'getItem'>): SavedDraft | undefined {
+ const text = storage.getItem(DRAFT_KEY);
+ if (text === null) return undefined;
+ if (text.length > 100000) throw new Error('Saved draft too large');
+ return savedDraft(JSON.parse(text) as unknown);
+}
+export function writeDraft(storage: Pick<Storage, 'setItem' | 'getItem'>, draft: SavedDraft): void {
+ const text = JSON.stringify(savedDraft(draft));
+ if (text.length > 100000) throw new Error('Saved draft too large');
+ storage.setItem(DRAFT_KEY, text);
+ if (storage.getItem(DRAFT_KEY) !== text) throw new Error('Draft storage verification failed');
+}
diff --git a/ext/demo-site/src/execution.ts b/ext/demo-site/src/execution.ts
@@ -1,7 +1,6 @@
import { DEFAULT_BUDGET, type Budget, type CollectionPolicy } from './graph.js';
import { COMPILER_BUDGET } from './compiler.js';
export const PACKAGE_ACTIVE_MS = 10_000;
-export const UNLIMITED_EXECUTION = Object.freeze({ nodes: Infinity, fuel: Infinity, stack: Infinity });
export const EXECUTION_COLLECTION = Object.freeze({ threshold: 250_000 });
export const PACKAGE_COLLECTION = Object.freeze({ threshold: 100_000 });
export const PACKAGE_BUDGET: Readonly<Budget> = Object.freeze({ ...DEFAULT_BUDGET, fuel: 20_000_000 });
@@ -9,7 +8,7 @@ export type Runner = 'package' | 'compiler';
export interface ExecutionLimits extends Budget { seconds: number; }
export interface ExecutionSettings { limits: ExecutionLimits; uncapped: boolean; }
export function defaultLimits(runner: Runner): ExecutionLimits {
- return { ...(runner === 'package' ? PACKAGE_BUDGET : COMPILER_BUDGET), seconds: runner === 'package' ? 10 : 0 };
+ return { ...(runner === 'package' ? PACKAGE_BUDGET : COMPILER_BUDGET), fuel: runner === 'package' ? PACKAGE_BUDGET.fuel : 0, seconds: runner === 'package' ? 10 : 0 };
}
export function executionSettings(raw: unknown, runner: Runner, uncapped: unknown = false): ExecutionSettings {
if (typeof uncapped !== 'boolean') throw new Error('Invalid disable-limits selection');
@@ -18,16 +17,16 @@ export function executionSettings(raw: unknown, runner: Runner, uncapped: unknow
const input = values as Record<string, unknown>;
for (const key of ['nodes', 'fuel', 'stack', 'seconds']) {
const value = input[key];
- if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < (key === 'seconds' ? 0 : 1)
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < (key === 'seconds' || key === 'fuel' ? 0 : 1)
|| (key === 'seconds' && !Number.isSafeInteger(value * 1000)))
- throw new Error(`${key}: enter a ${key === 'seconds' ? 'nonnegative' : 'positive'} whole number`);
+ throw new Error(`${key}: enter a ${key === 'seconds' || key === 'fuel' ? 'nonnegative' : 'positive'} whole number`);
}
return { limits: { nodes: input['nodes'] as number, fuel: input['fuel'] as number,
stack: input['stack'] as number, seconds: input['seconds'] as number }, uncapped };
}
export function executionBudget(settings: ExecutionSettings): Readonly<Budget> {
const { nodes, fuel, stack } = settings.limits;
- return settings.uncapped ? UNLIMITED_EXECUTION : { nodes, fuel, stack };
+ return settings.uncapped ? { nodes: Infinity, fuel: Infinity, stack: Infinity } : { nodes, fuel: fuel === 0 ? Infinity : fuel, stack };
}
export function activeMilliseconds(settings: ExecutionSettings): number {
return settings.uncapped || settings.limits.seconds === 0 ? Infinity : settings.limits.seconds * 1000;
diff --git a/ext/demo-site/src/limits-ui.ts b/ext/demo-site/src/limits-ui.ts
@@ -11,10 +11,10 @@ for (const runner of ['package', 'compiler'] as const) {
const container = document.getElementById(`${runner}-limits-defaults`)!;
const defaults = defaultLimits(runner), fields = new Map<string, HTMLInputElement>();
const form = document.createElement('form'); form.noValidate = true; form.className = 'limits-editor';
- for (const [key, label] of [['nodes', 'Live nodes'], ['fuel', 'Work units'], ['stack', 'Evaluator frames'], ['seconds', 'Active seconds (0 = unlimited)']] as const) {
+ for (const [key, label] of [['nodes', 'Live nodes'], ['fuel', 'Work units (0 = no work limit)'], ['stack', 'Evaluator frames'], ['seconds', 'Active seconds (0 = unlimited)']] as const) {
const wrapper = document.createElement('div'), caption = document.createElement('label'), input = document.createElement('input');
const hint = document.createElement('span'); hint.className = 'hint';
- input.id = `${runner}-limit-${key}`; input.type = 'number'; input.min = key === 'seconds' ? '0' : '1'; input.step = '1';
+ input.id = `${runner}-limit-${key}`; input.type = 'number'; input.min = key === 'seconds' || key === 'fuel' ? '0' : '1'; input.step = '1';
input.inputMode = 'numeric'; input.required = true; input.value = String(defaults[key]);
caption.htmlFor = input.id; caption.textContent = label;
hint.textContent = `Default: ${defaults[key].toLocaleString()}`;
@@ -29,9 +29,10 @@ for (const runner of ['package', 'compiler'] as const) {
function update(): void {
const settings = readSettings(runner);
const customized = Object.entries(defaults).some(([key, value]) => settings.limits[key as keyof typeof defaults] !== value);
- summary.textContent = `Execution limits: ${settings.uncapped ? 'disabled' : customized ? 'custom' : 'defaults'}`;
+ summary.textContent = `Advanced: ${settings.uncapped ? 'browser limits; execution guards off' : customized ? 'custom resource guards' : 'resource guards and diagnostics'}`;
for (const input of fields.values()) input.disabled = settings.uncapped;
apply.disabled = settings.uncapped;
+ document.getElementById(`${runner}-resource-mode`)!.textContent = settings.uncapped ? 'Use browser limits' : 'Standard';
}
function publish(settings: ExecutionSettings): void {
committed.set(runner, settings);
@@ -39,7 +40,7 @@ for (const runner of ['package', 'compiler'] as const) {
error.textContent = ''; update();
window.dispatchEvent(new CustomEvent('limits-change', { detail: runner }));
}
- form.oninput = () => { error.textContent = ''; summary.textContent = 'Execution limits: pending changes'; };
+ form.oninput = () => { error.textContent = ''; summary.textContent = 'Advanced: unapplied resource settings'; };
form.onsubmit = event => {
event.preventDefault();
try {
diff --git a/ext/demo-site/src/packages.ts b/ext/demo-site/src/packages.ts
@@ -0,0 +1,31 @@
+import { bundleHash, type BundleHash } from './bundle.js';
+export interface Release { name: string; version: string; hash: BundleHash; manifest: BundleHash; title: string; demo?: string; }
+export interface EnvironmentProfile { hash: BundleHash; names: string[]; }
+const component = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
+function record(value: unknown): Record<string, unknown> {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid metadata object');
+ return value as Record<string, unknown>;
+}
+export function packageIndex(value: unknown): Release[] {
+ const input = record(value);
+ if (input['schema'] !== 1 || !Array.isArray(input['releases']) || input['releases'].length > 4096) throw new Error('Invalid package index');
+ const seen = new Set<string>();
+ return input['releases'].map((raw: unknown) => {
+ const r = record(raw);
+ if (typeof r['name'] !== 'string' || !component.test(r['name']) || typeof r['version'] !== 'string' || !component.test(r['version']) ||
+ typeof r['title'] !== 'string' || r['title'].length > 256 || (r['demo'] !== undefined && (typeof r['demo'] !== 'string' || !component.test(r['demo'])))) throw new Error('Invalid release');
+ const key = `${r['name']}@${r['version']}`;
+ if (seen.has(key)) throw new Error('Duplicate package version'); seen.add(key);
+ return { name: r['name'], version: r['version'], title: r['title'], hash: bundleHash(r['hash']), manifest: bundleHash(r['manifest']),
+ ...(r['demo'] === undefined ? {} : { demo: r['demo'] as string }) };
+ });
+}
+export function environmentProfile(value: unknown, name: string): { compiler: BundleHash; profile: EnvironmentProfile | null } {
+ const input = record(value), profiles = record(input['profiles']);
+ if (input['schema'] !== 1 || !Object.hasOwn(profiles, name)) throw new Error('Unknown demo environment');
+ const compiler = bundleHash(input['compiler']), raw = profiles[name];
+ if (raw === null) return { compiler, profile: null };
+ const profile = record(raw), names = profile['names'];
+ if (!Array.isArray(names) || names.length > 128 || names.some(n => typeof n !== 'string' || !/^[A-Za-z_][A-Za-z0-9_?$%'.-]*$/u.test(n) || n.length > 128) || new Set(names).size !== names.length) throw new Error('Invalid environment bindings');
+ return { compiler, profile: { hash: bundleHash(profile['hash']), names: names as string[] } };
+}
diff --git a/ext/demo-site/src/protocol.ts b/ext/demo-site/src/protocol.ts
@@ -8,10 +8,12 @@ export type RequestId = number & { readonly __requestId: unique symbol };
export interface CatalogueEntry {
id: string; title: string; hash: BundleHash; source: string; sourceSha256: string;
export: string; arguments: unknown[]; output: OutputType; mode: 'pure' | 'io';
+ environment?: string;
presets?: Record<string, { arguments: unknown[]; output: OutputType; mode: 'pure' | 'io' }>;
}
export type ClientMessage = { type: 'load'; hash: BundleHash; localId?: string; uncapped?: boolean; limits?: ExecutionLimits }
| { type: 'run'; export: string; arguments: unknown; output: OutputType; mode: 'pure' | 'io'; step: boolean }
+ | { type: 'download' }
| { type: 'export'; export: string; format: TreeFormat }
| { type: 'resume'; id: RequestId; value?: string };
export type WorkerMessage = { type: 'state'; state: 'running' }
@@ -22,5 +24,6 @@ export type WorkerMessage = { type: 'state'; state: 'running' }
| { type: 'output'; text: string }
| { type: 'done'; result: ValueView; stats: GraphStats }
| { type: 'exported'; hash: BundleHash; export: string; format: TreeFormat; file: TreeFile }
+ | { type: 'downloaded'; hash: BundleHash; bytes: Uint8Array }
| { type: 'export-error'; message: string }
| { type: 'error'; message: string };
diff --git a/ext/demo-site/src/tabs.ts b/ext/demo-site/src/tabs.ts
@@ -1,4 +1,4 @@
-type Pane = 'how' | 'playground' | 'compiler' | 'export-definition';
+type Pane = 'how' | 'playground' | 'export-definition';
const tabs = [...document.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
export function activePane(): Pane {
return (tabs.find(tab => tab.getAttribute('aria-selected') === 'true')?.dataset['pane'] ?? 'how') as Pane;
diff --git a/ext/demo-site/src/worker.ts b/ext/demo-site/src/worker.ts
@@ -1,7 +1,7 @@
import { exportTree, treeFormat } from './tree-export.js';
import { executionSettings, executionBudget, collectionPolicy, PACKAGE_COLLECTION, type ExecutionSettings } from './execution.js';
import { Graph } from './graph.js';
-import { bundleHash, fetchBundle, fetchBytes, loadBundle, sha256, type Bundle, type BundleHash } from './bundle.js';
+import { bundleHash, decodeBundle, fetchBytes, loadBundle, sha256, type Bundle, type BundleHash } from './bundle.js';
import { literals, makeLiteral, outputType, view } from './codecs.js';
import { runIO } from './io.js';
import type { ClientMessage, WorkerMessage, RequestId } from './protocol.js';
@@ -11,7 +11,7 @@ const send = (message: WorkerMessage): void => scope.postMessage(message);
const running = (): void => send({ type: 'state', state: 'running' });
let phase: 'empty' | 'loading' | 'ready' | 'running' | 'done' = 'empty';
let graph: Graph | undefined, exports: Map<string, import('./graph.js').NodeId> | undefined;
-let stored: { hash: BundleHash; bundle: Bundle } | undefined;
+let stored: { hash: BundleHash; bundle: Bundle; bytes: Uint8Array } | undefined;
let requestId = 0;
let settings: ExecutionSettings = executionSettings(undefined, 'package');
let pending: { id: RequestId; kind: 'step' | 'input'; resolve(value: string): void; reject(error: Error): void } | undefined;
@@ -36,8 +36,10 @@ async function load(hash: BundleHash, localId: string | undefined): Promise<void
await sha256(new TextEncoder().encode(entry['source'])) !== entry['sourceSha256']) throw new Error('Catalogue source integrity failure');
source = entry['source'];
}
- const bundle: Bundle = await fetchBundle(url, hash);
- stored = { hash, bundle };
+ const bytes = await fetchBytes(url);
+ if (await sha256(bytes) !== hash) throw new Error('Bundle SHA-256 mismatch');
+ const bundle = decodeBundle(bytes);
+ stored = { hash, bundle, bytes };
const budget = executionBudget(settings);
graph = new Graph(budget, collectionPolicy(budget, PACKAGE_COLLECTION.threshold)); exports = loadBundle(graph, bundle);
phase = 'ready';
@@ -70,6 +72,9 @@ scope.onmessage = ({ data }) => {
else waiter.resolve(typeof message['value'] === 'string' ? message['value'] : '');
return;
}
+ if (message['type'] === 'download' && phase === 'ready' && stored) {
+ send({ type: 'downloaded', hash: stored.hash, bytes: stored.bytes }); return;
+ }
if (message['type'] === 'export' && phase === 'ready' && stored) {
try {
if (typeof message['export'] !== 'string' || message['export'].length > 512) throw new Error('Invalid export selection');
diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css
@@ -174,3 +174,24 @@ details pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:11px; }
.package-controls .input-row { flex-wrap:wrap; }
.package-controls .input-row input { flex-basis:100%; }
}
+
+#source-editor { flex:1; width:100%; margin:0; min-height:320px; resize:vertical; border:0; border-radius:0; background:transparent; color:inherit; font:13px/1.6 ui-monospace,monospace; padding:20px; }
+#source-editor.draft { background:#273c32; box-shadow:inset 4px 0 #e5b96b; }
+#authorship { margin:0; padding:10px 20px; color:#c6dacb; font-size:13px; }
+#authorship.draft { color:#ffda98; }
+button.primary.draft { background:#8b581b; }
+.source-panel > .controls,.scratch-examples,.compiler-metrics { margin:10px 20px; }
+.scratch-examples,.compiler-metrics { color:#c6dacb; }
+.scratch-examples .hint,.compiler-metrics .hint { color:inherit; }
+#release-info { overflow-wrap:anywhere; }
+.sr-only { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; }
+[hidden] { display:none !important; }
+
+@media(max-width:740px) { #source-editor { font-size:16px; height:320px; min-height:240px; max-height:360px; } }
+#compiler-disclosure,#compile-activity { margin:10px 20px; color:#e2d5af; }
+#compile-consent { max-width:560px; width:calc(100% - 32px); border:1px solid var(--line); border-radius:12px; padding:24px; background:var(--panel); color:var(--ink); }
+#compile-consent::backdrop { background:#0b1a15aa; }
+.source-panel details > summary { color:#c6dacb; }
+.resource-choice { border:1px solid var(--line); border-radius:8px; padding:12px 16px; margin:12px 0; }
+.resource-choice p { margin:4px 0 8px; }
+.draft-recovery { padding:0 20px; }
diff --git a/ext/demo-site/test/browser.mjs b/ext/demo-site/test/browser.mjs
@@ -26,7 +26,10 @@ for(const name of ['id','append','size']) {
const catalogue=JSON.parse(await readFile(resolve(root,'catalogue.json')));
const greeting=catalogue.find(entry=>entry.id==='greet');
published.greet=greeting.hash;
-for(const entry of catalogue)registry.set(entry.hash,await dependencyBytes(entry.hash));
+const packageIndex=JSON.parse(await readFile(resolve(root,'packages.json')));
+for(const entry of [...catalogue,...packageIndex.releases])registry.set(entry.hash,await dependencyBytes(entry.hash));
+const environments=JSON.parse(await readFile(resolve(root,'environments.json')));
+for(const profile of Object.values(environments.profiles))if(profile)registry.set(profile.hash,await dependencyBytes(profile.hash));
const records=[[]];for(let i=1;i<32;i++)records.push([i-1,i-1]);
const previewBundle=makeBundle(records,[['main',14]]);published.preview=pin(previewBundle);registry.set(published.preview,previewBundle);
const huge=makeBundle(records);published.huge=pin(huge);registry.set(published.huge,huge);
@@ -83,7 +86,7 @@ let browser, socket;
let next = 0; const pending = new Map();
try {
browser = spawn(process.argv[2] || 'chromium', ['--headless', '--no-sandbox', '--disable-dev-shm-usage',
- '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] });
+ '--js-flags=--max-old-space-size=768', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] });
const debuggerURL = await new Promise((resolve, reject) => {
let stderr = '';
const timer = setTimeout(() => reject(new Error('Chromium startup timed out: ' + stderr)), 15_000);
@@ -134,7 +137,8 @@ try {
const status = text => `document.getElementById('status')?.textContent === ${JSON.stringify(text)}`;
const click = async id => {
if (!id.startsWith('tab-')) await evaluate(`(()=>{const panel=document.getElementById(${JSON.stringify(id)}).closest('[role="tabpanel"]');if(panel)document.getElementById(panel.getAttribute('aria-labelledby')).click();})()`);
- return evaluate(`document.getElementById(${JSON.stringify(id)}).click()`);
+ await evaluate(`document.getElementById(${JSON.stringify(id)}).click()`);
+ if(await evaluate("document.getElementById('compile-consent').open"))await evaluate("document.getElementById('confirm-compile').click()");
};
const openPlayground = async () => {
await wait("document.getElementById('tab-playground')?.onclick !== null");
@@ -161,16 +165,16 @@ try {
return sizes.row;
};
const heights=[];
- for(const id of ['tab-compiler','tab-export-definition','tab-how','tab-playground']) {await click(id);heights.push(await assertPaneFits());}
+ for(const id of ['tab-export-definition','tab-how','tab-playground']) {await click(id);heights.push(await assertPaneFits());}
assert.ok(new Set(heights.map(Math.round)).size>1,'height changes between tabs');
- assert.equal(await evaluate("document.getElementById('compiler').inert"),true);
+ assert.equal(await evaluate("document.querySelectorAll('[role=tab]').length"),3);
assert.deepEqual(await evaluate("['nodes','fuel','stack','seconds'].map(k=>document.getElementById('package-limit-'+k).value)"),['250000','20000000','50000','10']);
- assert.deepEqual(await evaluate("['nodes','fuel','stack','seconds'].map(k=>document.getElementById('compiler-limit-'+k).value)"),['6000000','80000000','100000','0']);
+ assert.deepEqual(await evaluate("['nodes','fuel','stack','seconds'].map(k=>document.getElementById('compiler-limit-'+k).value)"),['6000000','0','100000','0']);
console.log('PASS Ecosystem default / deferred package load / ARIA keyboard tabs / dynamic active height / editable default limits');
assert.equal(requests.some(path=>path.endsWith('/compiler.json')),false);
- assert.equal(await evaluate("document.getElementById('compiler-compile').disabled"),false);
+ assert.equal(await evaluate("document.getElementById('compile').disabled"),true);
assert.equal(await evaluate("document.getElementById('export').value"),'answer');
- assert.match(await evaluate("document.getElementById('source').textContent"),/Start here: a small tricu language guide/);
+ assert.match(await evaluate("document.getElementById('source-editor').value"),/Start here: a small tricu language guide/);
await click('run');await wait(status('Finished'));
assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"');
for(const [name,expected] of [['identity','Returned string: "Hello!"'],['first','Returned number: "7"'],['greeting','Returned string: "Hello from tricu!"'],['numbers','Returned list: tree graph'],['leaf','Returned tree: tree graph'],['stem','Returned tree: tree graph'],['fork','Returned tree: tree graph']]){
@@ -203,7 +207,7 @@ try {
assert.equal(await evaluate('window.__copiedTree'),await evaluate('window.__downloadedTree'));
assert.match(await evaluate('window.__downloadName'),/^identity-[0-9a-f]{12}\.dag$/);
const retainedExport=await evaluate("document.getElementById('tree-preview').textContent");
- await click('tab-compiler'); await click('tab-export-definition');
+ await click('tab-playground'); await click('tab-export-definition');
assert.equal(await evaluate("document.getElementById('copy-tree').disabled"),false);
assert.equal(await evaluate("document.getElementById('tree-preview').textContent"),retainedExport);
@@ -215,17 +219,12 @@ try {
console.log('PASS stored definition export after Run / all formats / no argument parsing / provenance / copy-download / selection invalidation');
const assertSourceFill=async()=>{
- const layout=await evaluate("(()=>{const panel=document.querySelector('#playground .source-panel'),pre=panel.querySelector('pre'),heading=panel.querySelector('.panel-heading');const p=panel.getBoundingClientRect(),s=pre.getBoundingClientRect(),h=heading.getBoundingClientRect();return {bottom:p.bottom-s.bottom,top:s.top-h.bottom,height:s.height};})()");
- assert.ok(Math.abs(layout.bottom)<1,'source scroller reaches green pane bottom');
- assert.ok(Math.abs(layout.top)<1,'source scroller starts below heading');return layout.height;
+ const layout=await evaluate("(()=>{const panel=document.querySelector('#playground .source-panel'),source=panel.querySelector('#source-editor:not([hidden]),#source-notes:not([hidden])');const p=panel.getBoundingClientRect(),s=source.getBoundingClientRect();return {left:s.left,right:s.right,pLeft:p.left,pRight:p.right,height:s.height};})()");
+ assert.ok(layout.left>=layout.pLeft-1 && layout.right<=layout.pRight+1,'source stays within its panel');
+ assert.ok(layout.height>0,'source is visible'); return layout.height;
};
await assertSourceFill();
- const normalSourceHeight=await assertSourceFill();
- await evaluate("document.querySelector('#playground .application-fields details').open=true");
- assert.ok((await assertSourceFill())>normalSourceHeight,'source follows expanded controls');
- await evaluate("document.querySelector('#playground .application-fields details').open=false");
- assert.ok(Math.abs((await assertSourceFill())-normalSourceHeight)<1);
- console.log('PASS source fills desktop green pane / control expansion and shrink');
+ console.log('PASS editable source stays within shared workspace');
const resultRecord=async()=>{
const value=await evaluate("document.getElementById('result').textContent");
@@ -263,7 +262,7 @@ try {
if(data.type==='done'){clearTimeout(timer);w.terminate();resolve({compilation,result:data.result,uncapped:data.stats.fuel===Infinity});}
if(data.type==='error'){clearTimeout(timer);w.terminate();reject(Error(data.message));}
};
- w.postMessage({type:'compile',source:'id = (x : x)',uncapped:true});
+ w.postMessage({type:'compile',source:'id = (x : x)',uncapped:true,limits:{nodes:2,fuel:1,stack:1,seconds:1}});
})`);
assert.deepEqual(compilerGate.compilation.names,['id']);assert.equal(compilerGate.result.value,'42');assert.equal(compilerGate.uncapped,true);
assert.ok(compilerGate.compilation.stats.collections>0);
@@ -279,7 +278,7 @@ try {
await evaluate("document.getElementById('uncapped').checked=true;document.getElementById('uncapped').dispatchEvent(new Event('change'))");
await wait(status('Ready'));await click('run');await wait(status('Finished'));
assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned bool: true');
- assert.equal(await evaluate("document.getElementById('package-limits-summary').textContent"),'Execution limits: disabled');
+ assert.equal(await evaluate("document.getElementById('package-limits-summary').textContent"),'Advanced: browser limits; execution guards off');
const unlimitedPackage=await evaluate(`new Promise((resolve,reject)=>{
const w=new Worker(new URL('./dist/worker.js',location.href),{type:'module'});
w.onmessage=({data})=>{
@@ -287,7 +286,7 @@ try {
if(data.type==='done'){w.terminate();resolve({result:data.result,fuel:data.stats.fuel===Infinity,collections:data.stats.collections});}
if(data.type==='error'){w.terminate();reject(Error(data.message));}
};
- w.postMessage({type:'load',hash:${JSON.stringify(published.id)},uncapped:true});
+ w.postMessage({type:'load',hash:${JSON.stringify(published.id)},uncapped:true,limits:{nodes:2,fuel:1,stack:1,seconds:1}});
})`);
assert.equal(unlimitedPackage.result.value,'42');assert.equal(unlimitedPackage.fuel,true);assert.ok(unlimitedPackage.collections>0);
await evaluate("document.getElementById('uncapped').checked=false;document.getElementById('uncapped').dispatchEvent(new Event('change'))");await wait(status('Ready'));
@@ -315,7 +314,7 @@ try {
assert.equal(await evaluate("document.getElementById('status').textContent"),'Ready');
assert.match(await evaluate("document.querySelector('#playground .limits-error').textContent"),/nodes/);
await evaluate("document.getElementById('package-limit-nodes').value='300000';document.querySelector('#playground .limits-editor').requestSubmit()");await wait(status('Ready'));
- assert.equal(await evaluate("document.getElementById('package-limits-summary').textContent"),'Execution limits: custom');
+ assert.equal(await evaluate("document.getElementById('package-limits-summary').textContent"),'Advanced: custom resource guards');
await evaluate("document.querySelector('#playground .limits-editor button[type=button]').click()");await wait(status('Ready'));
assert.equal(await evaluate("document.getElementById('package-limit-nodes').value"),'250000');
console.log('PASS custom Worker budgets / finite fuel / small GC thresholds / malformed settings / Apply validation / restore');
@@ -438,128 +437,196 @@ try {
assert.equal(await evaluate("document.getElementById('terminal').textContent"),'');
console.log('PASS share selection loads metadata without executing');
+ await evaluate("document.getElementById('package-name').value='tricu-base';document.getElementById('package-name').dispatchEvent(new Event('change'));document.getElementById('package-version').value='0.1.0'");
+ await click('load-release');await wait(status('Ready'));
+ assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true,'library release is not a curated editable demo');
+ await evaluate("document.getElementById('export').value='showNumber';document.getElementById('export').dispatchEvent(new Event('change'));document.getElementById('arguments').value='[{\"type\":\"number\",\"value\":\"42\"}]';document.getElementById('output-type').value='string';document.getElementById('mode').value='pure'");
+ await click('run');await wait(status('Finished'));
+ assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned string: "42"');
+ assert.match(await evaluate("document.getElementById('release-info').textContent"),/tricu-base@0.1.0/);
+ console.log('PASS published library selection / verified native library running in Chromium / no fabricated editable source');
+
+ await evaluate("document.querySelector('[data-compiler-example=identity]').click()");
+ // The same controls now serve published releases and explicitly local drafts.
+ await evaluate("document.getElementById('package-name').value='intro';document.getElementById('package-name').dispatchEvent(new Event('change'));document.getElementById('package-version').value='0.1.0'");
+ await click('load-release');await wait(status('Ready'));
+ assert.match(await evaluate("document.getElementById('release-info').textContent"),/intro@0.1.0/);
+ const publishedSource=await evaluate("document.getElementById('source-editor').value");
+ await evaluate(`window.__createURL=URL.createObjectURL;URL.createObjectURL=blob=>{window.__bundleBytes=blob.arrayBuffer().then(b=>Array.from(new Uint8Array(b)));return window.__createURL(blob);};
+ window.__bundleClick=e=>{if(e.target instanceof HTMLAnchorElement && e.target.download){e.preventDefault();window.__bundleName=e.target.download;}};document.addEventListener('click',window.__bundleClick);`);
+ await click('download-bundle');await wait("document.getElementById('result').textContent.includes('Downloaded original verified bundle')");
+ const downloaded=Buffer.from(await evaluate('window.__bundleBytes'));
+ assert.equal(pin(downloaded),catalogue.find(e=>e.id==='intro').hash);
+ assert.equal(await evaluate('window.__bundleName'),pin(downloaded)+'.arboricx');
+ await evaluate("URL.createObjectURL=window.__createURL;document.removeEventListener('click',window.__bundleClick)");
+ await evaluate("document.getElementById('source-editor').value=document.getElementById('source-editor').value.replace('first 42 7','first 43 7');document.getElementById('source-editor').dispatchEvent(new Event('input'))");
+ assert.match(await evaluate("document.getElementById('authorship').textContent"),/Local draft/);
+ assert.equal(await evaluate("document.getElementById('download-bundle').disabled"),true);
+ assert.equal(await evaluate("document.getElementById('share').disabled"),true);
+ assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true);
+ const beforeConsent=requests.filter(path=>path.endsWith('/compiler.json')).length;
+ await evaluate("document.getElementById('run').click()");
+ assert.equal(await evaluate("document.getElementById('compile-consent').open"),true);
+ assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,beforeConsent);
+ await evaluate("document.getElementById('cancel-compile').click()");
+ assert.equal(await evaluate("document.getElementById('compile-consent').open"),false);
+ assert.match(await evaluate("document.getElementById('authorship').textContent"),/Local draft/);
+ await click('run');await wait(status('Finished')+' || '+status('Stopped'),60);
+ assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "43"');
+ console.log('PASS curated intro edit with explicit empty environment',await evaluate("document.getElementById('compiler-stats').textContent"));
+ // An edit invalidates the compiled heap immediately. A failed draft never
+ // executes the previously compiled 43 or the published 42.
+ await evaluate("document.getElementById('source-editor').value='answer = (';document.getElementById('source-editor').dispatchEvent(new Event('input'))");
+ assert.equal(await evaluate("document.getElementById('export').disabled"),true);
+ await click('run');await wait(status('Stopped'));
+ assert.match(await evaluate("document.getElementById('result').textContent"),/Compiler diagnostic/);
+ await click('restore-source');await wait(status('Ready'));
+ assert.equal(await evaluate("document.getElementById('source-editor').value"),publishedSource);
+ assert.match(await evaluate("document.getElementById('authorship').textContent"),/Published/);
+ await click('run');await wait(status('Finished'));
+ assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"');
+ // All practical demos use a prebaked support package, not browser imports.
+ for(const demo of ['shipping','events','registration','migration']) {
+ await evaluate(`document.querySelector('[data-demo=${demo}]').click()`);await wait(status('Ready'));
+ await evaluate("document.getElementById('source-editor').value='answer = stringField \"status\" \"edited\"';document.getElementById('source-editor').dispatchEvent(new Event('input'));document.getElementById('arguments').value='[]';document.getElementById('output-type').value='list'");
+ await click('run');await wait(status('Finished')+' || '+status('Stopped'),60);
+ const result=await evaluate("document.getElementById('result').textContent");
+ assert.match(result,/^Returned list:/,demo+': '+result);
+ console.log('PASS editable '+demo,await evaluate("document.getElementById('compiler-stats').textContent"));
+ await click('restore-source');await wait(status('Ready'));
+ }
+ await loadHash(published.id);
+ assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true);
+ assert.equal(await evaluate("document.getElementById('compile').disabled"),true);
+ assert.equal(await evaluate("document.getElementById('download-bundle').disabled"),false);
+ console.log('PASS named releases / exact bundle download / local draft invalidation / failure without fallback / restore / arbitrary read-only packages');
+ await evaluate("document.querySelector('[data-compiler-example=identity]').click()");
// Compiler UI: compile outputs stay in the Worker and never become flat source.
- const compilerStatus=text=>`document.getElementById('compiler-status').textContent===${JSON.stringify(text)}`;
+ const compilerStatus=text=>`document.getElementById('status').textContent===${JSON.stringify(text)}`;
await evaluate("document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))");
- await click('compiler-compile');await wait(compilerStatus('Compiled'));
- assert.equal(await evaluate("document.getElementById('compiler-export').value"),'id');
+ await click('compile');await wait(compilerStatus('Compiled'));
+ assert.equal(await evaluate("document.getElementById('export').value"),'id');
// Bad arguments leave the costly compiled heap available for correction.
- await evaluate("document.getElementById('compiler-arguments').value='invalid JSON'");await click('compiler-run');
- assert.equal(await evaluate("document.getElementById('compiler-status').textContent"),'Compiled');
- await evaluate("document.getElementById('compiler-arguments').value='[{\"type\":\"number\",\"value\":\"42\"}]'");
- await click('compiler-run');await wait(compilerStatus('Finished'));
- assert.equal(await evaluate("document.getElementById('compiler-result').textContent"),'Returned number: "42"');
- assert.equal(await evaluate("document.getElementById('compiler-run').disabled"),false);
+ await evaluate("document.getElementById('arguments').value='invalid JSON'");await click('run');
+ assert.equal(await evaluate("document.getElementById('status').textContent"),'Compiled');
+ await evaluate("document.getElementById('arguments').value='[{\"type\":\"number\",\"value\":\"42\"}]'");
+ await click('run');await wait(compilerStatus('Finished'));
+ assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"');
+ assert.equal(await evaluate("document.getElementById('run').disabled"),false);
const compiledRequests=requests.filter(path=>path.endsWith('/compiler.json')).length;
for(const value of ['7','99']){
- await evaluate(`document.getElementById('compiler-arguments').value='[{"type":"number","value":"${value}"}]'`);
- await click('compiler-run');await wait(compilerStatus('Finished'));
- assert.equal(await evaluate("document.getElementById('compiler-result').textContent"),`Returned number: "${value}"`);
- assert.equal(await evaluate("document.getElementById('compiler-run').disabled"),false);
+ await evaluate(`document.getElementById('arguments').value='[{"type":"number","value":"${value}"}]'`);
+ await click('run');await wait(compilerStatus('Finished'));
+ assert.equal(await evaluate("document.getElementById('result').textContent"),`Returned number: "${value}"`);
+ assert.equal(await evaluate("document.getElementById('run').disabled"),false);
}
assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,compiledRequests);
- await evaluate("document.getElementById('compiler-arguments').value='[{\"type\":\"number\",\"value\":\"42\"}]'");
- assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/execution limits disabled/);
+ await evaluate("document.getElementById('arguments').value='[{\"type\":\"number\",\"value\":\"42\"}]'");
+ assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/browser limits; execution guards off/);
await evaluate("document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))");
console.log('PASS uncapped compiler UI / corrected arguments / repeated values without recompile / profile disposal');
- assert.equal(await evaluate("document.getElementById('compiler-source').maxLength"),32768);
- await evaluate("document.getElementById('compiler-source').value='--'+ 'x'.repeat(300)+'\\nid = (x : x)';document.getElementById('compiler-source').dispatchEvent(new Event('input'));document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))");
- await click('compiler-compile');await wait(compilerStatus('Compiled'),60);
- await click('compiler-run');await wait(compilerStatus('Finished'));
- assert.equal(await evaluate("document.getElementById('compiler-result').textContent"),'Returned number: "42"');
+ assert.equal(await evaluate("document.getElementById('source-editor').maxLength"),32768);
+ await evaluate("document.getElementById('source-editor').value='--'+ 'x'.repeat(300)+'\\nid = (x : x)';document.getElementById('source-editor').dispatchEvent(new Event('input'));document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))");
+ await click('compile');await wait(compilerStatus('Compiled'),60);
+ await click('run');await wait(compilerStatus('Finished'));
+ assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"');
await evaluate("document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))");
console.log('PASS source beyond former 256 limit / no main-thread graph encoding cap');
for(const example of ['pair','definitions']){
await evaluate(`document.querySelector('[data-compiler-example=${example}]').click()`);
- await click('compiler-compile');await wait(compilerStatus('Compiled'));
- assert.equal(await evaluate("document.getElementById('compiler-export').value"),example==='pair'?'pair':'answer');
- if(example==='definitions')assert.equal(await evaluate("document.getElementById('compiler-export').options.length"),2);
- await click('compiler-run');await wait(compilerStatus('Finished'));
- if(example==='definitions')assert.equal(await evaluate("document.getElementById('compiler-result').textContent"),'Returned number: "42"');
- else assert.match(await evaluate("document.getElementById('compiler-result').textContent"),/Returned tree/);
+ await click('compile');await wait(compilerStatus('Compiled'));
+ assert.equal(await evaluate("document.getElementById('export').value"),example==='pair'?'pair':'answer');
+ if(example==='definitions')assert.equal(await evaluate("document.getElementById('export').options.length"),2);
+ await click('run');await wait(compilerStatus('Finished'));
+ if(example==='definitions')assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"');
+ else assert.match(await evaluate("document.getElementById('result').textContent"),/Returned tree/);
}
console.log('PASS richer compiler examples / definition dependencies / selected compiled exports');
await evaluate("document.querySelector('[data-compiler-example=contracts]').click()");
- await click('compiler-compile');await wait(compilerStatus('Compiled'));await click('compiler-run');await wait(compilerStatus('Finished'));
- assert.equal(await evaluate("document.getElementById('compiler-result').textContent"),'Returned number: "42"');
+ await click('compile');await wait(compilerStatus('Compiled'));await click('run');await wait(compilerStatus('Finished'));
+ assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"');
+ await evaluate("document.getElementById('compiler-limit-fuel').value='80000000';document.querySelector('#compiler-settings .limits-editor').requestSubmit()");
+ await click('compile');await wait(compilerStatus('Compiled'));
let previousTotal=0;
for(const value of ['12','13']){
- await evaluate(`document.getElementById('compiler-arguments').value='[{"type":"number","value":"${value}"}]'`);
- await click('compiler-run');await wait(compilerStatus('Finished'));
+ await evaluate(`document.getElementById('arguments').value='[{"type":"number","value":"${value}"}]'`);
+ await click('run');await wait(compilerStatus('Finished'));
const stats=await evaluate("document.getElementById('compiler-stats').textContent");
const total=Number(stats.match(/([0-9,]+) cumulative work/)[1].replaceAll(',',''));
const remaining=Number(stats.match(/([0-9,]+) run work remaining/)[1].replaceAll(',',''));
assert.ok(total>previousTotal);previousTotal=total;
assert.ok(remaining>79_000_000&&remaining<=80_000_000,'run has its own work budget, excluding compilation and prior runs');
- assert.equal(await evaluate("document.getElementById('compiler-result').textContent"),`Returned number: "${value}"`);
+ assert.equal(await evaluate("document.getElementById('result').textContent"),`Returned number: "${value}"`);
}
console.log('PASS separate compile/run work allowances / cumulative counters');
- await evaluate("document.getElementById('compiler-arguments').value='[{\"type\":\"tree\",\"value\":\"10\"}]';document.getElementById('compiler-output').value='tree'");
- await click('compiler-run');await wait(compilerStatus('Finished'));
- assert.match(await evaluate("document.getElementById('compiler-graph').textContent"),/fork/);
- await evaluate("document.getElementById('compiler-source').value='id =@nat? (x : x)';document.getElementById('compiler-source').dispatchEvent(new Event('input'));document.getElementById('compiler-arguments').value='[]'");
- await click('compiler-compile');await wait(compilerStatus('Compiled'));await click('compiler-run');await wait(compilerStatus('Finished'));
- assert.match(await evaluate("document.getElementById('compiler-graph').textContent"),/fork/);
- const rejectedFunction=JSON.parse(await evaluate("document.getElementById('compiler-graph').textContent"));
+ await evaluate("document.getElementById('arguments').value='[{\"type\":\"tree\",\"value\":\"10\"}]';document.getElementById('output-type').value='tree'");
+ await click('run');await wait(compilerStatus('Finished'));
+ assert.match(await evaluate("document.getElementById('graph-result').textContent"),/fork/);
+ await evaluate("document.getElementById('source-editor').value='id =@nat? (x : x)';document.getElementById('source-editor').dispatchEvent(new Event('input'));document.getElementById('arguments').value='[]'");
+ await click('compile');await wait(compilerStatus('Compiled'));await click('run');await wait(compilerStatus('Finished'));
+ assert.match(await evaluate("document.getElementById('graph-result').textContent"),/fork/);
+ const rejectedFunction=JSON.parse(await evaluate("document.getElementById('graph-result').textContent"));
assert.equal(rejectedFunction.nodes.find(n=>n.id===rejectedFunction.root).children[0],0,'function tree must fail the canonical-natural contract');
console.log('PASS default bare contract sugar / checked identity / error Result / rejected function-value annotation');
- await evaluate(`document.getElementById('compiler-source').value='id = (x : x)\\nanswer = id 42\\nother = id 7';document.getElementById('compiler-source').dispatchEvent(new Event('input'));document.getElementById('compiler-arguments').value='[]';document.getElementById('compiler-output').value='number'`);
- await click('compiler-compile');await wait(compilerStatus('Compiled'));
- await evaluate("document.getElementById('compiler-export').value='other'");await click('compiler-run');await wait(compilerStatus('Finished'));
- assert.equal(await evaluate("document.getElementById('compiler-result').textContent"),'Returned number: "7"');
+ await evaluate(`document.getElementById('source-editor').value='id = (x : x)\\nanswer = id 42\\nother = id 7';document.getElementById('source-editor').dispatchEvent(new Event('input'));document.getElementById('arguments').value='[]';document.getElementById('output-type').value='number'`);
+ await click('compile');await wait(compilerStatus('Compiled'));
+ await evaluate("document.getElementById('export').value='other'");await click('run');await wait(compilerStatus('Finished'));
+ assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "7"');
const allocationText=await evaluate("document.getElementById('compiler-stats').textContent");
const allocated=allocationText.match(/([0-9,]+) allocated/);
assert.ok(allocated);assert.ok(Number(allocated[1].replaceAll(',',''))>6_000_000);
- await evaluate("document.getElementById('compiler-export').value='answer'");await click('compiler-run');await wait(compilerStatus('Finished'));
- assert.equal(await evaluate("document.getElementById('compiler-result').textContent"),'Returned number: "42"');
- await evaluate("document.getElementById('compiler-export').value='id';document.getElementById('compiler-arguments').value='[{\"type\":\"number\",\"value\":\"13\"}]'");await click('compiler-run');await wait(compilerStatus('Finished'));
- assert.equal(await evaluate("document.getElementById('compiler-result').textContent"),'Returned number: "13"');
+ await evaluate("document.getElementById('export').value='answer'");await click('run');await wait(compilerStatus('Finished'));
+ assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"');
+ await evaluate("document.getElementById('export').value='id';document.getElementById('arguments').value='[{\"type\":\"number\",\"value\":\"13\"}]'");await click('run');await wait(compilerStatus('Finished'));
+ assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "13"');
console.log('PASS GC compiles/runs beyond the old 6m cumulative-allocation limit');
await evaluate("document.querySelector('[data-compiler-example=input]').click()");
- await click('compiler-compile');await wait(compilerStatus('Compiled'));
- await evaluate("document.getElementById('compiler-step').checked=true");await click('compiler-run');
- await wait(compilerStatus('Paused before action'));await click('compiler-next');await wait(compilerStatus('Waiting for input'));
- await evaluate("document.getElementById('compiler-line').value='λ雪🙂';document.getElementById('compiler-input-form').requestSubmit()");
+ await click('compile');await wait(compilerStatus('Compiled'));
+ await evaluate("document.getElementById('step-mode').checked=true");await click('run');
+ await wait(compilerStatus('Paused before action'));await click('next');await wait(compilerStatus('Waiting for input'));
+ await evaluate("document.getElementById('line-input').value='λ雪🙂';document.getElementById('input-form').requestSubmit()");
await wait(compilerStatus('Finished'));
- assert.equal(await evaluate("document.getElementById('compiler-result').textContent"),'Returned string: "λ雪🙂"');
- await evaluate("document.getElementById('compiler-step').checked=false");
+ assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned string: "λ雪🙂"');
+ await evaluate("document.getElementById('step-mode').checked=false");
for(const value of ['second','third']){
- await click('compiler-run');await wait(compilerStatus('Waiting for input'));
- await evaluate(`document.getElementById('compiler-line').value='${value}';document.getElementById('compiler-input-form').requestSubmit()`);
+ await click('run');await wait(compilerStatus('Waiting for input'));
+ await evaluate(`document.getElementById('line-input').value='${value}';document.getElementById('input-form').requestSubmit()`);
await wait(compilerStatus('Finished'));
- assert.equal(await evaluate("document.getElementById('compiler-result').textContent"),`Returned string: "${value}"`);
+ assert.equal(await evaluate("document.getElementById('result').textContent"),`Returned string: "${value}"`);
}
console.log('PASS repeated compiled IO / fresh input / stepping / retained definition roots');
- await evaluate("document.getElementById('compiler-source').value='id = (';document.getElementById('compiler-source').dispatchEvent(new Event('input'))");
- await click('compiler-compile');await wait(compilerStatus('Stopped'));
- assert.match(await evaluate("document.getElementById('compiler-result').textContent"),/Compiler diagnostic 2.*expected token kind 7/);
- assert.equal(await evaluate("document.getElementById('compiler-run').disabled"),true);
- await evaluate("document.getElementById('compiler-source').value='id = nope'");
- await click('compiler-compile');await wait(compilerStatus('Stopped'));
- assert.match(await evaluate("document.getElementById('compiler-result').textContent"),/Compiler diagnostic 20 at 1:6.*unresolved name.*nope/);
+ await evaluate("document.getElementById('source-editor').value='id = (';document.getElementById('source-editor').dispatchEvent(new Event('input'))");
+ await click('compile');await wait(compilerStatus('Stopped'));
+ assert.match(await evaluate("document.getElementById('result').textContent"),/Compiler diagnostic 2.*expected token kind 7/);
+ assert.equal(await evaluate("document.getElementById('export').disabled"),true);
+ await evaluate("document.getElementById('source-editor').value='id = nope'");
+ await click('compile');await wait(compilerStatus('Stopped'));
+ assert.match(await evaluate("document.getElementById('result').textContent"),/Compiler diagnostic 20 at 1:6.*unresolved name.*nope/);
for(const [fault,expected] of [['descriptor',/stable compiler descriptor/],['pin',/SHA-256 mismatch/],['oversize',/download limit/]]){
compilerFault=fault;await evaluate("document.querySelector('[data-compiler-example=identity]').click()");
- await click('compiler-compile');await wait(compilerStatus('Stopped'));
- assert.match(await evaluate("document.getElementById('compiler-result').textContent"),expected);
- assert.equal(await evaluate("document.getElementById('compiler-run').disabled"),true);
+ await click('compile');await wait(compilerStatus('Stopped'));
+ assert.match(await evaluate("document.getElementById('result').textContent"),expected);
+ assert.equal(await evaluate("document.getElementById('export').disabled"),true);
+ assert.match(await evaluate("document.getElementById('run').textContent"),/Compile & run/);
}
compilerFault=undefined;
// Source bound is enforced before the compiler bundle is downloaded again.
const before=requests.filter(path=>path.endsWith('/compiler.json')).length;
- await evaluate("document.getElementById('compiler-source').value='x'.repeat(16385)");await click('compiler-compile');
- assert.match(await evaluate("document.getElementById('compiler-result').textContent"),/limit/);
+ await evaluate("document.getElementById('source-editor').value='x'.repeat(16385)");await click('compile');
+ assert.match(await evaluate("document.getElementById('result').textContent"),/limit/);
assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,before);
await evaluate("document.querySelector('[data-compiler-example=identity]').click()");
- await click('compiler-compile');await wait(compilerStatus('Compiled'));
+ await click('compile');await wait(compilerStatus('Compiled'));
await evaluate("document.querySelector('[data-demo=microscope]').click()");await wait(status('Ready'));
- assert.equal(await evaluate("document.getElementById('compiler-run').disabled"),true);
- await click('compiler-compile');await click('compiler-stop');await wait(compilerStatus('Stopped'));
+ assert.equal(await evaluate("document.getElementById('authorship').textContent.startsWith('Published')"),true);
+ await evaluate("document.querySelector('[data-compiler-example=identity]').click()");
+ await click('compile');await click('stop');await wait(compilerStatus('Stopped'));
await new Promise(resolve=>setTimeout(resolve,500));
- assert.equal(await evaluate("document.getElementById('compiler-status').textContent"),'Stopped');
- await click('compiler-reset');
- assert.equal(await evaluate("document.getElementById('compiler-compile').disabled"),false);
+ assert.equal(await evaluate("document.getElementById('status').textContent"),'Stopped');
+ await click('reset');
+ assert.equal(await evaluate("document.getElementById('compile').disabled"),false);
console.log('PASS compiler syntax/name diagnostics / descriptor and pin integrity / source cap / replacement / stop/reset/profile toggle');
// A genuinely CPU-bound Worker gets a finite active timeout. No production hook.
@@ -613,21 +680,22 @@ try {
terminate(){this.spin?.terminate();super.terminate();globalThis.compilerWorkerTerminated=true;}
};`});
await send('Page.reload');await openPlayground();
+ await evaluate("document.querySelector('[data-compiler-example=identity]').click()");
await evaluate("document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))");
- await click('compiler-compile');
+ await click('compile');
await new Promise(resolve=>setTimeout(resolve,31_000));
- assert.equal(await evaluate("document.getElementById('compiler-status').textContent"),'Compiling');
- await click('compiler-stop');await wait(compilerStatus('Stopped'));
+ assert.equal(await evaluate("document.getElementById('status').textContent"),'Compiling');
+ await click('stop');await wait(compilerStatus('Stopped'));
assert.equal(await evaluate('globalThis.compilerWorkerTerminated'),true);
console.log('PASS no compiler timeout past 30 seconds / manual CPU-bound cancellation');
- await evaluate('globalThis.compilerWorkerTerminated=false');await click('compiler-compile');await click('tab-how');
- assert.equal(await evaluate("document.getElementById('compiler-status').textContent"),'Stopped');
+ await evaluate('globalThis.compilerWorkerTerminated=false');await click('compile');await click('tab-how');
+ assert.equal(await evaluate("document.getElementById('status').textContent"),'Stopped');
assert.equal(await evaluate('globalThis.compilerWorkerTerminated'),true);
console.log('PASS leaving compiler pane terminates active compilation');
- await evaluate("document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'));document.getElementById('compiler-limit-seconds').value='1';document.querySelector('#compiler .limits-editor').requestSubmit()");
- await click('compiler-compile');await wait(compilerStatus('Stopped'),3);
- assert.match(await evaluate("document.getElementById('compiler-result').textContent"),/time limit exceeded/);
- await evaluate("document.querySelector('#compiler .limits-editor button[type=button]').click()");
+ await evaluate("document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'));document.getElementById('compiler-limit-seconds').value='1';document.querySelector('#compiler-settings .limits-editor').requestSubmit()");
+ await click('compile');await wait(compilerStatus('Stopped'),3);
+ assert.match(await evaluate("document.getElementById('result').textContent"),/time limit exceeded/);
+ await evaluate("document.querySelector('#compiler-settings .limits-editor button[type=button]').click()");
assert.equal(await evaluate("document.getElementById('compiler-limit-seconds').value"),'0');
console.log('PASS editable compiler active-second deadline / restore unlimited default');
@@ -640,7 +708,7 @@ try {
await wait("document.getElementById('tab-playground')?.onclick !== null");
await send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true });
assert.equal(await evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true);
- for(const id of ['tab-playground','tab-compiler','tab-export-definition','tab-how']) {
+ for(const id of ['tab-playground','tab-export-definition','tab-how']) {
await click(id);
await assertPaneFits();
assert.equal(await evaluate('document.documentElement.scrollWidth <= window.innerWidth'),true);
@@ -659,13 +727,13 @@ try {
center:cta.x+cta.width/2-(hero.x+hero.width/2),
leafOffset:(circles[0][0]-bounds[0].y)/bounds[0].height,
circles,bounds:bounds.map(b=>({x:b.x,right:b.right})),nav:nav.map(b=>({x:b.x,right:b.right,height:b.height})),
- size:parseFloat(getComputedStyle(document.getElementById('compiler-source')).fontSize),
+ size:parseFloat(getComputedStyle(document.getElementById('source-editor')).fontSize),
shapePosition:getComputedStyle(document.querySelector('.shapes')).position};
})()`);
assert.equal(layout.overflow,false,`overflow at ${width}px`);
await click('tab-playground');
if(width>740)await assertSourceFill();
- else assert.ok(await evaluate("document.querySelector('#playground .source-panel pre').getBoundingClientRect().height<=280"),'stacked mobile source stays bounded');
+ else assert.ok(await evaluate("document.getElementById('source-editor').getBoundingClientRect().height<=360"),'stacked mobile source stays bounded');
assert.ok(Math.abs(layout.leafOffset-43.5/80)<0.01,`universal Leaf offset at ${width}px`);
for(const b of [...layout.bounds,...layout.nav]) assert.ok(b.x>=0&&b.right<=width,`offscreen element at ${width}px`);
if(width<950) {
@@ -679,7 +747,7 @@ try {
}
if(width<=740)assert.ok(layout.size>=16,'mobile inputs stay legible');
if(width<=540)for(const item of layout.nav)assert.ok(item.height>=44,'mobile navigation tap target');
- for(const id of ['tab-playground','tab-compiler','tab-export-definition','tab-how']) {
+ for(const id of ['tab-playground','tab-export-definition','tab-how']) {
await click(id);
assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,`${id} at ${width}px`);
}
@@ -688,7 +756,7 @@ try {
await click('tab-how');
console.log('PASS responsive 320-1440px / Leaf midpoint / centered mobile CTA / header targets / legible inputs / unchanged desktop arrangement');
await send('Emulation.setDeviceMetricsOverride',{width:320,height:844,deviceScaleFactor:1,mobile:true});
- for(const id of ['tab-playground','tab-compiler']) {
+ for(const id of ['tab-playground']) {
await click(id);const before=await paneHeight();
await evaluate("document.querySelector('.workspace-panel.is-active .execution-limits').open=true");
assert.ok((await paneHeight())>before);await assertPaneFits();
@@ -706,8 +774,8 @@ try {
if (process.argv[3]) {
const screenshot = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
await writeFile(process.argv[3], Buffer.from(screenshot.data, 'base64'));
- await click('tab-compiler');await evaluate("document.querySelector('#compiler .execution-limits').open=true");
- const clip=await evaluate("(()=>{const r=document.getElementById('compiler').getBoundingClientRect();return {x:r.x+scrollX,y:r.y+scrollY,width:r.width,height:r.height,scale:1};})()");
+ await click('tab-playground');await evaluate("document.querySelector('#compiler-settings').open=true");
+ const clip=await evaluate("(()=>{const r=document.getElementById('playground').getBoundingClientRect();return {x:r.x+scrollX,y:r.y+scrollY,width:r.width,height:r.height,scale:1};})()");
const panel=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip});
await writeFile(process.argv[3].replace(/\.png$/,'')+'-compiler.png',Buffer.from(panel.data,'base64'));
await click('tab-export-definition'); await wait(status('Ready'));
@@ -720,6 +788,57 @@ try {
await writeFile(process.argv[3].replace(/\.png$/,'')+'-export-'+width+'.png',Buffer.from(shot.data,'base64'));
}
}
+ await click('tab-playground');
+ await evaluate("document.querySelector('[data-compiler-example=identity]').click();document.getElementById('source-editor').value='id = (x : x)\\n-- recovery test';document.getElementById('source-editor').dispatchEvent(new Event('input'));document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))");
+ await click('compile');await wait(compilerStatus('Compiled'));
+ const savedSource=await evaluate("JSON.parse(localStorage.getItem('arboricx.local-draft.v1')).source");
+ async function reloadForRecovery(){
+ const origin=await evaluate('performance.timeOrigin');
+ await send('Page.reload');
+ await wait(`performance.timeOrigin!==${origin} && document.querySelector('#package-settings .limits-editor')!==null && document.getElementById('recover-draft')?.hidden===false`);
+ }
+
+ const compilerRequests=requests.filter(path=>path.endsWith('/compiler.json')).length;
+ await reloadForRecovery();
+ await click('tab-playground');await wait(status('Ready'));
+ assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,compilerRequests,'reload does not compile');
+ assert.equal(await evaluate("document.getElementById('compiler-uncapped').checked"),false);
+ await click('recover-draft');
+ assert.equal(await evaluate("document.getElementById('source-editor').value"),savedSource);
+ assert.equal(await evaluate("document.getElementById('status').textContent"),'Recovered draft: not compiled');
+ assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,compilerRequests,'recovery does not compile');
+ await click('compile');await wait(compilerStatus('Compiled'));
+ assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/memory guard active/);
+ // Failed storage is visible and compilation requires a separate explicit override.
+ await evaluate("window._setItem=Storage.prototype.setItem;Storage.prototype.setItem=()=>{throw new DOMException('blocked','QuotaExceededError')};window.confirm=()=>false;document.getElementById('source-editor').value+='\\n-- not saved';document.getElementById('source-editor').dispatchEvent(new Event('input'))");
+ const beforeFailedSave=requests.filter(path=>path.endsWith('/compiler.json')).length;
+ await click('compile');
+ assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,beforeFailedSave);
+ assert.match(await evaluate("document.getElementById('draft-save-status').textContent"),/Draft not saved/);
+ await evaluate("window.confirm=()=>true");await click('compile');await wait(compilerStatus('Compiled'));
+ assert.equal(await evaluate("JSON.parse(localStorage.getItem('arboricx.local-draft.v1')).source"),savedSource,'failed save does not replace the previous recovery copy');
+ await evaluate("Storage.prototype.setItem=window._setItem");
+ await click('forget-draft');
+ assert.equal(await evaluate("localStorage.getItem('arboricx.local-draft.v1')"),null);
+ assert.equal(await evaluate("document.getElementById('recover-draft').hidden"),true);
+ assert.match(await evaluate("document.getElementById('source-editor').value"),/not saved/);
+ // Curated recovery must keep its environment, never become a scratch program.
+ await evaluate("document.querySelector('[data-demo=shipping]').click()");await wait(status('Ready'));
+ await evaluate("document.getElementById('source-editor').value='answer = stringField \"status\" \"recovered\"';document.getElementById('arguments').value='[]';document.getElementById('output-type').value='list';document.getElementById('source-editor').dispatchEvent(new Event('input'))");
+ assert.equal(await evaluate("JSON.parse(localStorage.getItem('arboricx.local-draft.v1')).demo.environment"),'practical');
+ await reloadForRecovery();
+ await click('tab-playground');await wait(status('Ready'));
+ await click('recover-draft');await click('run');await wait(status('Finished'));
+ assert.match(await evaluate("document.getElementById('result').textContent"),/^Returned list:/);
+ await evaluate("const d=JSON.parse(localStorage.getItem('arboricx.local-draft.v1'));d.demo.hash='0'.repeat(64);localStorage.setItem('arboricx.local-draft.v1',JSON.stringify(d))");
+ const beforeMissingContext=requests.filter(path=>path.endsWith('/compiler.json')).length;
+ await click('recover-draft');
+ assert.match(await evaluate("document.getElementById('draft-save-status').textContent"),/no longer in this catalogue/);
+ assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,beforeMissingContext);
+ await click('forget-draft');
+ console.log('PASS browser-limits consent / saved source before compile / reload recovery in Standard / no auto-execution / storage failure / curated environment / missing-context rejection / forget');
+ assert.deepEqual(exceptions, []);
+
} finally {
for (const waiter of pending.values()) { clearTimeout(waiter.timer); waiter.reject(new Error('Browser test closed')); }
pending.clear(); socket?.close();
diff --git a/ext/demo-site/test/compiler.test.mjs b/ext/demo-site/test/compiler.test.mjs
@@ -12,7 +12,9 @@ test('compiler shipped pin matches authenticated reference and Haskell golden pr
assert.equal(createHash('sha256').update(shipped).digest('hex'),descriptor.hash);
assert.equal(goldens.compilerBundleSha256,descriptor.hash);
assert.match(descriptor.referenceCommit,/^[0-9a-f]{40}$/);
- assert.deepEqual(shipped,await readFile(new URL('../../../test/fixtures/compiler-library.arboricx',import.meta.url)));
+ // The site's accepted compiler is intentionally pinned independently of HEAD.
+ // Authenticate it against its retained publication metadata below, not the
+ // current compiler-library fixture. Never ask assert to diff megabyte buffers.
const metadata=(await readFile(new URL('../compiler-reference.v1',import.meta.url),'utf8')).trim().split('\n');
assert.equal(metadata[0],'tricu.compiler-library-fixture.v1');
const fixtureDigest=createHash('sha256').update('tricu.compiler-library-fixture.v1\0').update(shipped).digest('hex');
diff --git a/ext/demo-site/test/draft-storage.test.mjs b/ext/demo-site/test/draft-storage.test.mjs
@@ -0,0 +1,21 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {DRAFT_KEY,savedDraft,readDraft,writeDraft} from '../dist/draft-storage.js';
+const draft={schema:1,source:'invalid unfinished source is still saved',name:'id',arguments:'[]',output:'tree',mode:'pure'};
+function storage(){const values=new Map();return {getItem:k=>values.get(k)??null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)};}
+test('recovery retains source and exact demo context but no risky mode or executable state',()=>{
+ const s=storage();assert.equal(readDraft(s),undefined);
+ const demo={hash:'a'.repeat(64),environment:'practical'};
+ writeDraft(s,{...draft,demo,uncapped:true,compiled:true});
+ assert.deepEqual(readDraft(s),{...draft,demo});
+ s.removeItem(DRAFT_KEY);assert.equal(readDraft(s),undefined);
+});
+test('recovery rejects malformed, oversized or unavailable storage without silent success',()=>{
+ for(const value of [null,[],{}, {...draft,schema:2},{...draft,source:'x'.repeat(32769)},{...draft,demo:{hash:'bad',environment:'empty'}}])assert.throws(()=>savedDraft(value));
+ const s=storage();s.setItem(DRAFT_KEY,'{');assert.throws(()=>readDraft(s));
+ s.setItem(DRAFT_KEY,' '.repeat(100001));assert.throws(()=>readDraft(s),/large/);
+ assert.throws(()=>writeDraft(s,{...draft,source:'\0'.repeat(32768)}),/large/);
+ assert.throws(()=>writeDraft({setItem(){throw new Error('quota');}},draft),/quota/);
+ assert.throws(()=>writeDraft({setItem(){},getItem(){return null;}},draft),/verification/);
+ assert.throws(()=>readDraft({getItem(){throw new Error('denied');}}),/denied/);
+});
diff --git a/ext/demo-site/test/editable-conformance.mjs b/ext/demo-site/test/editable-conformance.mjs
@@ -0,0 +1,47 @@
+// Opt-in real demo compilation against pinned prebaked environments. No host
+// recompilation; expected results are retained independent Haskell captures.
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {createHash} from 'node:crypto';
+import {Graph} from '../dist/graph.js';
+import {decodeBundle,loadBundle,COMPILER_BUNDLE_LIMIT} from '../dist/bundle.js';
+import {compiledDefinitions,sourceLiteral,COMPILER_EXPORT} from '../dist/compiler.js';
+import {environmentProfile} from '../dist/packages.js';
+import {list,text} from '../dist/codecs.js';
+import {runIO} from '../dist/io.js';
+const root=new URL('../',import.meta.url);
+const catalogue=JSON.parse(await readFile(new URL('catalogue.json',root)));
+const environments=JSON.parse(await readFile(new URL('environments.json',root)));
+const goldens=JSON.parse(await readFile(new URL('demos.json',root)));
+async function bundle(hash,limit){
+ const bytes=await readFile(new URL('bundles/'+hash+'.arboricx',root));
+ assert.equal(createHash('sha256').update(bytes).digest('hex'),hash);
+ return decodeBundle(bytes,limit);
+}
+const compiler=await bundle(environments.compiler,COMPILER_BUNDLE_LIMIT);
+for(const entry of catalogue.filter(e=>!process.argv[2]||e.id===process.argv[2])){
+ const started=performance.now();
+ const g=new Graph({nodes:6_000_000,fuel:Infinity,stack:100_000},{threshold:250_000});
+ const library=loadBundle(g,compiler), {profile}=environmentProfile(environments,entry.environment);
+ const bindings=[];
+ if(profile){
+ const support=loadBundle(g,await bundle(profile.hash));
+ for(const name of profile.names){assert.ok(support.has(name));bindings.push(g.fork(text(g,name),support.get(name)));}
+ }
+ const definitions=compiledDefinitions(g,g.apply(library.get(COMPILER_EXPORT),[list(g,bindings),sourceLiteral(g,entry.source+'\n-- Edited locally\n')]));
+ g.collect(definitions.values());
+ console.log(entry.id,'compile ms',Math.round(performance.now()-started),'stats',JSON.stringify(g.stats));
+ // Exactly apply the default published export using the first Haskell capture
+ // for that export; evaluating the draft must reproduce the independent tree.
+ const expected=goldens.demos[entry.id].haskellCases.find(c=>c.export===entry.export);
+ assert.ok(expected);
+ g.beginOperation();
+ let result=g.apply(definitions.get(entry.export),expected.arguments.map(a=>g.fromTernary(a)));
+ let terminal='';
+ if(entry.mode==='io')result=await runIO(g,result,{before:async()=>{},after(){},running(){},write(s){terminal+=s;},read:async()=>expected.input},true,definitions.values());
+ const actual=g.toTernary(result);
+ // Compare small digests rather than permitting gigantic diagnostic diffs.
+ const sha=s=>createHash('sha256').update(s).digest('hex');
+ assert.equal(sha(actual),sha(expected.result),entry.id+' result');assert.equal(terminal,expected.terminal);
+ console.log('PASS',entry.id,'draft equals independent Haskell result');
+}
diff --git a/ext/demo-site/test/execution.test.mjs b/ext/demo-site/test/execution.test.mjs
@@ -19,12 +19,14 @@ test('editable limits preserve defaults, validated snapshots and adaptive GC',()
assert.equal(collectionPolicy({nodes:1}),undefined);
const unlimited=executionSettings(settings.limits,'compiler',true);
assert.equal(activeMilliseconds(unlimited),Infinity);assert.equal(executionBudget(unlimited).fuel,Infinity);
+ assert.equal(executionBudget(unlimited).nodes,Infinity);assert.equal(executionBudget(unlimited).stack,Infinity);
+ assert.equal(executionBudget(executionSettings({...settings.limits,fuel:0},'compiler')).fuel,Infinity);
assert.equal(activeMilliseconds(executionSettings({...settings.limits,seconds:0},'package')),Infinity);
});
test('malformed custom limits cannot become unlimited or fall back silently',()=>{
const defaults=defaultLimits('package');
- for(const key of ['nodes','fuel','stack'])for(const value of [0,-1,1.5,NaN,Infinity,'500',true,Number.MAX_SAFE_INTEGER+1])
+ for(const key of ['nodes','fuel','stack'])for(const value of [...(key==='fuel'?[]:[0]),-1,1.5,NaN,Infinity,'500',true,Number.MAX_SAFE_INTEGER+1])
assert.throws(()=>executionSettings({...defaults,[key]:value},'package'),new RegExp(key));
for(const seconds of [-1,0.5,Infinity,Number.MAX_SAFE_INTEGER])
assert.throws(()=>executionSettings({...defaults,seconds},'compiler'),/seconds/);
diff --git a/ext/demo-site/test/packages.test.mjs b/ext/demo-site/test/packages.test.mjs
@@ -0,0 +1,66 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {createHash} from 'node:crypto';
+import {packageIndex,environmentProfile} from '../dist/packages.js';
+import {decodeBundle,loadBundle} from '../dist/bundle.js';
+import {Graph} from '../dist/graph.js';
+import {executionSettings,executionBudget} from '../dist/execution.js';
+const read=name=>readFile(new URL('../'+name,import.meta.url));
+
+test('named versions and demo environments are validated and pinned',async()=>{
+ const releases=packageIndex(JSON.parse(await read('packages.json')));
+ const catalogue=JSON.parse(await read('catalogue.json'));
+ const environments=JSON.parse(await read('environments.json'));
+ const compiler=JSON.parse(await read('compiler.json'));
+ assert.equal(environments.compiler,compiler.hash);
+ for(const entry of catalogue){
+ const release=releases.find(r=>r.demo===entry.id&&r.hash===entry.hash);assert.ok(release);
+ const {profile}=environmentProfile(environments,entry.environment);
+ if(profile){
+ const bytes=await read('bundles/'+profile.hash+'.arboricx');
+ assert.equal(createHash('sha256').update(bytes).digest('hex'),profile.hash);
+ const values=loadBundle(new Graph(),decodeBundle(bytes));
+ for(const name of profile.names)assert.ok(values.has(name),name);
+ }
+ }
+});
+test('index and environment boundaries reject malformed paths, pins, duplicates and profiles',()=>{
+ const r={name:'demo',version:'0.1',title:'A demo',hash:'a'.repeat(64),manifest:'b'.repeat(64)};
+ assert.equal(packageIndex({schema:1,releases:[r]})[0].name,'demo');
+ for(const change of [{name:'../x'},{version:'x/y'},{hash:'A'.repeat(64)},{manifest:'x'},{title:[]},{demo:'x/y'}])assert.throws(()=>packageIndex({schema:1,releases:[{...r,...change}]}));
+ assert.throws(()=>packageIndex({schema:1,releases:[r,r]}),/Duplicate/);
+ assert.throws(()=>packageIndex({schema:2,releases:[r]}));
+ const descriptor={schema:1,compiler:'a'.repeat(64),profiles:{empty:null}};
+ assert.equal(environmentProfile(descriptor,'empty').profile,null);
+ assert.throws(()=>environmentProfile(descriptor,'toString'),/Unknown/);
+ for(const names of [['bad name'],['x','x'],Array(129).fill('x'),['../x']])assert.throws(()=>environmentProfile({...descriptor,profiles:{test:{hash:r.hash,names}}},'test'));
+});
+test('Standard retains memory guards; explicit browser limits bypass execution guards but retain GC',()=>{
+ for(const raw of [undefined,{nodes:10,fuel:0,stack:20,seconds:0}]){
+ const settings=executionSettings(raw,'compiler'),budget=executionBudget(settings);
+ assert.equal(budget.fuel,Infinity);assert.ok(Number.isFinite(budget.nodes));assert.ok(Number.isFinite(budget.stack));
+ const g=new Graph({...budget,nodes:2});g.stem(g.leaf);
+ assert.throws(()=>g.stem(g.leaf),/allocation/);
+ const browser=executionBudget(executionSettings(raw,'compiler',true));
+ assert.deepEqual(browser,{nodes:Infinity,fuel:Infinity,stack:Infinity});
+ const open=new Graph(browser,{threshold:2});let root=open.leaf;
+ for(let i=0;i<100;i++){root=open.stem(root);open.collect([root]);}
+ assert.ok(open.stats.nodes>10);assert.ok(open.stats.collections>0);
+ }
+});
+
+test('published native libraries have authenticated complete interfaces and no fabricated demo source',async()=>{
+ const releases=packageIndex(JSON.parse(await read('packages.json')));
+ const expected={
+ 'tricu-base':['append','matchResult','showNumber'],
+ 'tricu-file-io':['io','bind','readFile','sha256Hex','putStr','abortIO'],
+ 'arboricx-inspection':['inspectBundle','renderSummary','describeError'],
+ };
+ for(const [name,names] of Object.entries(expected)){
+ const release=releases.find(r=>r.name===name&&r.version==='0.1.0');assert.ok(release);assert.equal(release.demo,undefined);
+ const bytes=await read('bundles/'+release.hash+'.arboricx');
+ assert.equal(createHash('sha256').update(bytes).digest('hex'),release.hash);
+ assert.deepEqual([...loadBundle(new Graph(),decodeBundle(bytes)).keys()].sort(),names.sort());
+ }
+});
diff --git a/ext/demo-site/test/release.py b/ext/demo-site/test/release.py
@@ -19,10 +19,15 @@ def main():
rows = manifest['bundles']
catalogue = json.loads((args.site / 'catalogue.json').read_text())
compiler = json.loads((args.site / 'compiler.json').read_text())
- assert {row['hash'] for row in rows} == {entry['hash'] for entry in [*catalogue, compiler]}
+ index = json.loads((args.site / 'packages.json').read_text())
+ environments = json.loads((args.site / 'environments.json').read_text())
+ count = len(rows) + len(manifest['releases'])
+ assert {row['hash'] for row in rows} == {entry['hash'] for entry in [*catalogue, compiler, *index['releases'], *[p for p in environments['profiles'].values() if p]]}
for row in rows:
expected = sorted([entry['id'] for entry in catalogue if entry['hash'] == row['hash']]
- + (['compiler'] if compiler['hash'] == row['hash'] else []))
+ + (['compiler'] if compiler['hash'] == row['hash'] else [])
+ + ['release:' + r['name'] + '@' + r['version'] for r in index['releases'] if r['hash'] == row['hash']]
+ + ['environment:' + n for n, p in environments['profiles'].items() if p and p['hash'] == row['hash']])
assert row['uses'] == expected
assert not list(args.site.rglob('*.arboricx'))
assert not (args.site / 'bundles').exists()
@@ -62,14 +67,24 @@ def main():
for child in children:
stdout, stderr = child.communicate(timeout=30)
assert child.returncode == 0, stderr
- assert module.seed(pack, concurrent) == (0, len(rows))
+ assert module.seed(pack, concurrent) == (0, count)
root = Path(temp) / 'registry'
- assert module.seed(pack, root) == (len(rows), len(rows))
+ assert module.seed(pack, root) == (count, count)
+ for release in manifest['releases']:
+ assert (root / release['path']).read_bytes() == (pack / release['path']).read_bytes()
+ # An existing different release label blocks all new installations.
+ conflict = Path(temp) / 'release-conflict'
+ record = conflict / manifest['releases'][0]['path']
+ record.parent.mkdir(parents=True)
+ record.write_bytes(b'wrong release')
+ rejected(lambda: module.seed(pack, conflict))
+ assert not list(conflict.rglob('*.arboricx'))
+ assert record.read_bytes() == b'wrong release'
object_path = root / relative
before = (object_path.stat().st_ino, object_path.stat().st_mtime_ns)
old = root / 'objects/old/retained.arboricx'
old.parent.mkdir();old.write_bytes(b'prior release retained')
- assert module.seed(pack, root) == (0, len(rows))
+ assert module.seed(pack, root) == (0, count)
assert before == (object_path.stat().st_ino, object_path.stat().st_mtime_ns)
assert old.read_bytes() == b'prior release retained'
# Exercise the installed wrapper, not just the Python function.
diff --git a/ext/demo-site/test/run-bounded.py b/ext/demo-site/test/run-bounded.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+"""Run site checks with per-process data limits, aggregate RSS guard and deadline.
+
+Use inside a Nix tooling shell. Chromium reserves a huge PROT_NONE address space,
+so bound writable data rather than virtual address reservations. RLIMIT_DATA also
+covers anonymous writable mmap on Linux. The aggregate /proc guard covers the
+whole process group, including Chromium and Workers; it is sampled, not a cgroup.
+"""
+import argparse
+import os
+from pathlib import Path
+import resource
+import signal
+import subprocess
+import time
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--seconds', type=int, default=300)
+ p.add_argument('--memory-mib', type=int, default=4096)
+ p.add_argument('command', nargs=argparse.REMAINDER)
+ args = p.parse_args()
+ command = args.command[1:] if args.command[:1] == ['--'] else args.command
+ if not command or args.seconds <= 0 or args.memory_mib <= 0:
+ p.error('positive limits and a command are required')
+ limit = args.memory_mib * 1024 * 1024
+
+ def limits():
+ resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
+ resource.setrlimit(resource.RLIMIT_DATA, (limit, limit))
+
+ child = subprocess.Popen(command, start_new_session=True, preexec_fn=limits)
+ started = time.monotonic()
+ peak = 0
+ reason = None
+ try:
+ while child.poll() is None:
+ resident = 0
+ for path in Path('/proc').glob('[0-9]*/status'):
+ try:
+ fields = dict(line.split(':', 1) for line in path.read_text().splitlines() if ':' in line)
+ # /proc can be mounted from the outer PID namespace. The
+ # last NSpgid is the ID seen by this sandbox's subprocess.
+ if int(fields.get('NSpgid', '-1').split()[-1]) == child.pid:
+ resident += int(fields.get('VmRSS', '0 kB').split()[0]) * 1024
+ except (OSError, ValueError, IndexError):
+ pass
+ peak = max(peak, resident)
+ if resident > limit:
+ reason = 'aggregate RSS guard exceeded'
+ break
+ if time.monotonic() - started > args.seconds:
+ reason = 'whole-job deadline exceeded'
+ break
+ time.sleep(.1)
+ finally:
+ try:
+ os.killpg(child.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ child.wait()
+ print(f'Bounded check: peak sampled group RSS {peak / 1024**2:.1f} MiB; {time.monotonic()-started:.1f}s; {reason or "completed"}', flush=True)
+ raise SystemExit(1 if reason else child.returncode)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs
@@ -6,7 +6,7 @@ const here=new URL('../',import.meta.url);
test('site copy uses plain quotes and focuses on the Tree Calculus ecosystem',async()=>{
const html=await readFile(new URL('index.html',here),'utf8');
assert.doesNotMatch(html,/[\u2014\u2018\u2019\u201c\u201d]/);
- for(const file of ['src/app.ts','src/compiler-app.ts','src/export-app.ts','style.css'])
+ for(const file of ['src/app.ts','src/export-app.ts','style.css'])
assert.doesNotMatch(await readFile(new URL(file,here),'utf8'),/[\u2014\u2018\u2019\u201c\u201d]/);
assert.match(html,/Arboricx and tricu are research experiments\./);
assert.match(html,/Use \[\] for/);
@@ -30,16 +30,16 @@ test('demo-site build and reproduction paths follow the directory move',async()=
test('shared pane has accessible tabs and execution settings in both runners',async()=>{
const html=await readFile(new URL('index.html',here),'utf8');
- assert.equal((html.match(/role="tab"/g)||[]).length,4);
- assert.equal((html.match(/role="tabpanel"/g)||[]).length,4);
+ assert.equal((html.match(/role="tab"/g)||[]).length,3);
+ assert.equal((html.match(/role="tabpanel"/g)||[]).length,3);
assert.match(html,/id="tab-how"[^>]*aria-selected="true"/);
for(const id of ['package','compiler'])
assert.match(html,new RegExp(`id="${id}-limits-defaults"`));
for(const id of ['uncapped','compiler-uncapped'])
assert.match(html,new RegExp(`id="${id}" type="checkbox"`));
- const {UNLIMITED_EXECUTION,PACKAGE_ACTIVE_MS}=await import('../dist/execution.js');
- const {UNCAPPED_COMPILER_BUDGET}=await import('../dist/compiler.js');
- assert.deepEqual(UNLIMITED_EXECUTION,UNCAPPED_COMPILER_BUDGET);
+ const {executionSettings,executionBudget,PACKAGE_ACTIVE_MS}=await import('../dist/execution.js');
+ const budget=executionBudget(executionSettings(undefined,'compiler',true));
+ assert.equal(budget.fuel,Infinity);assert.equal(budget.nodes,Infinity);assert.equal(budget.stack,Infinity);
assert.equal(PACKAGE_ACTIVE_MS,10000);
});
@@ -70,12 +70,12 @@ test('definition export is a peer panel with its own controls and shared package
const html=await readFile(new URL('index.html',here),'utf8');
const ids=[...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]);
assert.equal(new Set(ids).size,ids.length,'unique DOM IDs');
- for(const id of ['how','playground','compiler','export-definition']){
+ for(const id of ['how','playground','export-definition']){
assert.match(html,new RegExp(`id="tab-${id}"[^>]*aria-controls="${id}"`));
assert.match(html,new RegExp(`id="${id}"[^>]*aria-labelledby="tab-${id}"`));
}
const playground=html.slice(html.indexOf('<section id="playground"'),html.indexOf('<section id="export-definition"'));
- const panel=html.slice(html.indexOf('<section id="export-definition"'),html.indexOf('<section id="compiler"'));
+ const panel=html.slice(html.indexOf('<section id="export-definition"'),html.indexOf('<section id="how"'));
for(const id of ['tree-definition','tree-format','export-tree','stop-tree','tree-preview','copy-tree','download-tree','tree-provenance']){
assert.ok(panel.includes(`id="${id}"`),id);
assert.ok(!playground.includes(`id="${id}"`),id+' not nested in Playground');
@@ -99,3 +99,11 @@ test('hero pronunciation, ecosystem order, and desktop shape alignment',async()=
assert.match(css,/\.pronunciation\s*\{[^}]*text-transform:none/);
assert.match(css,/@media \(min-width:950px\)\s*\{\s*\.shapes\s*\{[^}]*top:70px/);
});
+
+test('unified authoring requires informed compilation and retains published identity',async()=>{
+ const html=await readFile(new URL('index.html',here),'utf8');
+ assert.doesNotMatch(html,/id="tab-compiler"/);
+ for(const id of ['source-editor','authorship','restore-source','compile-consent','confirm-compile','cancel-compile','download-bundle','package-name','package-version'])assert.match(html,new RegExp(`id="${id}"`));
+ assert.match(html,/can take minutes/);assert.match(html,/Your draft is not published/);
+ assert.match(html,/Use browser limits/);assert.match(html,/may crash or reload the tab/);
+});
diff --git a/flake.nix b/flake.nix
@@ -258,6 +258,17 @@
in {
packages.${packageName} = tricuPackage;
packages.default = tricuPackage;
+ # Independent Haskell oracle for the opt-in published-library consumer.
+ packages.inspectorOracle = pkgs.runCommand "arboricx-inspector-oracle" {
+ nativeBuildInputs = [ (haskellPackages.ghcWithPackages (p: [
+ p.cryptonite p.memory p.base16-bytestring p.vector
+ ])) ];
+ } ''
+ mkdir -p $out/bin
+ ghc -O1 -XOverloadedStrings -XScopedTypeVariables -XMultiWayIf -XLambdaCase \
+ -i${./src} -outputdir "$TMPDIR/objects" ${./test/InspectorOracle.hs} \
+ -o $out/bin/inspector-oracle
+ '';
packages.arboricxSite = siteRelease.arboricxSite;
packages.arboricxSiteDeps = siteRelease.arboricxSiteDeps;
packages.tricu-static = tricuMuslStatic;
@@ -294,6 +305,10 @@
python3 test/unit.py
python3 test/shipped_compiler.py
python3 test/executable_arguments.py ${tricuPackage}/bin/tricu
+ python3 test/package_registry.py ${tricuPackage}/bin/tricu
+ python3 test/project_dependencies.py ${tricuPackage}/bin/tricu
+ python3 test/project_inspection.py ${tricuPackage}/bin/tricu
+ python3 test/provenance.py ${tricuPackage}/bin/tricu
'';
installPhase = "mkdir -p $out";
};
diff --git a/lib/arboricx/inspect.tri b/lib/arboricx/inspect.tri
@@ -0,0 +1,65 @@
+!import prelude *
+!import native_math as N
+!import arboricx as A
+!import arboricx_common as C
+!import arboricx_manifest as M
+!import arboricx_nodes as Nodes
+!export inspectBundle
+!export renderSummary
+!export describeError
+
+-- Validate the complete in-memory bundle without reconstructing or applying
+-- exported trees. Result success is (stored-node-count, export-records).
+-- Each export record is (UTF-8 name bytes, (u32 root bytes, (kind bytes, ABI bytes))).
+-- The decoder's remainder is an internal section cursor, NOT unconsumed input;
+-- success deliberately returns an empty remainder. Failure remainders retain
+-- opaque diagnostic context, not an incremental parsing continuation.
+inspectBundle bytes = (bindResult (A.readArboricxDecoded bytes)
+ (decoded _ : ok
+ (pair (C.u64BEBytesToNat (Nodes.nodesSectionCount (snd decoded)))
+ (M.manifestExports (fst (fst decoded)))) t))
+
+hexDigit n = (lazyBool (_ : N.add 48 n) (_ : N.add 55 n) (N.lt n 10))
+
+-- Byte-wise percent escaping makes TSV fields unambiguous and terminal-safe.
+-- Only printable ASCII other than '%' passes through, including for UTF-8 names.
+escapeField [] = []
+escapeField [37 | rest] = append "%25" (escapeField rest)
+escapeField [byte | rest] = (lazyBool
+ (_ : pair byte (escapeField rest))
+ (_ : let pieces = N.divMod byte 16 in
+ pair 37 (pair (hexDigit (fst pieces)) (pair (hexDigit (snd pieces)) (escapeField rest))))
+ (N.inRange byte 32 126))
+escapeField _ = []
+
+renderExports [] = ""
+renderExports [(name, (root, (kind, abi))) | rest] = (append "export\t"
+ (append (escapeField name) (append "\t" (append (showNumber (C.u32BEBytesToNat root))
+ (append "\t" (append (escapeField kind) (append "\t" (append (escapeField abi)
+ (append "\n" (renderExports rest))))))))))
+renderExports _ = ""
+
+-- Accepts a successful inspectBundle summary, not an arbitrary unvalidated tree.
+-- Count stored records, never unfold logical occurrences of shared branches.
+renderSummary summary = (append "stored-nodes\t" (append (showNumber (fst summary))
+ (append "\n" (renderExports (snd summary)))))
+
+-- Stable human-facing categories alongside the numeric codec diagnostic.
+describeError 1 = "Unexpected end of input"
+describeError 2 = "Unexpected bytes or invalid bundle magic"
+describeError 3 = "Unexpected byte"
+describeError 4 = "Missing required section"
+describeError 5 = "Unsupported bundle version or header"
+describeError 6 = "Duplicate section"
+describeError 7 = "Duplicate node record"
+describeError 8 = "Invalid node payload"
+describeError 9 = "Invalid node reference or export root"
+describeError 10 = "Invalid manifest magic"
+describeError 11 = "Unsupported manifest version"
+describeError 12 = "Trailing manifest bytes"
+describeError 13 = "Invalid manifest, export interface or metadata"
+describeError 15 = "Invalid binary input"
+describeError 16 = "Invalid binary count"
+describeError 17 = "Unsupported section"
+describeError 18 = "Invalid section range or truncated container"
+describeError _ = "Unrecognized bundle diagnostic"
diff --git a/lib/arboricx/server.tri b/lib/arboricx/server.tri
@@ -131,9 +131,54 @@ getBundleRoute root "GET" target = case (stripPrefix bundleHashPrefix target) of
getBundleRoute _ _ _ = pure notFoundResponse
+-- Read-only exact-version index. Administrative publication happens locally;
+-- this route never resolves dependencies or executes fetched code. HTTP input
+-- bytes are canonical naturals. Scan at most 128 bytes per path component.
+packagePrefix = "/_arboricx/package/"
+packageAlphaNum? byte = (or? (Nat.inRange byte 48 57)
+ (or? (Nat.inRange byte 65 90) (Nat.inRange byte 97 122)))
+packageByte? byte = (or? (packageAlphaNum? byte)
+ (or? (equal? byte 46) (or? (equal? byte 95) (equal? byte 45))))
+packageVersion [] reversed _ = just (reverse reversed)
+packageVersion _ _ 0 = nothing
+packageVersion [byte | rest] reversed remaining = case (packageByte? byte) of
+ ^true = packageVersion rest (pair byte reversed) (Nat.subtract remaining 1)
+ _ = nothing
+
+packageVersion _ _ _ = nothing
+packageName [47 | [first | rest]] reversed _ = case (packageAlphaNum? first) of
+ ^true = case (packageVersion rest [first] 127) of
+ (t version) = just (pair (reverse reversed) version)
+ _ = nothing
+ _ = nothing
+
+packageName _ _ 0 = nothing
+packageName [byte | rest] reversed remaining = case (packageByte? byte) of
+ ^true = packageName rest (pair byte reversed) (Nat.subtract remaining 1)
+ _ = nothing
+
+packageName _ _ _ = nothing
+packageTarget [first | rest] = case (packageAlphaNum? first) of
+ ^true = packageName rest [first] 127
+ _ = nothing
+
+packageTarget _ = nothing
+
+servePackage root target = case (packageTarget target) of
+ (t (name, version)) = (do bind
+ reply <- readFile (pathJoin (pathJoin (pathJoin root "packages") name) version)
+ case reply of
+ ((t t), (bytes, _)) = pure (response 200 "application/vnd.arboricx.package-release" bytes)
+ _ = pure (errorResponse 404 "Package release not found"))
+ _ = pure (badRequestResponse "Invalid package name/version")
+
+getRegistryRoute root target = case (stripPrefix packagePrefix target) of
+ (t suffix) = servePackage root suffix
+ _ = getBundleRoute root "GET" target
+
arboricxRouter _ "GET" "/" _ _ = pure (htmlResponse htmlLandingPage)
arboricxRouter _ "GET" ^healthPath _ _ = healthRoute "GET" healthPath
-arboricxRouter root "GET" target _ _ = getBundleRoute root "GET" target
+arboricxRouter root "GET" target _ _ = getRegistryRoute root target
arboricxRouter root "POST" target _ body = putBundleRoute root "POST" target body
arboricxRouter _ _ _ _ _ = pure notFoundResponse
diff --git a/lib/packages/base.tri b/lib/packages/base.tri
@@ -0,0 +1,12 @@
+!import base as B
+!import conversions as C
+!export append
+!export matchResult
+!export showNumber
+
+-- Small public foundation for Result-aware command-line consumers.
+-- matchResult receives failure first, success second; each handler gets
+-- a payload and a remainder. showNumber accepts canonical naturals.
+append = B.append
+matchResult = B.matchResult
+showNumber = C.showNumber
diff --git a/lib/packages/io.tri b/lib/packages/io.tri
@@ -0,0 +1,16 @@
+!import io as I
+!export io
+!export bind
+!export readFile
+!export sha256Hex
+!export putStr
+!export abortIO
+
+-- Construct interaction trees only. Importing this package grants no effects;
+-- the Haskell host still requires explicit application IO permissions.
+io = I.io
+bind = I.bind
+readFile = I.readFile
+sha256Hex = I.sha256Hex
+putStr = I.putStr
+abortIO = I.abortIO
diff --git a/lib/source/pattern.tri b/lib/source/pattern.tri
@@ -150,13 +150,92 @@ spMatchArguments patterns ctx body miss = (lazyList
(pattern rest : spPattern pattern (fst (spPending ctx)) (spPop ctx)
(next nextMiss : spMatchArguments rest next body nextMiss) miss) patterns)
--- The final equation is irrefutable, so the empty suffix is unreachable.
--- One native lazy continuation shares all misses to the remaining equations.
-spEquations equations ctx = (lazyList (_ : spLit t)
- (equation rest : spApp
- (spLam (spMatchArguments (fst equation)
- (spShiftContext ctx) (snd equation) (spApp (spVar 0) (spLit t))))
- (spLam (spShift (spEquations rest ctx)))) equations)
+-- Sequential fallback for arbitrary ordered patterns. A final irrefutable row
+-- cannot miss, so it needs neither a failure binder nor a delayed empty suffix.
+spSequentialEquations equations ctx = (lazyList (_ : spLit t)
+ (equation rest : lazyBool
+ (_ : spMatchArguments (fst equation) ctx (snd equation) (spLit t))
+ (_ : spApp
+ (spLam (spMatchArguments (fst equation)
+ (spShiftContext ctx) (snd equation) (spApp (spVar 0) (spLit t))))
+ (spLam (spShift (spSequentialEquations rest ctx))))
+ (emptyList? rest)) equations)
+
+-- Only factor a constructor test that the FIRST row must reach. All preceding
+-- columns must be plain captures/wildcards in EVERY row. Pins, literals and
+-- whole patterns at the split column conservatively retain sequential lowering.
+-- This is not permission to reorder tests of different arguments or children.
+spPlainIrrefutable pattern = (let tag = sourcePatternTag pattern in
+ or? (equal? tag 0) (equal? tag 4))
+spRootPattern pattern = (let tag = sourcePatternTag pattern in
+ or? (spPlainIrrefutable pattern)
+ (or? (equal? tag 1) (or? (equal? tag 2) (equal? tag 3))))
+spFindSplit equations patterns column = (lazyList (_ : nothing)
+ (pattern rest : lazyBool
+ (_ : lazyBool
+ (_ : spFindSplit equations rest (succ column))
+ (_ : nothing)
+ (all? (equation : spPlainIrrefutable (nth column (fst equation))) equations))
+ (_ : lazyBool
+ (_ : just column)
+ (_ : nothing)
+ (and? (spRootPattern pattern)
+ (all? (equation : spRootPattern (nth column (fst equation))) equations)))
+ (spPlainIrrefutable pattern)) patterns)
+spRootAccepts kind pattern =
+ or? (spPlainIrrefutable pattern) (equal? kind (sourcePatternTag pattern))
+
+-- The selected constructor and its child expressions are already available.
+-- Child coordinates travel explicitly because a failure binder shifts them.
+-- Nested patterns still use ordinary left-to-right matching and saved siblings.
+spKnownPattern pattern children value ctx hit miss = (case sourcePatternTag pattern of
+ 1 = hit ctx miss
+ 2 = spPattern (sourcePatternPayload pattern) (fst children) ctx hit miss
+ 3 = (spPattern (fst (sourcePatternPayload pattern)) (fst children)
+ (spSave ctx (fst (snd children)))
+ (next nextMiss : spPattern (snd (sourcePatternPayload pattern))
+ (fst (spPending next)) (spPop next) hit nextMiss) miss)
+ _ = spPattern pattern value ctx hit miss)
+spKnownArguments patterns column children ctx body miss = (lazyList
+ (_ : spBody body ctx)
+ (pattern rest : lazyBool
+ (_ : spKnownPattern pattern children (fst (spPending ctx)) (spPop ctx)
+ (next nextMiss : spMatchArguments rest next body nextMiss) miss)
+ (_ : spPattern pattern (fst (spPending ctx)) (spPop ctx)
+ (next nextMiss : spKnownArguments rest (pred_ column) children next body nextMiss) miss)
+ (isZero_? column)) patterns)
+spKnownIrrefutable pattern = (case sourcePatternTag pattern of
+ 1 = true
+ 2 = spIrrefutable (sourcePatternPayload pattern)
+ 3 = and? (spIrrefutable (fst (sourcePatternPayload pattern)))
+ (spIrrefutable (snd (sourcePatternPayload pattern)))
+ _ = spIrrefutable pattern)
+spKnownRowIrrefutable patterns column = (lazyList (_ : true)
+ (pattern rest : lazyBool
+ (_ : and? (spKnownIrrefutable pattern) (spAllIrrefutable rest))
+ (_ : and? (spIrrefutable pattern) (spKnownRowIrrefutable rest (pred_ column)))
+ (isZero_? column)) patterns)
+spKnownEquations equations column children ctx = (lazyList (_ : spLit t)
+ (equation rest : lazyBool
+ (_ : spKnownArguments (fst equation) column children ctx (snd equation) (spLit t))
+ (_ : spApp
+ (spLam (spKnownArguments (fst equation) column (map spShift children)
+ (spShiftContext ctx) (snd equation) (spApp (spVar 0) (spLit t))))
+ (spLam (spShift (spKnownEquations rest column children ctx))))
+ (spKnownRowIrrefutable (fst equation) column)) equations)
+spSplitEquations equations column ctx = (let
+ rows kind = filter (equation : spRootAccepts kind (nth column (fst equation))) equations
+ stemCtx = spShiftContext ctx
+ forkCtx = spShiftContext stemCtx
+ in spCase
+ (spKnownEquations (rows 1) column [] ctx)
+ (spKnownEquations (rows 2) column [(spVar 0)] stemCtx)
+ (spKnownEquations (rows 3) column [(spVar 1) (spVar 0)] forkCtx)
+ (nth column (spPending ctx)))
+spEquations equations ctx = (lazyMaybe
+ (_ : spSequentialEquations equations ctx)
+ (column : spSplitEquations equations column ctx)
+ (spFindSplit equations (fst (fst equations)) 0))
spWrap patterns body = (lazyList (_ : body)
(_ rest : spLam (spWrap rest body)) patterns)
diff --git a/src/Main.hs b/src/Main.hs
@@ -1,13 +1,18 @@
module Main where
import ContentStore
+import qualified Package
+import qualified Module.Provenance as Provenance
+import qualified Module.Inspection as Inspection
+import qualified Project
import ContentStore.Bundle
import Module.Manifest
-import System.Exit (die)
+import System.Exit (die, exitFailure)
import IODriver (IOPermissions(..), runIO, YieldStats(..), runIOWithYieldStatsHook)
import REPL (portableRepl)
import Research (T(..), EvaluatedForm(..), formatT, exportDag, apply, ofString, ofNumber, toString)
import Wire (encodeBundle, buildBundleShared, defaultExportNames, Bundle(..))
+import TreeObservation (forceTreeShared)
import Control.Monad (forM, forM_, unless, when)
import Control.Exception (IOException, bracketOnError, catch, evaluate)
@@ -29,7 +34,7 @@ import System.Directory (createDirectoryIfMissing, getHomeDirectory, getCurrentD
renameFile, removeFile, pathIsSymbolicLink, doesPathExist, doesFileExist, canonicalizePath)
import System.FilePath (takeBaseName, takeDirectory, takeExtension, (</>))
import System.Timeout (timeout)
-import System.IO (openBinaryTempFile, hClose, stderr, withBinaryFile, IOMode(ReadMode))
+import System.IO (openBinaryTempFile, hClose, hFlush, hPutStrLn, stdout, stderr, withBinaryFile, IOMode(ReadMode))
import qualified Data.Aeson as JSON
import GHC.Clock (getMonotonicTimeNSec)
import GHC.Stats
@@ -64,7 +69,10 @@ data CompilerOptions = CompilerOptions
} deriving (Show)
data TricuArgs
- = Repl CompilerOptions
+ = PackageCommand PackageArgs
+ | ProjectCommand ProjectArgs
+ | ProvenanceCommand ProvenanceArgs
+ | Repl CompilerOptions
| Eval
{ evalFiles :: [FilePath]
, evalStore :: Maybe FilePath
@@ -115,11 +123,13 @@ data TricuArgs
, storedStore :: Maybe FilePath
, storedFormat :: EvaluatedForm
, storedResult :: Bool
+ , storedNoResult :: Bool
, storedIo :: Bool
, storedAllowRead :: [FilePath]
, storedAllowWrite :: [FilePath]
, storedUnsafeIo :: Bool
, storedProfile :: Bool
+ , storedProfileFullResult :: Bool
}
| ArboricxExport
{ exportTargets :: [String]
@@ -142,6 +152,151 @@ data TricuArgs
}
deriving (Show)
+data PackageArgs
+ = PackagePublish FilePath String String FilePath
+ | PackageResolve FilePath String String String FilePath Bool
+ | PackageFetch FilePath FilePath (Maybe FilePath)
+ | PackageBind FilePath FilePath (Maybe FilePath)
+ deriving (Show)
+
+packageParser :: Parser TricuArgs
+packageParser = PackageCommand <$> subparser (mconcat
+ [ command "publish" (info (publish <**> helper) (progDesc "Publish an immutable exact release into a trusted server directory"))
+ , command "resolve" (info (resolve <**> helper) (progDesc "Resolve an exact version from an explicitly trusted registry into a lock"))
+ , command "fetch" (info (fetch <**> helper) (progDesc "Fetch a locked module; never consult the package index"))
+ , command "bind" (info (binding <**> helper) (progDesc "Write a pinned workspace re-export module, offline"))
+ ])
+ where
+ opt name label = strOption (long name <> metavar label)
+ config = opt "registries" "CONFIG"
+ lock = opt "lock" "LOCK"
+ name = opt "name" "PACKAGE"
+ version = opt "version" "VERSION"
+ publish = PackagePublish <$> opt "root" "SERVER_STORE" <*> name <*> version <*> opt "file" "BUNDLE"
+ resolve = PackageResolve <$> config <*> opt "registry" "REGISTRY" <*> name <*> version <*> lock
+ <*> switch (long "update" <> help "Permit changing the locked selection, never an existing version's identity")
+ fetch = PackageFetch <$> config <*> lock <*> storePathParser
+ binding = PackageBind <$> lock <*> opt "output" "MODULE.tri" <*> storePathParser
+
+runPackage :: PackageArgs -> IO ()
+runPackage args = (case args of
+ PackagePublish root name version file -> Package.publishRelease root (T.pack name) (T.pack version) file >>= BS.putStr . Package.encodeRelease
+ PackageResolve config registry name version lock update ->
+ Package.resolveRelease config (T.pack registry) (T.pack name) (T.pack version) lock update >>= BS.putStr . Package.encodeLock
+ PackageFetch config lock store -> do
+ resolved <- resolveStorePath store
+ selected <- Package.fetchLocked config lock resolved
+ putStrLn (T.unpack (Package.releaseManifest (Package.lockRelease selected)))
+ PackageBind lock output store -> resolveStorePath store >>= \resolved -> Package.bindLocked lock resolved output
+ ) `catch` (\(e :: IOException) -> die (show e))
+
+-- A single declaration/lock covers the whole dependency set. Source compilation
+-- remains independent; these commands only prepare local immutable imports.
+data ProjectArgs
+ = ProjectResolve FilePath (Maybe FilePath) FilePath Project.Update
+ | ProjectExplain FilePath (Maybe FilePath) (Maybe FilePath) (Maybe FilePath)
+ | ProjectDiff FilePath FilePath (Maybe FilePath)
+ | ProjectFetch FilePath (Maybe FilePath) FilePath (Maybe FilePath)
+ | ProjectBind FilePath (Maybe FilePath) (Maybe FilePath) (Maybe FilePath) Bool
+ deriving (Show)
+
+projectParser :: Parser TricuArgs
+projectParser = ProjectCommand <$> subparser (mconcat
+ [ command "resolve" (info (resolve Project.Frozen <**> helper) (progDesc "Create a project lock; preserve existing selections without network"))
+ , command "update" (info (update <**> helper) (progDesc "Explicitly update selected dependencies and report identity changes"))
+ , command "fetch" (info (fetch <**> helper) (progDesc "Fetch the locked project into the CAS; never consult the index"))
+ , command "bind" (info (binding False <**> helper) (progDesc "Verify and generate owned workspace bindings offline"))
+ , command "explain" (info (explain <**> helper) (progDesc "Explain locked selections and local evidence offline, without writes"))
+ , command "diff" (info (diff <**> helper) (progDesc "Compare locks, interfaces and tree structure offline, without execution"))
+ , command "check" (info (binding True <**> helper) (progDesc "Check declaration, lock, generated bindings and local CAS offline"))
+ ])
+ where
+ opt name label description = strOption (long name <> metavar label <> help description)
+ project = strOption (long "project" <> metavar "FILE" <> value "tricu.project" <> showDefault <> help "Dependency declaration")
+ lock = optional (opt "lock" "FILE" "Consolidated lock; default tricu.lock beside the declaration")
+ config = opt "registries" "CONFIG" "Explicit trusted registry configuration"
+ resolve mode = ProjectResolve <$> project <*> lock <*> config <*> pure mode
+ update = ProjectResolve <$> project <*> lock <*> config <*>
+ ((flag' Project.All (long "all" <> help "Re-resolve all selections")) <|>
+ (Project.Selected . map T.pack <$> some (opt "dependency" "BINDING" "Local binding to update; repeat to select several")))
+ fetch = ProjectFetch <$> project <*> lock <*> config <*> storePathParser
+ explain = ProjectExplain <$> project <*> lock <*> optional (opt "directory" "WORKSPACE" "Binding directory; default beside declaration") <*> storePathParser
+ diff = ProjectDiff <$> opt "before" "LOCK" "Previous project lock" <*> opt "after" "LOCK" "Next project lock" <*> storePathParser
+ binding check = ProjectBind <$> project <*> lock <*> optional (opt "directory" "WORKSPACE" "Flat workspace; default beside the declaration") <*> storePathParser <*> pure check
+
+runProject :: ProjectArgs -> IO ()
+runProject args = (case args of
+ ProjectResolve file lock config mode -> Project.resolveProject file (lockPath file lock) config mode >>= putStr . T.unpack
+ ProjectExplain file lock directory store -> do
+ resolved <- resolveStorePath store
+ emitReport =<< Project.explainProject file (lockPath file lock) (maybe (takeDirectory file) id directory) resolved
+ ProjectDiff before after store -> do
+ resolved <- resolveStorePath store
+ emitReport =<< Project.diffProject before after resolved
+ ProjectFetch file lock config store -> do
+ resolved <- resolveStorePath store
+ Project.fetchProject file (lockPath file lock) config resolved
+ putStrLn "Project modules verified locally."
+ ProjectBind file lock directory store check -> do
+ resolved <- resolveStorePath store
+ let workspace = maybe (takeDirectory file) id directory
+ (if check then Project.checkProject else Project.bindProject) file (lockPath file lock) workspace resolved
+ putStrLn (if check then "Project bindings and modules verified." else "Project bindings ready; compilation remains offline.")
+ ) `catch` (\(e :: IOException) -> die (show e))
+ where
+ lockPath file = maybe (takeDirectory file </> "tricu.lock") id
+ emitReport (report, complete) = do
+ putStr (T.unpack report)
+ unless complete exitFailure
+
+data ProvenanceArgs
+ = CaptureInputs FilePath (Maybe FilePath) FilePath String (Maybe String) (Maybe (FilePath,Maybe FilePath)) FilePath
+ | VerifyInputs FilePath String
+ | RebuildInputs FilePath String String FilePath Int
+ deriving (Show)
+
+provenanceParser :: Parser TricuArgs
+provenanceParser = ProvenanceCommand <$> subparser (mconcat
+ [ command "capture" (info (capture <**> helper) (progDesc "Retain native receipts, exact sources and executable inputs in a new pack"))
+ , command "verify" (info (verify <**> helper) (progDesc "Authenticate a retained input closure without executing its compiler"))
+ , command "rebuild" (info (rebuild <**> helper) (progDesc "Rebuild in a new directory with an explicitly trusted retained compiler"))
+ ])
+ where
+ opt name label = strOption (long name <> metavar label <> help label)
+ pack = opt "pack" "DIRECTORY"
+ pin = opt "pin" "INPUTS_HASH"
+ capture = CaptureInputs <$> opt "file" "ENTRY.tri" <*> storePathParser
+ <*> opt "tool-store" "TOOL_STORE" <*> opt "toolchain" "COMPILER_MANIFEST"
+ <*> optional (opt "receipt" "NATIVE_RECEIPT_HASH")
+ <*> optional ((,) <$> opt "project" "DECLARATION" <*> optional (opt "lock" "PROJECT_LOCK"))
+ <*> opt "output" "NEW_PACK_DIRECTORY"
+ verify = VerifyInputs <$> pack <*> pin
+ rebuild = RebuildInputs <$> pack <*> pin <*> strOption (long "trust-compiler" <> metavar "COMPILER_MANIFEST"
+ <> help "Explicitly authorize this compiler; a matching hash is not publisher trust")
+ <*> opt "output" "NEW_REBUILD_DIRECTORY"
+ <*> option auto (long "compile-timeout" <> value 120 <> showDefault <> help "Compiler deadline in seconds")
+
+runProvenance :: ProvenanceArgs -> IO ()
+runProvenance args = (case args of
+ CaptureInputs file store tools compiler receipt project output -> do
+ app <- resolveStorePath store
+ let context = fmap (\(p,l) -> (p, maybe (takeDirectory p </> "tricu.lock") id l)) project
+ pin <- Provenance.capture file app (StorePath tools) (T.pack compiler) (T.pack <$> receipt) context output
+ putStrLn ("Retained build inputs: " ++ T.unpack pin ++ "\nProducer claims; use verify, then an explicitly trusted rebuild.")
+ VerifyInputs pack pin -> Provenance.verifyPack pack (T.pack pin) >>= putStr . T.unpack
+ RebuildInputs pack pin trusted output seconds -> do
+ let tools = output </> "tools"
+ app = output </> "application"
+ options = CompilerOptions (Just trusted) (Just tools) seconds False
+ validateCompilerOptions options
+ recipe <- Provenance.prepareRebuild pack (T.pack pin) (T.pack trusted) output
+ (_,actual,receipt) <- buildPortableResult options (Just app)
+ (output </> "workspace" </> T.unpack (Provenance.recipeEntry recipe) ++ ".tri")
+ (report,exact) <- Provenance.finishRebuild pack (T.pack pin) output recipe actual receipt
+ putStr (T.unpack report)
+ unless exact exitFailure
+ ) `catch` (\(e :: IOException) -> die (T.unpack (Inspection.safeText (T.pack (show e)))))
+
-- ---------------------------------------------------------------------------
-- optparse-applicative parsers
-- ---------------------------------------------------------------------------
@@ -298,13 +453,15 @@ storedRunParser = ArboricxRun
<*> option readEvaluatedForm (long "format" <> short 'f'
<> metavar "FORM" <> value Tree <> help "Output format")
<*> switch (long "result" <> help "Display a canonical Result with ok/error labels")
+ <*> switch (long "no-result" <> help "Suppress final value display for --io; keep program output and errors")
<*> switch (long "io" <> help "Run a stored IO program")
<*> many (strOption (long "allow-read" <> metavar "PATH"
<> help "Allow reading from PATH prefix (repeatable)"))
<*> many (strOption (long "allow-write" <> metavar "PATH"
<> help "Allow writing to PATH prefix (repeatable)"))
<*> switch (long "unsafe-io" <> help "Allow unrestricted IO")
- <*> switch (long "profile" <> help "Emit GC-backed load/invocation/yield samples to stderr (requires +RTS -T)")
+ <*> switch (long "profile" <> help "Emit load/invocation/observation/yield samples without extra result forcing (requires +RTS -T)")
+ <*> switch (long "profile-full-result" <> help "Also force the complete result with sharing-aware traversal (requires --profile; changes demand)")
exportParser :: Parser TricuArgs
exportParser = ArboricxExport
@@ -404,7 +561,10 @@ tricuParser = AppArgs
<**> infoOption versionStr (long "version" <> help "Show version"))
where
topCommands = mconcat
- [ command "repl" (info ((Repl <$> compilerOptionsParser) <**> helper)
+ [ command "provenance" (info (provenanceParser <**> helper) (progDesc "Retained build inputs and offline reproduction"))
+ , command "project" (info (projectParser <**> helper) (progDesc "Project dependency preparation and offline inspection"))
+ , command "package" (info (packageParser <**> helper) (progDesc "Exact-version trusted registry operations"))
+ , command "repl" (info ((Repl <$> compilerOptionsParser) <**> helper)
(progDesc "Interactive portable source session"))
, command "eval" (info (evalParser <**> helper)
(progDesc "Compile a native module and print its exported main"))
@@ -482,6 +642,9 @@ main = do
let mGlobalStore = globalStore appArgs
args = applyGlobalStore mGlobalStore (appCommand appArgs)
case args of
+ PackageCommand commandArgs -> runPackage commandArgs
+ ProjectCommand commandArgs -> runProject commandArgs
+ ProvenanceCommand commandArgs -> runProvenance commandArgs
Repl options -> runReplWithOptions mGlobalStore options
Eval {} -> runEval args
TreeWrite {} -> runTreeWrite args
@@ -500,6 +663,16 @@ main = do
applyGlobalStore :: Maybe FilePath -> TricuArgs -> TricuArgs
applyGlobalStore mGlobal args = case args of
+ PackageCommand (PackageFetch config lock store) -> PackageCommand (PackageFetch config lock (preferLocal store))
+ PackageCommand (PackageBind lock output store) -> PackageCommand (PackageBind lock output (preferLocal store))
+ PackageCommand _ -> args
+ ProjectCommand (ProjectFetch file lock config store) -> ProjectCommand (ProjectFetch file lock config (preferLocal store))
+ ProjectCommand (ProjectBind file lock directory store check) -> ProjectCommand (ProjectBind file lock directory (preferLocal store) check)
+ ProjectCommand (ProjectExplain file lock directory store) -> ProjectCommand (ProjectExplain file lock directory (preferLocal store))
+ ProjectCommand (ProjectDiff before after store) -> ProjectCommand (ProjectDiff before after (preferLocal store))
+ ProjectCommand _ -> args
+ ProvenanceCommand (CaptureInputs file store tools compiler receipt project output) -> ProvenanceCommand (CaptureInputs file (preferLocal store) tools compiler receipt project output)
+ ProvenanceCommand _ -> args
Repl options -> Repl options
Eval {} -> args { evalStore = preferLocal (evalStore args) }
TreeWrite {} -> args { treeStore = preferLocal (treeStore args) }
@@ -711,8 +884,12 @@ runImport opts = do
-- No .tri frontend or source compilation occurs here.
runStored :: TricuArgs -> IO ()
runStored opts = do
+ when (storedNoResult opts && (not (storedIo opts) || storedResult opts)) $
+ die "--no-result requires --io and cannot be combined with --result"
unless (storedRepeat opts >= 1 && storedRepeat opts <= 1000) $
die "--repeat must be between 1 and 1000"
+ when (storedProfileFullResult opts && not (storedProfile opts)) $
+ die "--profile-full-result requires --profile"
when (storedProfile opts) $ do
enabled <- getRTSStatsEnabled
unless enabled $ die "--profile requires +RTS -T"
@@ -747,12 +924,20 @@ runStored opts = do
then fmap (fmap fst) (runIOWithYieldStatsHook onYield perms Leaf Leaf applied)
else runIO perms applied) >>= either (die . ("IO error: " ++)) pure
else pure applied
- when (storedProfile opts) $ evaluate (forceProfileTree finalT)
sample (label "invoke-end")
- displayed <- if storedResult opts
- then either die pure (renderInvocationResult (storedFormat opts) finalT)
- else pure (formatT (storedFormat opts) finalT)
- writeOutput "" displayed
+ when (storedProfileFullResult opts) $ do
+ sample (label "force-begin")
+ forceTreeShared finalT
+ sample (label "force-end")
+ sample (label "observe-begin")
+ unless (storedNoResult opts) $ do
+ displayed <- if storedResult opts
+ then either die pure (renderInvocationResult (storedFormat opts) finalT)
+ else pure (formatT (storedFormat opts) finalT)
+ writeOutput "" displayed
+ -- Keep the following JSON on its own line even when stdout/stderr are merged.
+ when (storedProfile opts) $ hFlush stdout >> hPutStrLn stderr ""
+ sample (label "observe-end")
when (storedRepeat opts > 1) $ do
putStrLn ""
putStrLn "RUN_DONE"
@@ -843,6 +1028,13 @@ validateCompilerOptions options = do
-- All header staging, scope, compilation, publication and cache policy is tricu.
buildPortableModule :: CompilerOptions -> Maybe FilePath -> FilePath -> IO (StorePath, T.Text)
buildPortableModule options appStore input = do
+ (store,manifest,_) <- buildPortableResult options appStore input
+ pure (store,manifest)
+
+-- Keep the compiler-returned receipt available to reproduction tooling instead
+-- of recovering execution evidence from a mutable cache locator afterward.
+buildPortableResult :: CompilerOptions -> Maybe FilePath -> FilePath -> IO (StorePath, T.Text, T.Text)
+buildPortableResult options appStore input = do
when (compilerProfile options) $ do
enabled <- getRTSStatsEnabled
unless enabled $ die "--compile-profile requires +RTS -T"
@@ -884,12 +1076,12 @@ buildPortableModule options appStore input = do
receiptBytes <- getObject store receiptHash >>= maybe (die "Compiler receipt missing") pure
unless (hashObject (Domain "arboricx.build-receipt.v1") receiptBytes == receiptHash) $
die "Compiler receipt digest mismatch"
- pure manifestHash
+ pure (manifestHash,receiptHash)
Fork Leaf (Fork problem _) -> die $ "Portable compilation failed: " ++ formatT Decode problem
_ -> die "Invalid portable compiler Result"
- manifest <- maybe (die "Portable compiler load/build deadline exceeded") pure built
+ (manifest,receipt) <- maybe (die "Portable compiler load/build deadline exceeded") pure built
sample "publication-verified"
- pure (store, manifest)
+ pure (store, manifest,receipt)
-- Lightweight stage samples: no forced collection or expanded-tree traversal.
-- Allocation/live counters reflect the most recent RTS collection.
@@ -926,11 +1118,6 @@ profileSample label counts = do
]) counts)))
BL.hPutStr stderr "\n"
-forceProfileTree :: Research.T -> ()
-forceProfileTree Leaf = ()
-forceProfileTree (Stem child) = forceProfileTree child
-forceProfileTree (Fork left right) = forceProfileTree left `seq` forceProfileTree right
-
-- Presentation of the canonical Result envelope; the underlying tree survives.
renderInvocationResult :: EvaluatedForm -> Research.T -> Either String String
renderInvocationResult form (Fork (Stem Leaf) (Fork value _)) =
diff --git a/src/Module/Inspection.hs b/src/Module/Inspection.hs
@@ -0,0 +1,131 @@
+{-# LANGUAGE BangPatterns #-}
+-- Read-only evidence, independent of evaluators and registry transport.
+module Module.Inspection
+ ( Inspection(..), inspectModule, inspectModuleWithBudget, inspectionStatus, compareGraphs, safeText
+ ) where
+
+import ContentStore.Object
+import ContentStore.Alias (ObjectRef(..))
+import ContentStore.Filesystem (objectPath)
+import ContentStore.Bundle (indexedTreeDomain)
+import Module.Manifest
+import qualified Wire as W
+import Control.Exception (IOException, catch, bracket)
+import Control.Monad (foldM)
+import qualified Data.ByteString as BS
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Text.Encoding (encodeUtf8)
+import qualified Data.Map.Strict as M
+import qualified Data.IntMap.Strict as IM
+import qualified Data.Foldable as F
+import Data.List (foldl')
+import Numeric (showHex)
+import System.IO (openBinaryFile, IOMode(ReadMode), hClose)
+import System.Posix.Files (getSymbolicLinkStatus, isRegularFile)
+import System.IO.Error (isDoesNotExistError)
+
+data Inspection = Inspection
+ { inspectedManifest :: Either Text ModuleManifest
+ , inspectedExports :: M.Map Text (Either Text W.Bundle)
+ }
+
+-- Escape all non-ASCII/control bytes and '%' in display fields, including errors.
+safeText :: Text -> Text
+safeText = T.pack . concatMap escape . BS.unpack . encodeUtf8
+ where
+ escape b | b >= 32 && b <= 126 && b /= 37 = [toEnum (fromIntegral b)]
+ | otherwise = '%' : (if b < 16 then "0" else "") ++ showHex b ""
+
+readEvidence :: StorePath -> Domain -> Int -> Text -> IO (Either Text BS.ByteString)
+readEvidence store domain limit pin = case validateObjectHash pin of
+ Left err -> pure (Left ("invalid: " <> T.pack err))
+ Right () -> (do
+ status <- getSymbolicLinkStatus (objectPath store pin)
+ if not (isRegularFile status) then pure (Left "unavailable: expected regular CAS file") else
+ bracket (openBinaryFile (objectPath store pin) ReadMode) hClose $ \h -> do
+ bytes <- BS.hGet h (limit + 1)
+ pure $ if BS.length bytes > limit then Left "unavailable: object exceeds remaining module inspection byte budget"
+ else case verifyObjectBytes domain pin bytes of
+ Left err -> Left ("invalid: " <> T.pack err)
+ Right () -> Right bytes)
+ `catch` (\(e :: IOException) -> pure (Left (if isDoesNotExistError e then "missing" else "unavailable: " <> T.pack (show e))))
+
+inspectModule :: StorePath -> Text -> IO Inspection
+inspectModule = inspectModuleWithBudget (64 * 1024 * 1024)
+
+-- Explicit budget is also used by focused tests; the CLI uses the default.
+inspectModuleWithBudget :: Int -> StorePath -> Text -> IO Inspection
+inspectModuleWithBudget budget store pin
+ | budget < 0 || budget == maxBound = pure (Inspection (Left "unavailable: invalid inspection budget") M.empty)
+ | otherwise = inspectWithBudget budget store pin
+
+inspectWithBudget :: Int -> StorePath -> Text -> IO Inspection
+inspectWithBudget budget store pin = do
+ bytes <- readEvidence store manifestDomain 65536 pin
+ let manifest = bytes >>= \payload -> case decodeManifest payload of
+ Left err -> Left ("invalid: " <> T.pack err)
+ Right m | not (null (moduleManifestReferences m)) || null (moduleManifestExports m) -> Left "unsupported: expected a closed nonempty package module"
+ | otherwise -> Right m
+ exports <- case manifest of
+ Left _ -> pure M.empty
+ Right m -> do
+ (_,_,found) <- foldM inspect (budget, M.empty, M.empty) (moduleManifestExports m)
+ pure found
+ pure (Inspection manifest exports)
+ where
+ inspect (!remaining,!cache,!found) ex = do
+ let ref = moduleExportObject ex
+ cached evidence remainingBytes nextCache = pure (remainingBytes,nextCache,M.insert (moduleExportName ex) evidence found)
+ if objectRefKind ref /= unDomain indexedTreeDomain || moduleExportAbi ex /= "arboricx.abi.tree.v1"
+ then cached (Left "unsupported: expected indexed tree with tree ABI") remaining cache
+ else case M.lookup ref cache of
+ Just evidence -> cached evidence remaining cache
+ Nothing -> do
+ bytes <- readEvidence store indexedTreeDomain remaining (objectRefHash ref)
+ let evidence = bytes >>= \payload -> case decodeGraph payload of
+ Left err -> Left ("invalid: " <> T.pack err)
+ Right graph -> Right graph
+ -- Only retained authenticated bytes count toward graph memory.
+ remainingBytes = either (const remaining) (\payload -> remaining - BS.length payload) bytes
+ evidence `seq` cached evidence remainingBytes (M.insert ref evidence cache)
+ decodeGraph payload = do
+ graph <- W.decodeBundle payload
+ W.verifyBundle graph
+ case (W.bundleRoots graph, W.manifestExports (W.bundleManifest graph)) of
+ ([root],[ex]) | W.exportName ex == "root" && W.exportRoot ex == root
+ && W.exportKind ex == "term" && W.exportAbi ex == "arboricx.abi.tree.v1" -> Right graph
+ _ -> Left "indexed tree requires one root named root with the tree ABI"
+
+inspectionStatus :: Inspection -> Text
+inspectionStatus i = case inspectedManifest i of
+ Left err -> err
+ Right _ -> if all good (M.elems (inspectedExports i)) then "verified" else "incomplete (see exports)"
+ where good (Right _) = True; good _ = False
+
+-- Collision-free hash-consing, not probabilistic subtree hashes or recursive T
+-- equality. Intern child-ID tuples in ONE table shared by both graphs. Memory is
+-- proportional to stored records; no logical unfolding, evaluation or CAS writes.
+compareGraphs :: W.Bundle -> W.Bundle -> Either String Bool
+compareGraphs left right = do
+ W.verifyBundle left
+ W.verifyBundle right
+ a <- single left; b <- single right
+ let (!table,!l) = intern M.empty left
+ (_,!r) = intern table right
+ pure (l IM.! a == r IM.! b)
+ where
+ single graph = case W.bundleRoots graph of
+ [root] -> Right (fromIntegral root)
+ _ -> Left "structural comparison requires one root"
+ intern initial graph = foldl' step (initial,IM.empty) (zip [0..] (F.toList (W.bundleNodes graph)))
+ step (!table,!indices) (index,node) =
+ let child n = indices IM.! fromIntegral n
+ key = case node of
+ W.BNLeaf -> (0 :: Int,0,0)
+ W.BNStem c -> (1,child c,0)
+ W.BNFork l r -> (2,child l,child r)
+ in case M.lookup key table of
+ Just ident -> (table,IM.insert index ident indices)
+ Nothing -> let ident = M.size table
+ in (M.insert key ident table,IM.insert index ident indices)
diff --git a/src/Module/Provenance.hs b/src/Module/Provenance.hs
@@ -0,0 +1,365 @@
+-- Retain inputs around native receipts; never invent a second build receipt.
+module Module.Provenance
+ ( capture, verifyPack, prepareRebuild, finishRebuild, Recipe(..) ) where
+
+import ContentStore.Object
+import ContentStore.Alias (ObjectRef(..))
+import ContentStore.Filesystem (objectPath, putObject)
+import Module.Manifest
+import qualified Module.Inspection as I
+import qualified Package as P
+import qualified Project as J
+import Control.Exception (bracket)
+import Control.Monad (unless, when, forM_, foldM)
+import qualified Data.ByteString as BS
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Text.Encoding (encodeUtf8, decodeUtf8')
+import qualified Data.Map.Strict as M
+import qualified Data.Set as S
+import Data.IORef
+import System.Directory (createDirectory, createDirectoryIfMissing)
+import System.FilePath ((</>), takeDirectory, takeBaseName, takeExtension)
+import System.IO (openBinaryFile, IOMode(ReadMode), hClose)
+import System.Posix.Files (getSymbolicLinkStatus, isRegularFile, isDirectory)
+
+receiptDomain, sourceDomain, recipeDomain, optionsDomain :: Domain
+receiptDomain = Domain "arboricx.build-receipt.v1"
+sourceDomain = Domain "arboricx.source.v1"
+recipeDomain = Domain "arboricx.build-inputs.v1"
+optionsDomain = Domain "arboricx.build-options.v1"
+profile :: BS.ByteString
+profile = "source-module.stable.v1"
+
+data Receipt = Receipt
+ { rSource :: Text, rCompiler :: Text, rOptions :: Text, rOutput :: Text
+ , rLocals :: [(Text,Text,Text)], rPins :: [(Text,Text,Text,Text)]
+ } deriving (Eq, Show)
+data Recipe = Recipe
+ { recipeEntry :: Text, recipeReceipt :: Text, recipeCompiler :: Text
+ , recipeUnits :: [(Text,Text)], recipeProject :: Maybe (Text,Text)
+ } deriving (Eq, Show)
+data Closure = Closure
+ { cRecipe :: Recipe, cObjects :: M.Map (Text,Text) BS.ByteString
+ , cReceipts :: M.Map Text (Text,Receipt), cExternal :: S.Set Text
+ }
+
+bounded :: Int -> FilePath -> IO BS.ByteString
+bounded limit file = do
+ status <- getSymbolicLinkStatus file
+ unless (isRegularFile status) $ fail "build inputs require regular files (no symlinks)"
+ bracket (openBinaryFile file ReadMode) hClose $ \h -> do
+ bytes <- BS.hGet h (limit + 1)
+ when (BS.length bytes > limit) $ fail "build input exceeds byte limit"
+ pure bytes
+
+textRows :: Text -> BS.ByteString -> Either String [[Text]]
+textRows header bytes = do
+ txt <- either (Left . show) Right (decodeUtf8' bytes)
+ case T.lines txt of
+ h:rest | h == header && encodeUtf8 (T.unlines (h:rest)) == bytes -> Right (map (T.splitOn "\t") rest)
+ _ -> Left "unsupported/noncanonical build metadata"
+
+parseReceipt :: BS.ByteString -> Either String Receipt
+parseReceipt bytes = do
+ rows <- textRows "arboricx.build-receipt.v1" bytes
+ case rows of
+ ["source",s]:["compiler",c]:["options",o]:["output",out]:rest -> do
+ mapM_ validateObjectHash [s,c,o,out]
+ let (locals,pins) = span (\row -> take 1 row == ["local"]) rest
+ ls <- mapM (\row -> case row of
+ ["local",n,m,r] | J.validBinding n -> mapM_ validateObjectHash [m,r] >> pure (n,m,r)
+ _ -> Left "invalid receipt local row") locals
+ ps <- mapM (\row -> case row of
+ ["pin",ref,m,n,t] -> mapM_ validateObjectHash [m,t] >> pure (ref,m,n,t)
+ _ -> Left "invalid receipt pin row") pins
+ unless (S.size (S.fromList [n | (n,_,_) <- ls]) == length ls) $ Left "duplicate receipt local"
+ unless (S.size (S.fromList [(ref,n) | (ref,_,n,_) <- ps]) == length ps) $ Left "duplicate receipt pin"
+ unless (o == hashObject optionsDomain profile) $ Left "unsupported build options; expected stable flat-workspace profile"
+ pure (Receipt s c o out ls ps)
+ _ -> Left "invalid native build receipt"
+
+encodeRecipe :: Recipe -> BS.ByteString
+encodeRecipe r = encodeUtf8 $ T.unlines $
+ ["arboricx.build-inputs.v1", "entry\t" <> recipeEntry r, "receipt\t" <> recipeReceipt r,
+ "compiler\t" <> recipeCompiler r] ++
+ ["unit\t" <> n <> "\t" <> p | (n,p) <- recipeUnits r] ++
+ maybe [] (\(p,l) -> ["project\t" <> p <> "\t" <> l]) (recipeProject r)
+
+decodeRecipe :: BS.ByteString -> Either String Recipe
+decodeRecipe bytes = do
+ rows <- textRows "arboricx.build-inputs.v1" bytes
+ case rows of
+ ["entry",entry]:["receipt",receipt]:["compiler",compiler]:rest | J.validBinding entry -> do
+ let (units,context) = span (\row -> take 1 row == ["unit"]) rest
+ us <- mapM (\row -> case row of
+ ["unit",n,p] | J.validBinding n -> validateObjectHash p >> pure (n,p)
+ _ -> Left "invalid build source unit") units
+ unless (not (null us) && length us <= 128 && M.toAscList (M.fromList us) == us &&
+ S.size (S.fromList (map (T.toCaseFold . fst) us)) == length us) $ Left "invalid/duplicate/unsorted source units"
+ mapM_ validateObjectHash [receipt,compiler]
+ ctx <- case context of
+ [] -> Right Nothing
+ [["project",p,l]] -> mapM_ validateObjectHash [p,l] >> pure (Just (p,l))
+ _ -> Left "invalid project context"
+ unless (lookup entry us == Just receipt) $ Left "entry receipt not in source units"
+ let result = Recipe entry receipt compiler us ctx
+ unless (encodeRecipe result == bytes) $ Left "noncanonical build inputs"
+ pure result
+ _ -> Left "invalid build inputs"
+
+-- Byte authentication is separate from the producer's claimed input/output link.
+-- Collect only the referenced closure; never copy caches or ambient aliases.
+collect :: Recipe -> (Domain -> Text -> IO BS.ByteString) -> (Text -> Text -> IO BS.ByteString) -> IO Closure
+collect initial load source = do
+ objects <- newIORef M.empty
+ total <- newIORef (0 :: Int)
+ let retain d pin bytes = do
+ either fail pure (verifyObjectBytes d pin bytes)
+ old <- readIORef objects
+ unless (M.member (unDomain d,pin) old) $ do
+ size <- atomicModifyIORef' total (\n -> let next = n + BS.length bytes in (next,next))
+ when (size > 256 * 1024 * 1024) $ fail "build input closure exceeds 256 MiB"
+ modifyIORef' objects (M.insert (unDomain d,pin) bytes)
+ pure bytes
+ get d p = do
+ known <- M.lookup (unDomain d,p) <$> readIORef objects
+ maybe (load d p >>= retain d p) pure known
+ moduleObjects p = do
+ bytes <- get manifestDomain p
+ m <- either fail pure (decodeManifest bytes)
+ unless (encodeManifest m == bytes) $ fail "noncanonical native manifest in build inputs"
+ unless (null (moduleManifestReferences m) && not (null (moduleManifestExports m))) $ fail "unsupported module profile in build inputs"
+ forM_ (moduleManifestExports m) $ \ex -> do
+ let ref = moduleExportObject ex
+ unless (objectRefKind ref == "arboricx.indexed-tree.v1" && moduleExportAbi ex == "arboricx.abi.tree.v1") $
+ fail "unsupported executable in build inputs"
+ _ <- get (Domain (objectRefKind ref)) (objectRefHash ref)
+ pure ()
+ pure m
+ compiler <- moduleObjects (recipeCompiler initial)
+ worker <- case [objectRefHash (moduleExportObject ex) | ex <- moduleManifestExports compiler, moduleExportName ex == "sourceBuildGraph"] of
+ [p] -> pure p
+ _ -> fail "compiler must export sourceBuildGraph"
+ let walk stack done name pin = do
+ when (name `elem` stack) $ fail "cyclic source receipt graph"
+ case M.lookup name done of
+ Just (previous,_) | previous == pin -> pure done
+ | otherwise -> fail "one workspace name refers to multiple receipts"
+ Nothing -> do
+ unless (J.validBinding name && M.size done + length stack < 128) $ fail "invalid/oversized source receipt graph"
+ receipt <- get receiptDomain pin >>= either fail pure . parseReceipt
+ unless (rCompiler receipt == worker) $ fail "receipt compiler identity differs from selected worker; this profile requires explicit --toolchain builds"
+ _ <- get optionsDomain (rOptions receipt)
+ _ <- source name (rSource receipt) >>= retain sourceDomain (rSource receipt)
+ _ <- moduleObjects (rOutput receipt)
+ children <- foldM (\acc (n,m,r) -> do
+ next <- walk (name:stack) acc n r
+ unless (maybe False ((== m) . rOutput . snd) (M.lookup n next)) $ fail "local receipt output mismatch"
+ pure next) done (rLocals receipt)
+ let groups = M.fromListWith (++) [(ref,[(m,n,t)]) | (ref,m,n,t) <- rPins receipt]
+ escape = T.replace "\r" "%0D" . T.replace "\n" "%0A" . T.replace "\t" "%09" . T.replace "%" "%25"
+ unless (all (\(n,_,_) -> M.member n groups) (rLocals receipt)) $ fail "receipt lacks local interface pins"
+ forM_ (M.toList groups) $ \(ref,ps) -> do
+ m <- case ps of
+ (p,_,_):_ -> pure p
+ [] -> fail "empty receipt pin group"
+ unless (all (\(p,_,_) -> p == m) ps) $ fail "conflicting pin group"
+ if ref == m then either fail pure (validateObjectHash ref)
+ else unless ((ref,m) `elem` [(n,p) | (n,p,_) <- rLocals receipt]) $ fail "unbound receipt pin group"
+ interface <- moduleObjects m
+ let expected = M.fromList [(escape (moduleExportName ex),objectRefHash (moduleExportObject ex)) | ex <- moduleManifestExports interface]
+ unless (M.fromList [(n,t) | (_,n,t) <- ps] == expected) $ fail "receipt pins differ from complete interface"
+ pure (M.insert name (pin,receipt) children)
+ receipts <- walk [] M.empty (recipeEntry initial) (recipeReceipt initial)
+ let actualUnits = [(n,p) | (n,(p,_)) <- M.toAscList receipts]
+ unless (null (recipeUnits initial) || recipeUnits initial == actualUnits) $ fail "recipe differs from reachable receipt graph"
+ case recipeProject initial of
+ Nothing -> pure ()
+ Just (p,l) -> do
+ ds <- get (Domain "arboricx.project-declaration.v1") p >>= either fail pure . J.decodeProject
+ ls <- get (Domain "arboricx.project-lock.v1") l >>= either fail pure . J.decodeProjectLock
+ let selection (n,locked) = let r = P.lockRelease locked in J.Dependency n (P.lockRegistry locked) (P.releaseName r) (P.releaseVersion r)
+ unless (ds == map selection ls) $ fail "captured project declaration differs from lock"
+ forM_ ls $ \(n,locked) -> case M.lookup n receipts of
+ Nothing -> pure () -- unused project selections are context, not build inputs
+ Just (_,r) -> do
+ bytes <- get sourceDomain (rSource r)
+ unless (bytes == P.bindingBytes (P.releaseManifest (P.lockRelease locked))) $ fail "project binding snapshot differs from lock"
+ allObjects <- readIORef objects
+ let external = S.fromList [m | (_,r) <- M.elems receipts, (ref,m,_,_) <- rPins r, ref == m]
+ pure (Closure (initial {recipeUnits = actualUnits}) allObjects receipts external)
+
+loadStore :: StorePath -> Domain -> Text -> IO BS.ByteString
+loadStore store domain pin = do
+ either fail pure (validateObjectHash pin)
+ forM_ [unStorePath store, unStorePath store </> "objects", unStorePath store </> "objects" </> shardForHash pin] $ \dir -> do
+ status <- getSymbolicLinkStatus dir
+ unless (isDirectory status) $ fail "build inputs require real CAS directories (no symlinks)"
+ let limit | domain == receiptDomain || domain == sourceDomain = 1048576
+ | domain == manifestDomain || domain == Domain "arboricx.project-declaration.v1" || domain == Domain "arboricx.project-lock.v1" = 65536
+ | otherwise = 64 * 1024 * 1024
+ bytes <- bounded limit (objectPath store pin)
+ either fail pure (verifyObjectBytes domain pin bytes)
+ pure bytes
+
+-- Decoding executable graphs is shared with the read-only inspection machinery.
+validateExecutables :: StorePath -> Closure -> IO ()
+validateExecutables store c = forM_ [p | ((d,p),_) <- M.toList (cObjects c), d == unDomain manifestDomain] $ \p -> do
+ evidence <- I.inspectModule store p
+ unless (I.inspectionStatus evidence == "verified") $ fail ("unverified build module " ++ T.unpack p ++ ": " ++ T.unpack (I.inspectionStatus evidence))
+
+writeObjects :: StorePath -> M.Map (Text,Text) BS.ByteString -> IO ()
+writeObjects store objects = forM_ (M.toList objects) $ \((d,p),bytes) -> do
+ actual <- putObject store (Domain d) bytes
+ unless (actual == p) $ fail "internal object copy identity mismatch"
+
+cacheReceipt :: StorePath -> Text -> IO Text
+cacheReceipt (StorePath store) name = do
+ bytes <- bounded 4096 (store </> "cache/source-build" </> T.unpack name)
+ txt <- either (fail . show) pure (decodeUtf8' bytes)
+ case T.splitOn "\t" txt of
+ ["arboricx.source-build-cache.v1",key,out,receipt] -> do
+ mapM_ (either fail pure . validateObjectHash) [key,out,receipt]
+ receiptBytes <- loadStore (StorePath store) receiptDomain receipt
+ parsed <- either fail pure (parseReceipt receiptBytes)
+ txtReceipt <- either (fail . show) pure (decodeUtf8' receiptBytes)
+ let body = drop 1 (T.lines txtReceipt)
+ keyBytes = encodeUtf8 (T.unlines ("arboricx.source-build-key.v1" : take 3 body ++ drop 4 body))
+ unless (rOutput parsed == out && hashObject (Domain "arboricx.source-build-key.v1") keyBytes == key) $
+ fail "native cache locator differs from authenticated receipt"
+ pure receipt
+ _ -> fail "invalid native build cache locator"
+
+capture :: FilePath -> StorePath -> StorePath -> Text -> Maybe Text -> Maybe (FilePath,FilePath) -> FilePath -> IO Text
+capture file app tools compiler selectedReceipt project output = do
+ unless (takeExtension file == ".tri" && J.validBinding (T.pack (takeBaseName file))) $ fail "capture requires a flat native .tri entry"
+ let entry = T.pack (takeBaseName file)
+ workspace = takeDirectory file
+ receipt <- maybe (cacheReceipt app entry) (\p -> either fail pure (validateObjectHash p) >> pure p) selectedReceipt
+ context <- case project of
+ Nothing -> pure Nothing
+ Just (p,l) -> do
+ ds <- bounded 65536 p
+ ls <- bounded 65536 l
+ pure (Just (ds,ls))
+ let ctx = fmap (\(d,l) -> (hashObject (Domain "arboricx.project-declaration.v1") d,hashObject (Domain "arboricx.project-lock.v1") l)) context
+ load d p | d == optionsDomain = pure profile
+ | d == Domain "arboricx.project-declaration.v1", Just (bytes,_) <- context = pure bytes
+ | d == Domain "arboricx.project-lock.v1", Just (_,bytes) <- context = pure bytes
+ | otherwise = loadStore app d p
+ -- Toolchain objects may live separately from application inputs. Copy them to
+ -- the closure through a typed lookup, never an ambient alias or source fallback.
+ toolManifestBytes <- loadStore tools manifestDomain compiler
+ toolManifest <- either fail pure (decodeManifest toolManifestBytes)
+ let toolPins = S.fromList ((unDomain manifestDomain,compiler) : [(objectRefKind r,objectRefHash r) | ex <- moduleManifestExports toolManifest, let r = moduleExportObject ex])
+ combined d p = if S.member (unDomain d,p) toolPins then loadStore tools d p else load d p
+ c <- collect (Recipe entry receipt compiler [] ctx) combined (\name _ -> bounded 1048576 (workspace </> T.unpack name ++ ".tri"))
+ -- Publication is create-only; a failed capture may leave an incomplete directory
+ -- with no descriptor. Never replace or merge another pack.
+ createDirectoryIfMissing True (takeDirectory output)
+ createDirectory output
+ let packed = StorePath (output </> "store")
+ writeObjects packed (cObjects c)
+ validateExecutables packed c
+ let bytes = encodeRecipe (cRecipe c)
+ _ <- either fail pure (decodeRecipe bytes)
+ P.writeAtomic True (output </> "build-inputs") bytes
+ pure (hashObject recipeDomain bytes)
+
+readPack :: FilePath -> Text -> IO Closure
+readPack directory pin = do
+ bytes <- bounded 65536 (directory </> "build-inputs")
+ either fail pure (verifyObjectBytes recipeDomain pin bytes)
+ recipe <- either fail pure (decodeRecipe bytes)
+ let store = StorePath (directory </> "store")
+ c <- collect recipe (loadStore store) (\_ source -> loadStore store sourceDomain source)
+ validateExecutables store c
+ pure c
+
+verifyPack :: FilePath -> Text -> IO Text
+verifyPack directory pin = do
+ c <- readPack directory pin
+ pure ("Verified retained bytes and native receipt links (referenced closure only). Producer claims only; no build executed.\n"
+ <> "Inputs " <> pin <> "\nNative receipt " <> recipeReceipt (cRecipe c)
+ <> "\nCompiler " <> recipeCompiler (cRecipe c) <> "\n")
+
+-- Prepare only explicitly needed executable inputs. Claimed outputs and receipts
+-- remain in the pack, not the fresh build store. No producer caches are copied.
+prepareRebuild :: FilePath -> Text -> Text -> FilePath -> IO Recipe
+prepareRebuild directory pin trusted output = do
+ c <- readPack directory pin
+ let recipe = cRecipe c
+ unless (trusted == recipeCompiler recipe) $ fail "--trust-compiler must equal the retained compiler manifest; verification alone does not authorize execution"
+ createDirectoryIfMissing True (takeDirectory output)
+ createDirectory output
+ let workspace = output </> "workspace"
+ objects = cObjects c
+ obj d p = maybe (fail "missing retained object") pure (M.lookup (unDomain d,p) objects)
+ seed target manifest = do
+ bytes <- obj manifestDomain manifest
+ m <- either fail pure (decodeManifest bytes)
+ _ <- putObject target manifestDomain bytes
+ forM_ (moduleManifestExports m) $ \ex -> do
+ let ref = moduleExportObject ex; d = Domain (objectRefKind ref)
+ payload <- obj d (objectRefHash ref)
+ _ <- putObject target d payload
+ pure ()
+ createDirectory workspace
+ forM_ (M.toList (cReceipts c)) $ \(name,(_,r)) -> obj sourceDomain (rSource r) >>= BS.writeFile (workspace </> T.unpack name ++ ".tri")
+ case recipeProject recipe of
+ Nothing -> pure ()
+ Just (p,l) -> do
+ obj (Domain "arboricx.project-declaration.v1") p >>= BS.writeFile (workspace </> "tricu.project")
+ obj (Domain "arboricx.project-lock.v1") l >>= BS.writeFile (workspace </> "tricu.lock")
+ seed (StorePath (output </> "tools")) (recipeCompiler recipe)
+ forM_ (S.toList (cExternal c)) (seed (StorePath (output </> "application")))
+ pure recipe
+
+finishRebuild :: FilePath -> Text -> FilePath -> Recipe -> Text -> Text -> IO (Text,Bool)
+finishRebuild directory pin output recipe actual actualReceipt = do
+ c <- readPack directory pin
+ unless (recipe == cRecipe c) $ fail "build input pack changed during rebuild"
+ let expected = rOutput (snd (cReceipts c M.! recipeEntry recipe))
+ app = StorePath (output </> "application")
+ receiptBytes <- loadStore app receiptDomain actualReceipt
+ r <- either fail pure (parseReceipt receiptBytes)
+ unless (rOutput r == actual) $ fail "rebuilt receipt/output mismatch"
+ -- Authenticate the returned receipt's entire newly produced local graph,
+ -- using fresh sources/stores, not the expected receipts retained in the pack.
+ let tools = StorePath (output </> "tools")
+ workspace = output </> "workspace"
+ toolBytes <- loadStore tools manifestDomain (recipeCompiler recipe)
+ toolManifest <- either fail pure (decodeManifest toolBytes)
+ let toolPins = S.fromList ((unDomain manifestDomain,recipeCompiler recipe) :
+ [(objectRefKind ref,objectRefHash ref) | ex <- moduleManifestExports toolManifest, let ref = moduleExportObject ex])
+ load d p
+ | S.member (unDomain d,p) toolPins = loadStore tools d p
+ | d == optionsDomain = pure profile
+ | d == Domain "arboricx.project-declaration.v1" = bounded 65536 (workspace </> "tricu.project")
+ | d == Domain "arboricx.project-lock.v1" = bounded 65536 (workspace </> "tricu.lock")
+ | otherwise = loadStore app d p
+ actualClosure <- collect (recipe {recipeReceipt = actualReceipt, recipeUnits = []}) load
+ (\name _ -> bounded 1048576 (workspace </> T.unpack name ++ ".tri"))
+ forM_ (S.toList (cExternal actualClosure `S.union` S.fromList [rOutput unit | (_,unit) <- M.elems (cReceipts actualClosure)])) $ \m -> do
+ evidence <- I.inspectModule app m
+ unless (I.inspectionStatus evidence == "verified") $ fail "rebuilt receipt graph contains an unverified executable module"
+ before <- I.inspectModule (StorePath (directory </> "store")) expected
+ after <- I.inspectModule app actual
+ let names i = either (const M.empty) (M.fromList . map (\ex -> (moduleExportName ex,ex)) . moduleManifestExports) (I.inspectedManifest i)
+ left = names before; right = names after
+ rows = ["export " <> I.safeText name <> ": " <> compareExport name <> "\n" | name <- S.toAscList (M.keysSet left `S.union` M.keysSet right)]
+ compareExport name = case (M.lookup name left,M.lookup name right) of
+ (Nothing,_) -> "added"
+ (_,Nothing) -> "removed"
+ (Just a,Just b) | moduleExportAbi a /= moduleExportAbi b -> "ABI changed"
+ _ -> case (M.lookup name (I.inspectedExports before),M.lookup name (I.inspectedExports after)) of
+ (Just (Right a),Just (Right b)) -> either (const "unknown") (\same -> if same then "tree identical" else "tree different") (I.compareGraphs a b)
+ _ -> "unknown"
+ exact = actual == expected && actualReceipt == recipeReceipt recipe && I.inspectionStatus before == "verified" && I.inspectionStatus after == "verified"
+ report = (if exact then "Reproduced native module and receipt exactly.\n" else "Rebuild mismatch; not reproduced exactly.\n")
+ <> "Inputs " <> pin <> "\nExpected module " <> expected <> "\nActual module " <> actual
+ <> "\nExpected receipt " <> recipeReceipt recipe <> "\nActual receipt " <> actualReceipt <> "\n" <> T.concat rows
+ <> "Local observation, not publisher authentication or a signed attestation.\n"
+ P.writeAtomic True (output </> "rebuild.txt") (encodeUtf8 report)
+ pure (report,exact)
diff --git a/src/Package.hs b/src/Package.hs
@@ -0,0 +1,281 @@
+-- Exact-version registry policy. Compilation never imports this module.
+module Package
+ ( Release(..), PackageLock(..), encodeRelease, decodeRelease
+ , encodeLock, decodeLock, validComponent, validateRegistryURL
+ , publishRelease, resolveRelease, fetchLocked, bindLocked
+ , readSmall, readRegistries, writeAtomic, distinctFiles
+ , resolveSelection, fetchSelection, verifyLocal, bindingBytes
+ ) where
+
+import ContentStore.Object
+import ContentStore.Alias (ObjectRef(..))
+import ContentStore.Bundle
+import Module.Manifest
+import Control.Exception (IOException, bracket, catch)
+import Control.Monad (unless, when, forM_)
+import Crypto.Hash (hash, Digest, SHA256)
+import qualified Data.ByteString as BS
+import qualified Data.Text as T
+import Data.Text (Text)
+import Data.Text.Encoding (encodeUtf8, decodeUtf8')
+import qualified Data.Set as Set
+import Network.HTTP.Client hiding (path)
+import Network.HTTP.Client.TLS (tlsManagerSettings)
+import Network.HTTP.Types.Status (statusCode)
+import Network.URI (parseURI, URI(..), URIAuth(..))
+import System.Directory (createDirectoryIfMissing, doesFileExist, removeFile, renameFile, canonicalizePath)
+import System.FilePath ((</>), takeDirectory)
+import System.IO (openBinaryTempFile, hClose)
+import qualified System.IO
+import System.IO.Error (isAlreadyExistsError)
+import System.IO.Temp (withSystemTempDirectory)
+import System.Posix.Files (createLink)
+import System.Posix.IO (openFd, closeFd, defaultFileFlags, OpenMode(ReadOnly))
+import System.Posix.Unistd (fileSynchronise)
+import System.Timeout (timeout)
+
+-- v1 deliberately denotes one native module, not a general package graph.
+data Release = Release
+ { releaseName :: Text, releaseVersion :: Text
+ , releaseManifest :: Text, releaseBundle :: Text
+ } deriving (Eq, Show)
+data PackageLock = PackageLock
+ { lockRegistry :: Text, lockURL :: Text, lockRelease :: Release
+ } deriving (Eq, Show)
+
+validComponent :: Text -> Bool
+validComponent s = not (T.null s) && T.length s <= 128
+ && asciiAlphaNum (T.head s) && T.all (\c -> asciiAlphaNum c || c `elem` ("._-" :: String)) s
+ where asciiAlphaNum c = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
+
+validateRelease :: Release -> Either String ()
+validateRelease r = do
+ unless (validComponent (releaseName r) && validComponent (releaseVersion r)) $
+ Left "invalid package name/version (1-128 ASCII letters/digits/._-, starting with letter/digit)"
+ validateObjectHash (releaseManifest r)
+ validateObjectHash (releaseBundle r)
+
+releaseRow :: Release -> Text
+releaseRow r = T.intercalate "\t" [releaseName r, releaseVersion r, releaseManifest r, releaseBundle r]
+encodeRelease :: Release -> BS.ByteString
+encodeRelease r = encodeUtf8 $ "arboricx.package-release.v1\n" <> releaseRow r <> "\n"
+parseReleaseRow :: Text -> Either String Release
+parseReleaseRow row = case T.splitOn "\t" row of
+ [n,v,m,b] -> let r = Release n v m b in validateRelease r >> Right r
+ _ -> Left "invalid package release row"
+decodeRelease :: BS.ByteString -> Either String Release
+decodeRelease bytes = do
+ txt <- either (Left . show) Right (decodeUtf8' bytes)
+ r <- case T.lines txt of
+ ["arboricx.package-release.v1", row] -> parseReleaseRow row
+ _ -> Left "unsupported package release format"
+ unless (encodeRelease r == bytes) $ Left "noncanonical package release"
+ pure r
+
+-- Origin-only URLs: no userinfo, paths, queries, fragments or implicit redirects.
+-- HTTP is allowed only for literal loopback development origins.
+validateRegistryURL :: Text -> Either String ()
+validateRegistryURL url = case parseURI (T.unpack url) of
+ Just u | Just a <- uriAuthority u
+ , null (uriUserInfo a), not (null (uriRegName a))
+ , null (uriPath u), null (uriQuery u), null (uriFragment u)
+ , all (\c -> c > ' ' && c < '\127') (T.unpack url)
+ , uriScheme u == "https:" || (uriScheme u == "http:" && uriRegName a `elem` ["localhost", "127.0.0.1", "[::1]"])
+ -> Right ()
+ _ -> Left "registry URL must be an HTTPS origin without trailing slash (HTTP only for loopback)"
+
+encodeLock :: PackageLock -> BS.ByteString
+encodeLock l = encodeUtf8 $ "arboricx.package-lock.v1\n" <> lockRegistry l <> "\t" <> lockURL l <> "\n" <> releaseRow (lockRelease l) <> "\n"
+decodeLock :: BS.ByteString -> Either String PackageLock
+decodeLock bytes = do
+ txt <- either (Left . show) Right (decodeUtf8' bytes)
+ l <- case T.lines txt of
+ ["arboricx.package-lock.v1", origin, row] -> case T.splitOn "\t" origin of
+ [name,url] -> do
+ unless (validComponent name) $ Left "invalid registry name"
+ validateRegistryURL url
+ PackageLock name url <$> parseReleaseRow row
+ _ -> Left "invalid lock origin"
+ _ -> Left "unsupported package lock format"
+ unless (encodeLock l == bytes) $ Left "noncanonical package lock"
+ pure l
+
+readSmall :: FilePath -> IO BS.ByteString
+readSmall path = do
+ -- Metadata is tiny; cap reads, including untrusted lock/config files.
+ bracket (System.IO.openBinaryFile path System.IO.ReadMode) hClose $ \h -> do
+ bytes <- BS.hGet h 65537
+ when (BS.length bytes > 65536) $ fail "metadata exceeds 64 KiB"
+ pure bytes
+
+registryURL :: FilePath -> Text -> IO Text
+registryURL config selected = do
+ entries <- readRegistries config
+ maybe (fail "registry is not explicitly trusted in configuration") pure (lookup selected entries)
+
+readRegistries :: FilePath -> IO [(Text, Text)]
+readRegistries config = do
+ bytes <- readSmall config
+ txt <- either (fail . show) pure (decodeUtf8' bytes)
+ rows <- case T.lines txt of
+ "arboricx.registries.v1" : rest -> pure rest
+ _ -> fail "unsupported registry configuration"
+ entries <- mapM (\row -> case T.splitOn "\t" row of
+ [name,url] | validComponent name -> either fail pure (validateRegistryURL url) >> pure (name,url)
+ _ -> fail "invalid registry configuration row") rows
+ unless (Set.size (Set.fromList (map fst entries)) == length entries) $ fail "duplicate registry name"
+ pure entries
+
+syncPath :: FilePath -> IO ()
+syncPath path = bracket (openFd path ReadOnly defaultFileFlags) closeFd fileSynchronise
+
+-- Trusted directories only. No-replace handles concurrent publishers, checking
+-- the winner byte-for-byte; mutable locks/bindings use explicit atomic replacement.
+writeAtomic :: Bool -> FilePath -> BS.ByteString -> IO ()
+writeAtomic exclusive path bytes = do
+ createDirectoryIfMissing True (takeDirectory path)
+ bracket (openBinaryTempFile (takeDirectory path) ".package-")
+ (\(tmp,h) -> do
+ hClose h `catch` (\(_ :: IOException) -> pure ())
+ removeFile tmp `catch` (\(_ :: IOException) -> pure ())) $ \(tmp,h) -> do
+ BS.hPut h bytes
+ hClose h
+ syncPath tmp
+ if exclusive then createLink tmp path `catch` (\e ->
+ if isAlreadyExistsError e then do
+ existing <- BS.readFile path
+ unless (existing == bytes) $ fail "immutable publication conflict"
+ syncPath path
+ else ioError e)
+ else renameFile tmp path
+ syncPath (takeDirectory path)
+
+bundleDigest :: BS.ByteString -> Text
+bundleDigest bytes = hashToText (hash bytes :: Digest SHA256)
+
+-- Match the native reader's ASCII source-name profile before advertising a
+-- release. Import writes immutable objects only, never ambient aliases.
+importModule :: StorePath -> BS.ByteString -> IO Text
+importModule store bytes = do
+ roots <- unpackIndexedBundleToStore store bytes
+ unless (not (null roots) && all (validExport . fst) roots) $ fail "package requires a nonempty native module interface"
+ putManifest store (ModuleManifest []
+ [ModuleExport name (ObjectRef (unDomain indexedTreeDomain) digest) "arboricx.abi.tree.v1" | (name,digest) <- roots])
+ where
+ validExport n = case T.uncons n of
+ Just (c,rest) -> start c && T.all (\x -> start x || (x >= '0' && x <= '9') || x `elem` ("-?$%'." :: String)) rest
+ _ -> False
+ start c = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
+
+publishRelease :: FilePath -> Text -> Text -> FilePath -> IO Release
+publishRelease root name version bundleFile = do
+ unless (validComponent name && validComponent version) $ fail "invalid package name/version"
+ bytes <- bracket (System.IO.openBinaryFile bundleFile System.IO.ReadMode) hClose $ \h -> BS.hGet h (bundleLimit + 1)
+ when (BS.length bytes > bundleLimit) $ fail "bundle exceeds 64 MiB transport limit"
+ manifest <- withSystemTempDirectory "tricu-package" $ \tmp -> importModule (StorePath tmp) bytes
+ let digest = bundleDigest bytes
+ r = Release name version manifest digest
+ writeAtomic True (root </> "objects" </> T.unpack (T.take 3 digest) </> T.unpack digest ++ ".arboricx") bytes
+ writeAtomic True (root </> "packages" </> T.unpack name </> T.unpack version) (encodeRelease r)
+ pure r
+
+bundleLimit :: Int
+bundleLimit = 64 * 1024 * 1024
+
+getBounded :: Text -> Int -> IO BS.ByteString
+getBounded url limit = requestBytes `catch` (\(e :: HttpException) -> fail (show e))
+ where
+ requestBytes = do
+ reply <- timeout (60 * 1000000) $ bracket (newManager tlsManagerSettings) closeManager $ \manager -> do
+ initial <- parseRequest (T.unpack url)
+ let request = initial { redirectCount = 0, responseTimeout = responseTimeoutMicro (30 * 1000000)
+ , decompress = const False }
+ withResponse request manager $ \response -> do
+ unless (statusCode (responseStatus response) == 200) $ fail "registry returned non-200 status (redirects are not followed)"
+ let loop n chunks = do
+ chunk <- brRead (responseBody response)
+ let total = n + BS.length chunk
+ when (total > limit) $ fail "registry response exceeds size limit"
+ if BS.null chunk then pure (BS.concat (reverse chunks)) else loop total (chunk:chunks)
+ loop 0 []
+ maybe (fail "registry request deadline exceeded") pure reply
+
+resolveRelease :: FilePath -> Text -> Text -> Text -> FilePath -> Bool -> IO PackageLock
+resolveRelease config registry name version path update = do
+ distinctFiles config path
+ unless (validComponent name && validComponent version) $ fail "invalid package name/version"
+ url <- registryURL config registry
+ exists <- doesFileExist path
+ old <- if exists then Just <$> (readSmall path >>= either fail pure . decodeLock) else pure Nothing
+ l <- resolveSelection registry url name version
+ case old of
+ Just previous | previous /= l -> do
+ let p = lockRelease previous
+ when (lockRegistry previous == registry && lockURL previous == url && releaseName p == name && releaseVersion p == version) $
+ fail "published version changed; refusing registry equivocation"
+ unless update $ fail "lock selection changed; explicit --update required"
+ _ -> pure ()
+ writeAtomic (not exists) path (encodeLock l)
+ pure l
+
+-- Parsed selections let project operations share transport/verification policy
+-- without manufacturing temporary single-package lock files.
+resolveSelection :: Text -> Text -> Text -> Text -> IO PackageLock
+resolveSelection registry url name version = do
+ unless (validComponent registry && validComponent name && validComponent version) $ fail "invalid registry/package/version"
+ either fail pure (validateRegistryURL url)
+ r <- getBounded (url <> "/_arboricx/package/" <> name <> "/" <> version) 4096 >>= either fail pure . decodeRelease
+ unless (releaseName r == name && releaseVersion r == version) $ fail "registry returned a different package/version"
+ pure (PackageLock registry url r)
+
+verifyLocal :: StorePath -> Release -> IO Bool
+verifyLocal store r = do
+ found <- getManifest store (releaseManifest r)
+ case found of
+ Nothing -> pure False
+ Just m -> do
+ unless (null (moduleManifestReferences m) && not (null (moduleManifestExports m))) $ fail "unsupported package module profile"
+ forM_ (moduleManifestExports m) $ \ex -> do
+ unless (objectRefKind (moduleExportObject ex) == unDomain indexedTreeDomain && moduleExportAbi ex == "arboricx.abi.tree.v1") $ fail "unsupported package executable"
+ _ <- loadExecutableObject store (moduleExportObject ex)
+ pure ()
+ pure True
+
+fetchLocked :: FilePath -> FilePath -> StorePath -> IO PackageLock
+fetchLocked config path store = do
+ l <- readSmall path >>= either fail pure . decodeLock
+ url <- registryURL config (lockRegistry l)
+ fetchSelection url l store
+ pure l
+
+fetchSelection :: Text -> PackageLock -> StorePath -> IO ()
+fetchSelection url l store = do
+ unless (url == lockURL l) $ fail "configured registry origin differs from lock"
+ let r = lockRelease l
+ present <- verifyLocal store r
+ unless present $ do
+ bytes <- getBounded (url <> "/_arboricx/bundle/hash/" <> releaseBundle r) bundleLimit
+ unless (bundleDigest bytes == releaseBundle r) $ fail "bundle digest mismatch"
+ -- Check the promised module before importing anything into the target store.
+ actual <- withSystemTempDirectory "tricu-package-check" $ \tmp -> importModule (StorePath tmp) bytes
+ unless (actual == releaseManifest r) $ fail "bundle module manifest mismatch"
+ _ <- importModule store bytes
+ pure ()
+
+bindingBytes :: Text -> BS.ByteString
+bindingBytes manifest = encodeUtf8 ("!export * from " <> manifest <> "\n")
+
+bindLocked :: FilePath -> StorePath -> FilePath -> IO ()
+bindLocked path store output = do
+ distinctFiles path output
+ l <- readSmall path >>= either fail pure . decodeLock
+ present <- verifyLocal store (lockRelease l)
+ unless present $ fail "locked module missing; fetch it explicitly first"
+ writeAtomic False output (bindingBytes (releaseManifest (lockRelease l)))
+
+-- Output commands may replace their explicit destination, never their input.
+distinctFiles :: FilePath -> FilePath -> IO ()
+distinctFiles input output = do
+ source <- canonicalizePath input
+ target <- canonicalizePath output
+ when (source == target) $ fail "output must not replace its input"
diff --git a/src/Project.hs b/src/Project.hs
@@ -0,0 +1,372 @@
+-- Explicit project dependency preparation, never called by the compiler.
+module Project
+ ( Dependency(..), ProjectLock, Update(..), encodeProject, decodeProject
+ , encodeProjectLock, decodeProjectLock, validBinding
+ , resolveProject, fetchProject, bindProject, checkProject, explainProject, diffProject
+ ) where
+
+import qualified Package as P
+import qualified Module.Inspection as I
+import qualified Module.Manifest as MM
+import ContentStore.Alias (ObjectRef(..))
+import ContentStore.Object (StorePath, validateObjectHash)
+import Control.Exception (catch, IOException)
+import Control.Monad (unless, when, forM_, foldM)
+import qualified Data.ByteString as BS
+import qualified Data.Text as T
+import Data.Text (Text)
+import Data.Text.Encoding (encodeUtf8, decodeUtf8')
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+import Data.List (sortOn)
+import System.Directory (removeFile)
+import System.FilePath ((</>))
+import System.IO.Error (isDoesNotExistError)
+import System.Posix.Files (getSymbolicLinkStatus, isRegularFile)
+
+-- Aliases are flat workspace module names, not package names or filesystem paths.
+data Dependency = Dependency
+ { dependencyAlias :: Text, dependencyRegistry :: Text
+ , dependencyName :: Text, dependencyVersion :: Text
+ } deriving (Eq, Show)
+type ProjectLock = [(Text, P.PackageLock)]
+data Update = Frozen | Selected [Text] | All deriving (Eq, Show)
+
+validBinding :: Text -> Bool
+validBinding name = not (T.null name) && T.length name <= 128
+ && start (T.head name) && T.all (\c -> start c || digit c || c == '-') name
+ && not (T.length name == 64 && T.all (\c -> digit c || c `elem` ("abcdef" :: String)) name)
+ where
+ digit c = c >= '0' && c <= '9'
+ start c = c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
+
+aliasesValid :: [Text] -> Either String ()
+aliasesValid names = do
+ unless (length names <= 128 && all validBinding names) $ Left "invalid workspace binding name or more than 128 dependencies"
+ unless (Set.size (Set.fromList (map T.toCaseFold names)) == length names) $ Left "duplicate/case-colliding workspace binding"
+
+rows :: Text -> BS.ByteString -> Either String [Text]
+rows header bytes = do
+ when (BS.length bytes > 65536) $ Left "project metadata exceeds 64 KiB"
+ txt <- either (Left . show) Right (decodeUtf8' bytes)
+ case T.lines txt of
+ h:rest | h == header && encodeUtf8 (T.unlines (h:rest)) == bytes -> Right rest
+ _ -> Left "unsupported/noncanonical project metadata"
+
+encodeProject :: [Dependency] -> BS.ByteString
+encodeProject ds = encodeUtf8 $ T.unlines ("arboricx.project.v1" : map row ds)
+ where row d = T.intercalate "\t" [dependencyAlias d, dependencyRegistry d, dependencyName d, dependencyVersion d]
+decodeProject :: BS.ByteString -> Either String [Dependency]
+decodeProject bytes = do
+ rs <- rows "arboricx.project.v1" bytes
+ ds <- mapM (\r -> case T.splitOn "\t" r of
+ [a,g,n,v] | all P.validComponent [g,n,v] -> Right (Dependency a g n v)
+ _ -> Left "project rows require binding, registry, package and exact version (tabs)") rs
+ aliasesValid (map dependencyAlias ds)
+ pure (sortOn dependencyAlias ds)
+
+encodeProjectLock :: ProjectLock -> BS.ByteString
+encodeProjectLock ls = encodeUtf8 $ T.unlines ("arboricx.project-lock.v1" : map row (sortOn fst ls))
+ where row (a,l) = let r = P.lockRelease l in T.intercalate "\t"
+ [a,P.lockRegistry l,P.lockURL l,P.releaseName r,P.releaseVersion r,P.releaseManifest r,P.releaseBundle r]
+decodeProjectLock :: BS.ByteString -> Either String ProjectLock
+decodeProjectLock bytes = do
+ rs <- rows "arboricx.project-lock.v1" bytes
+ ls <- mapM (\r -> case T.splitOn "\t" r of
+ [a,g,u,n,v,m,b] -> do
+ l <- P.decodeLock (P.encodeLock (P.PackageLock g u (P.Release n v m b)))
+ pure (a,l)
+ _ -> Left "invalid project lock row") rs
+ aliasesValid (map fst ls)
+ unless (encodeProjectLock ls == bytes) $ Left "project lock must be canonical and sorted"
+ _ <- foldM remember Map.empty (map snd ls)
+ pure ls
+
+-- Registry labels are configuration handles. The immutable claim is identified
+-- by origin/name/version, even if the same origin has two configured labels.
+type Claim = (Text, Text, Text)
+type Bindings = [(Text, Text)]
+
+claim :: P.PackageLock -> Claim
+claim l = let r = P.lockRelease l in (P.lockURL l,P.releaseName r,P.releaseVersion r)
+remember :: Map.Map Claim P.Release -> P.PackageLock -> Either String (Map.Map Claim P.Release)
+remember seen l = case Map.lookup (claim l) seen of
+ Just old | old /= P.lockRelease l -> Left "published version changed; refusing registry equivocation"
+ _ -> Right (Map.insert (claim l) (P.lockRelease l) seen)
+selection :: (Text, P.PackageLock) -> Dependency
+selection (a,l) = let r = P.lockRelease l in Dependency a (P.lockRegistry l) (P.releaseName r) (P.releaseVersion r)
+
+readProject :: FilePath -> IO [Dependency]
+readProject file = P.readSmall file >>= either fail pure . decodeProject
+readLock :: FilePath -> IO ProjectLock
+readLock file = P.readSmall file >>= either fail pure . decodeProjectLock
+trusted :: [(Text, Text)] -> Text -> IO Text
+trusted entries name = maybe (fail ("registry is not explicitly trusted: " ++ T.unpack name)) pure (lookup name entries)
+consistent :: [Dependency] -> ProjectLock -> IO ()
+consistent ds ls = unless (ds == map selection ls) $ fail "project declaration differs from lock; resolve/update explicitly"
+
+-- Only regular files may be managed; dangling links and directories are errors.
+optionalFile :: FilePath -> IO (Maybe BS.ByteString)
+optionalFile path = (do
+ status <- getSymbolicLinkStatus path
+ unless (isRegularFile status) $ fail ("expected regular file, refusing symlink/directory: " ++ path)
+ Just <$> P.readSmall path) `catch` (\(e :: IOException) -> if isDoesNotExistError e then pure Nothing else ioError e)
+
+resolveProject :: FilePath -> FilePath -> FilePath -> Update -> IO Text
+resolveProject project lock config update = do
+ P.distinctFiles project lock
+ P.distinctFiles config lock
+ ds <- readProject project
+ previous <- optionalFile lock
+ old <- maybe (pure []) (either fail pure . decodeProjectLock) previous
+ entries <- P.readRegistries config
+ wanted <- mapM (\d -> do u <- trusted entries (dependencyRegistry d); pure (dependencyAlias d,(d,u))) ds
+ let oldMap = Map.fromList old
+ wantedMap = Map.fromList wanted
+ names = Set.toAscList (Map.keysSet oldMap `Set.union` Map.keysSet wantedMap)
+ selected <- case (previous,update) of
+ (Nothing,Frozen) -> pure names
+ (Nothing,_) -> fail "project lock missing; resolve first"
+ (Just _,Frozen) -> pure []
+ (Just _,All) -> pure names
+ (Just _,Selected ns) -> do
+ unless (not (null ns) && length ns == Set.size (Set.fromList ns) && all (`elem` names) ns) $
+ fail "update requires unique known --dependency bindings (or --all)"
+ pure ns
+ -- Reject every unintended addition/removal/change before any network request.
+ forM_ names $ \a -> unless (a `elem` selected) $ case (Map.lookup a wantedMap, Map.lookup a oldMap) of
+ (Just (d,u),Just l) | d == selection (a,l) && u == P.lockURL l -> pure ()
+ _ -> fail ("unselected dependency changed: " ++ T.unpack a ++ "; use project update --dependency " ++ T.unpack a)
+ known <- either fail pure (foldM remember Map.empty (map snd old))
+ (new,_,_) <- foldM (resolveOne selected oldMap) ([],known,Map.empty) wanted
+ let next = sortOn fst new
+ bytes = encodeProjectLock next
+ _ <- either fail pure (decodeProjectLock bytes)
+ when (previous /= Just bytes) $ P.writeAtomic (previous == Nothing) lock bytes
+ pure (describeChanges old next)
+ where
+ resolveOne selected oldMap (acc,known,fetched) (a,(d,u))
+ | a `notElem` selected = case Map.lookup a oldMap of
+ Just l -> pure ((a,l):acc,known,fetched)
+ Nothing -> fail "internal missing frozen dependency"
+ | otherwise = do
+ let key = (u,dependencyName d,dependencyVersion d)
+ r <- case Map.lookup key fetched of
+ Just release -> pure release
+ Nothing -> P.lockRelease <$> P.resolveSelection (dependencyRegistry d) u (dependencyName d) (dependencyVersion d)
+ let l = P.PackageLock (dependencyRegistry d) u r
+ known' <- either fail pure (remember known l)
+ pure ((a,l):acc,known',Map.insert key r fetched)
+
+-- Human-readable selection diff: metadata/label changes do not imply code changed.
+describeChanges :: ProjectLock -> ProjectLock -> Text
+describeChanges old new = if null differences then "Project lock unchanged.\n" else T.concat differences
+ where
+ before = Map.fromList old; after = Map.fromList new
+ names = Set.toAscList (Map.keysSet before `Set.union` Map.keysSet after)
+ details label l = let r = P.lockRelease l in
+ " " <> label <> ": " <> P.lockRegistry l <> " (" <> P.lockURL l <> ") " <> P.releaseName r <> "@" <> P.releaseVersion r
+ <> "\n module " <> P.releaseManifest r <> "\n bundle " <> P.releaseBundle r <> "\n"
+ identity x y = if x == y then "unchanged" else "changed"
+ differences = [render a b c | a <- names, let b = Map.lookup a before, let c = Map.lookup a after, b /= c]
+ render a Nothing (Just n) = "Added " <> a <> "\n" <> details "after" n
+ render a (Just o) Nothing = "Removed " <> a <> "\n" <> details "before" o
+ render a (Just o) (Just n) = "Updated " <> a <> "\n" <> details "before" o <> details "after" n
+ <> " module " <> identity (P.releaseManifest (P.lockRelease o)) (P.releaseManifest (P.lockRelease n))
+ <> "; bundle " <> identity (P.releaseBundle (P.lockRelease o)) (P.releaseBundle (P.lockRelease n)) <> "\n"
+ render _ _ _ = ""
+
+fetchProject :: FilePath -> FilePath -> FilePath -> StorePath -> IO ()
+fetchProject project lock config store = do
+ ds <- readProject project
+ ls <- readLock lock
+ consistent ds ls
+ entries <- P.readRegistries config
+ urls <- mapM (\(_,l) -> do
+ u <- trusted entries (P.lockRegistry l)
+ unless (u == P.lockURL l) $ fail "configured registry origin differs from project lock"
+ pure (u,l)) ls
+ -- Complete preflight before importing even the first immutable object.
+ forM_ urls $ \(u,l) -> P.fetchSelection u l store
+
+bindingState :: FilePath
+bindingState = ".arboricx-project-bindings"
+encodeBindings :: Bindings -> BS.ByteString
+encodeBindings bs = encodeUtf8 $ T.unlines ("arboricx.project-bindings.v1" : [a <> "\t" <> m | (a,m) <- sortOn fst bs])
+decodeBindings :: BS.ByteString -> Either String Bindings
+decodeBindings bytes = do
+ rs <- rows "arboricx.project-bindings.v1" bytes
+ bs <- mapM (\row -> case T.splitOn "\t" row of
+ [a,m] -> validateObjectHash m >> pure (a,m)
+ _ -> Left "invalid project binding state") rs
+ aliasesValid (map fst bs)
+ unless (encodeBindings bs == bytes) $ Left "noncanonical project binding state"
+ pure bs
+readBindings :: FilePath -> IO Bindings
+readBindings path = optionalFile path >>= maybe (pure []) (either fail pure . decodeBindings)
+bindings :: ProjectLock -> Bindings
+bindings ls = [(a,P.releaseManifest (P.lockRelease l)) | (a,l) <- ls]
+verifyModules :: StorePath -> ProjectLock -> IO ()
+verifyModules store ls = forM_ ls $ \(a,l) -> do
+ found <- P.verifyLocal store (P.lockRelease l)
+ unless found $ fail ("locked module missing for " ++ T.unpack a ++ "; fetch explicitly first")
+
+-- Trusted, exclusively owned workspaces. Batch writes are NOT atomic. A pending
+-- journal records ownership before touching files, enabling safe roll-forward
+-- after interruption and making `check` fail closed until binding completes.
+bindProject :: FilePath -> FilePath -> FilePath -> StorePath -> IO ()
+bindProject project lock directory store = do
+ ds <- readProject project
+ ls <- readLock lock
+ consistent ds ls
+ let state = directory </> bindingState
+ pending = state ++ ".pending"
+ target a = directory </> T.unpack a ++ ".tri"
+ next = bindings ls
+ old <- readBindings state
+ interrupted <- readBindings pending
+ let owned = old ++ interrupted
+ names = Set.toAscList (Set.fromList (map fst (owned ++ next)))
+ unless (Set.size (Set.fromList (map T.toCaseFold names)) == length names) $
+ fail "case-colliding old/new binding names; remove the old dependency and bind before adding the new name"
+ forM_ (state:pending:map target names) $ \output -> do
+ P.distinctFiles project output
+ P.distinctFiles lock output
+ verifyModules store ls
+ -- Never adopt or overwrite authored files, even if they match generated bytes.
+ forM_ names $ \a -> do
+ current <- optionalFile (target a)
+ case current of
+ Nothing -> pure ()
+ Just bytes -> unless (any (\(b,m) -> b == a && bytes == P.bindingBytes m) owned) $
+ fail ("unmanaged or edited binding; refusing overwrite/removal: " ++ target a)
+ -- Preserve ownership of all in-flight files if a second update interrupts a
+ -- recovery. Finish the earlier transaction first rather than losing its pins.
+ pendingBytes <- optionalFile pending
+ when (pendingBytes /= Nothing && interrupted /= next) $
+ fail "unfinished binding transaction; restore its project/lock and bind before another update"
+ P.writeAtomic False pending (encodeBindings next)
+ forM_ next $ \(a,m) -> P.writeAtomic False (target a) (P.bindingBytes m)
+ forM_ names $ \a -> unless (a `elem` map fst next) $
+ removeFile (target a) `catch` (\(e :: IOException) -> unless (isDoesNotExistError e) (ioError e))
+ P.writeAtomic False state (encodeBindings next)
+ removeFile pending
+
+checkProject :: FilePath -> FilePath -> FilePath -> StorePath -> IO ()
+checkProject project lock directory store = do
+ ds <- readProject project
+ ls <- readLock lock
+ consistent ds ls
+ let state = directory </> bindingState
+ next = bindings ls
+ pending <- optionalFile (state ++ ".pending")
+ when (pending /= Nothing) $ fail "unfinished binding transaction; run project bind"
+ actual <- optionalFile state
+ unless (actual == Just (encodeBindings next)) $ fail "project binding state differs from lock; run project bind"
+ forM_ next $ \(a,m) -> do
+ bytes <- optionalFile (directory </> T.unpack a ++ ".tri")
+ unless (bytes == Just (P.bindingBytes m)) $ fail ("missing/changed project binding: " ++ T.unpack a)
+ verifyModules store ls
+
+-- Reports never initialize a store, fetch objects or evaluate executable terms.
+explainProject :: FilePath -> FilePath -> FilePath -> StorePath -> IO (Text, Bool)
+explainProject project lock directory store = do
+ ds <- readProject project
+ ls <- readLock lock
+ state <- diagnosticFile (directory </> bindingState)
+ pending <- diagnosticFile (directory </> bindingState ++ ".pending")
+ let owned = state == Right (Just (encodeBindings (bindings ls)))
+ settled = pending == Right Nothing
+ ledger = if owned then "matches lock" else "missing, changed or unreadable"
+ transaction = if settled then "none" else "pending or unreadable; finish project bind"
+ declared = Map.fromList [(dependencyAlias d,d) | d <- ds]
+ locked = Map.fromList ls
+ names = Set.toAscList (Map.keysSet declared `Set.union` Map.keysSet locked)
+ reports <- mapM (explainOne declared locked) names
+ pure ("Offline project explanation (locked claims, not registry/provenance verification)\n"
+ <> "Ownership ledger: " <> ledger <> "\nBinding transaction: " <> transaction <> "\n"
+ <> T.concat (map fst reports), owned && settled && and (map snd reports))
+ where
+ explainOne declared locked a = case Map.lookup a locked of
+ Nothing -> pure ("Binding " <> a <> ": declared but not locked\n" <> declaredLine (Map.lookup a declared),False)
+ Just l -> do
+ evidence <- I.inspectModule store (P.releaseManifest (P.lockRelease l))
+ actual <- diagnosticFile (directory </> T.unpack a ++ ".tri")
+ let matches = actual == Right (Just (P.bindingBytes (P.releaseManifest (P.lockRelease l))))
+ selectionMatches = Map.lookup a declared == Just (selection (a,l))
+ sourceStatus = case actual of
+ Left err -> "unavailable: " <> I.safeText err
+ Right Nothing -> "missing"
+ Right (Just _) -> if matches then "matches lock" else "differs from lock"
+ exportLines = [" export " <> I.safeText n <> ": " <> either I.safeText (const "verified") v <> "\n"
+ | (n,v) <- Map.toAscList (I.inspectedExports evidence)]
+ finishReport ("Binding " <> a <> "\n" <> declaredLine (Map.lookup a declared) <> selectionDetails l
+ <> " declaration: " <> (if selectionMatches then "matches lock" else "differs from lock or absent")
+ <> "\n generated binding: " <> sourceStatus <> "\n local module: " <> I.safeText (I.inspectionStatus evidence) <> "\n"
+ <> T.concat exportLines, matches && selectionMatches && I.inspectionStatus evidence == "verified")
+
+ declaredLine Nothing = " declared: absent\n"
+ declaredLine (Just d) = " declared: " <> dependencyRegistry d <> " " <> dependencyName d <> "@" <> dependencyVersion d <> "\n"
+
+diagnosticFile :: FilePath -> IO (Either Text (Maybe BS.ByteString))
+diagnosticFile path = (Right <$> optionalFile path) `catch` (\(e :: IOException) -> pure (Left (T.pack (show e))))
+
+selectionDetails :: P.PackageLock -> Text
+selectionDetails l = let r = P.lockRelease l in
+ " selection: " <> P.lockRegistry l <> " (" <> P.lockURL l <> ") " <> P.releaseName r <> "@" <> P.releaseVersion r
+ <> "\n module: " <> P.releaseManifest r <> "\n bundle: " <> P.releaseBundle r <> "\n"
+
+diffProject :: FilePath -> FilePath -> StorePath -> IO (Text, Bool)
+diffProject before after store = do
+ old <- readLock before
+ new <- readLock after
+ let om = Map.fromList old; nm = Map.fromList new
+ names = Set.toAscList (Map.keysSet om `Set.union` Map.keysSet nm)
+ reports <- mapM (diffOne om nm) names
+ pure ("Offline project comparison; structural differences do not prove behavioral differences.\n"
+ <> describeChanges old new <> T.concat (map fst reports), and (map snd reports))
+ where
+ diffOne om nm a = do
+ left <- traverse (I.inspectModule store . P.releaseManifest . P.lockRelease) (Map.lookup a om)
+ right <- traverse (I.inspectModule store . P.releaseManifest . P.lockRelease) (Map.lookup a nm)
+ let status Nothing = "not selected"
+ status (Just e) = I.inspectionStatus e
+ complete Nothing = True
+ complete (Just e) = I.inspectionStatus e == "verified"
+ heading = "Binding " <> a <> "\n selection: " <> (if Map.lookup a om == Map.lookup a nm then "unchanged" else "changed") <> "\n before evidence: " <> I.safeText (status left)
+ <> "\n after evidence: " <> I.safeText (status right) <> "\n"
+ interface Nothing = Just Map.empty
+ interface (Just e) = either (const Nothing) (Just . Map.fromList . map (\ex -> (MM.moduleExportName ex,ex)) . MM.moduleManifestExports) (I.inspectedManifest e)
+ detail = case (interface left,interface right) of
+ (Just l,Just r) -> T.concat [exportChange left right n (Map.lookup n l) (Map.lookup n r)
+ | n <- Set.toAscList (Map.keysSet l `Set.union` Map.keysSet r)]
+ _ -> " interface/structure: unknown (module evidence unavailable)\n"
+ finishReport (heading <> detail, complete left && complete right)
+ exportChange _ right n Nothing (Just _) = " export " <> I.safeText n <> ": added; evidence " <> exportStatus right n <> "\n"
+ exportChange left _ n (Just _) Nothing = " export " <> I.safeText n <> ": removed; evidence " <> exportStatus left n <> "\n"
+ exportChange left right n (Just l) (Just r) =
+ let same x y = if x == y then "unchanged" else "changed"
+ ref ex = MM.moduleExportObject ex
+ evidence item = item >>= Map.lookup n . I.inspectedExports
+ structure = case (evidence left,evidence right) of
+ (Just (Right x),Just (Right y)) -> either (\err -> "unknown: " <> I.safeText (T.pack err))
+ (\equal -> if equal then "identical" else "different") (I.compareGraphs x y)
+ (x,y) -> "unknown (before: " <> reason x <> "; after: " <> reason y <> ")"
+ reason Nothing = "unavailable"
+ reason (Just (Left err)) = I.safeText err
+ reason (Just (Right _)) = "verified"
+ in " export " <> I.safeText n <> ": ABI " <> same (MM.moduleExportAbi l) (MM.moduleExportAbi r)
+ <> " [" <> I.safeText (MM.moduleExportAbi l) <> " -> " <> I.safeText (MM.moduleExportAbi r) <> "]"
+ <> "; kind " <> same (objectRefKind (ref l)) (objectRefKind (ref r))
+ <> "; object " <> same (objectRefHash (ref l)) (objectRefHash (ref r))
+ <> "; tree " <> structure <> "\n"
+ exportChange _ _ _ _ _ = ""
+ exportStatus item n = case item >>= Map.lookup n . I.inspectedExports of
+ Just (Right _) -> "verified"
+ Just (Left err) -> I.safeText err
+ Nothing -> "unknown"
+
+-- Materialize each strict Text report before visiting the next dependency, so
+-- lazy report construction does not retain every dependency's parsed graphs.
+finishReport :: (Text, Bool) -> IO (Text, Bool)
+finishReport (report, complete) = report `seq` complete `seq` pure (report, complete)
diff --git a/src/TreeObservation.hs b/src/TreeObservation.hs
@@ -0,0 +1,31 @@
+-- Full-result observation is deliberately separate from normal evaluation.
+module TreeObservation (forceTreeShared) where
+
+import Control.Exception (evaluate)
+import qualified Data.IntMap.Strict as IntMap
+import Research (T(..))
+import System.Mem.StableName (StableName, makeStableName, hashStableName)
+
+data Visit = Enter T | Leave (StableName T)
+
+-- | Force a finite tree, avoiding repeated traversal of physically shared nodes.
+-- Names are taken after WHNF; hash collisions are resolved by name equality.
+-- Only completed subtrees are cached, so cycles are not mistaken for finite
+-- normal forms. Infinite inputs still require an external resource budget.
+-- The memo is local, and the explicit work stack avoids host recursion by depth.
+forceTreeShared :: T -> IO ()
+forceTreeShared root = go IntMap.empty [Enter root]
+ where
+ go _ [] = pure ()
+ go seen (Leave name : rest) =
+ go (IntMap.insertWith (++) (hashStableName name) [name] seen) rest
+ go seen (Enter input : rest) = do
+ tree <- evaluate input
+ name <- makeStableName tree
+ if name `elem` IntMap.findWithDefault [] (hashStableName name) seen
+ then go seen rest
+ else let done = Leave name : rest
+ in case tree of
+ Leaf -> go seen done
+ Stem child -> go seen (Enter child : done)
+ Fork left right -> go seen (Enter left : Enter right : done)
diff --git a/test/InspectorOracle.hs b/test/InspectorOracle.hs
@@ -0,0 +1,37 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- Test-only independent report oracle. Never load a tricu inspector export.
+module Main where
+
+import Wire
+import Crypto.Hash (hash, Digest, SHA256)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Char8 as B8
+import qualified Data.Foldable as F
+import Data.Text.Encoding (encodeUtf8)
+import Numeric (showHex)
+import System.Environment (getArgs)
+import System.Exit (die)
+
+field :: BS.ByteString -> BS.ByteString
+field = B8.pack . concatMap escape . BS.unpack
+ where
+ escape b | b < 32 || b >= 127 || b == 37 = '%' : pad (showHex b "")
+ | otherwise = [toEnum (fromIntegral b)]
+ pad s = map upper (replicate (2 - length s) '0' ++ s)
+ upper c | c >= 'a' && c <= 'f' = toEnum (fromEnum c - 32)
+ | otherwise = c
+
+main :: IO ()
+main = do
+ args <- getArgs
+ path <- case args of [p] -> pure p; _ -> die "usage: inspector-oracle BUNDLE"
+ bytes <- BS.readFile path
+ bundle <- either die pure (decodeBundle bytes)
+ either die pure (verifyBundle bundle)
+ B8.putStrLn "arboricx.inspect.v1"
+ B8.putStrLn ("bundle-sha256\t" <> B8.pack (show (hash bytes :: Digest SHA256)))
+ B8.putStrLn ("stored-nodes\t" <> B8.pack (show (F.length (bundleNodes bundle))))
+ mapM_ (B8.putStrLn . row) (manifestExports (bundleManifest bundle))
+ where
+ row e = B8.intercalate "\t" ["export", field (encodeUtf8 (exportName e)),
+ B8.pack (show (exportRoot e)), field (encodeUtf8 (exportKind e)), field (encodeUtf8 (exportAbi e))]
diff --git a/test/PackageTests.hs b/test/PackageTests.hs
@@ -0,0 +1,125 @@
+module PackageTests (packageTests) where
+
+import qualified Package as P
+import qualified Project as J
+import qualified Module.Inspection as I
+import qualified Module.Manifest as MM
+import qualified Data.Map.Strict as Map
+import ContentStore.Object (StorePath(..))
+import ContentStore.Filesystem (putObject)
+import ContentStore.Bundle (indexedTreeDomain)
+import ContentStore.Alias (ObjectRef(..))
+import qualified Wire as W
+import qualified Data.Sequence as Seq
+import qualified Data.ByteString as BS
+import qualified Data.Text as T
+import Wire (buildBundleShared, encodeBundle)
+import Research (T(..), ofNumber)
+import System.IO.Temp (withSystemTempDirectory)
+import System.FilePath ((</>))
+import System.Directory (doesFileExist)
+import Control.Exception (try, IOException)
+import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar)
+import Control.Monad (forM, forM_)
+import Test.Tasty
+import Test.Tasty.HUnit
+
+packageTests :: TestTree
+packageTests = testGroup "Exact package releases"
+ [ testCase "canonical release and lock codecs" $ do
+ let r = P.Release "collections" "0.1.0" (T.replicate 64 "a") (T.replicate 64 "b")
+ l = P.PackageLock "home" "https://example.com" r
+ P.decodeRelease (P.encodeRelease r) @?= Right r
+ P.decodeLock (P.encodeLock l) @?= Right l
+ forM_ [BS.init (P.encodeRelease r), P.encodeRelease r <> "\n", "arboricx.package-release.v2\n"] $ \bytes ->
+ assertBool "reject noncanonical release" (isLeft (P.decodeRelease bytes))
+ assertBool "reject noncanonical lock" (isLeft (P.decodeLock (P.encodeLock l <> "\n")))
+ , testCase "path components and explicit origins" $ do
+ forM_ ["", ".", "..", "../x", "x/y", "x:y", "x%2fy", "a\tb", T.replicate 129 "a", "é"] $ \s ->
+ assertBool (show s) (not (P.validComponent s))
+ forM_ ["a", "0.1.0", "a-b_c.d"] $ \s -> assertBool (show s) (P.validComponent s)
+ forM_ ["http://example.com", "https://example.com/", "https://user@example.com", "https://example.com?q=x", "file:///tmp/x", "https://example.com#x"] $ \u ->
+ assertBool (show u) (isLeft (P.validateRegistryURL u))
+ forM_ ["https://example.com", "http://127.0.0.1:9050", "http://[::1]:9050"] $ \u -> P.validateRegistryURL u @?= Right ()
+ , testCase "immutable publication including racing publishers" $ withSystemTempDirectory "package-tests" $ \tmp -> do
+ bytes <- encodeBundle <$> buildBundleShared [("value", ofNumber 42)]
+ let file = tmp </> "library.arboricx"
+ root = tmp </> "registry"
+ BS.writeFile file bytes
+ r <- P.publishRelease root "library" "1.0" file
+ P.publishRelease root "library" "1.0" file >>= (@?= r)
+ vars <- forM [1..4 :: Int] $ \_ -> do
+ var <- newEmptyMVar
+ _ <- forkIO $ (try (P.publishRelease root "library" "2.0" file) :: IO (Either IOException P.Release)) >>= putMVar var
+ pure var
+ results <- mapM takeMVar vars
+ forM_ results $ \result -> case result of
+ Left e -> assertFailure (show e)
+ Right actual -> P.releaseManifest actual @?= P.releaseManifest r
+ changed <- encodeBundle <$> buildBundleShared [("value", ofNumber 43)]
+ BS.writeFile file changed
+ failed <- try (P.publishRelease root "library" "1.0" file) :: IO (Either IOException P.Release)
+ assertBool "reject replacement" (isLeft failed)
+ BS.readFile (root </> "packages/library/1.0") >>= (@?= P.encodeRelease r)
+ exists <- doesFileExist (root </> "objects" </> T.unpack (T.take 3 (P.releaseBundle r)) </> T.unpack (P.releaseBundle r) ++ ".arboricx")
+ assertBool "bundle published before index" exists
+ , testCase "reject malformed bundle before index publication" $ withSystemTempDirectory "package-tests" $ \tmp -> do
+ BS.writeFile (tmp </> "bad") "not a bundle"
+ failed <- try (P.publishRelease tmp "bad" "1" (tmp </> "bad")) :: IO (Either IOException P.Release)
+ assertBool "invalid bundle fails" (isLeft failed)
+ exists <- doesFileExist (tmp </> "packages/bad/1")
+ assertBool "no release advertised" (not exists)
+ , testCase "project codecs and portable flat binding names" $ do
+ let ds = [J.Dependency "foundation" "home" "base" "1.0"]
+ r = P.Release "base" "1.0" (T.replicate 64 "a") (T.replicate 64 "b")
+ ls = [("foundation", P.PackageLock "home" "https://example.com" r)]
+ J.decodeProject (J.encodeProject ds) @?= Right ds
+ J.decodeProjectLock (J.encodeProjectLock ls) @?= Right ls
+ J.decodeProject "arboricx.project.v1\n" @?= Right []
+ forM_ ["", "..", "a/b", "a.b", "9abc", "a\tb", "é", T.replicate 64 "a", T.replicate 129 "x"] $ \a ->
+ assertBool (show a) (not (J.validBinding a))
+ forM_ ["a", "_foo", "foo-bar", "With42"] $ \a -> assertBool (show a) (J.validBinding a)
+ forM_ [BS.init (J.encodeProject ds), J.encodeProject ds <> "\n",
+ "arboricx.project.v2\n", "arboricx.project.v1\nfoo\thome\tbase\t*\n",
+ "arboricx.project.v1\nfoo\thome\tbase\t1\nFoo\thome\tbase\t1\n",
+ J.encodeProject [J.Dependency ("d" <> T.pack (show n)) "home" "base" "1" | n <- [1..129 :: Int]]] $ \bytes ->
+ assertBool "reject malformed declaration" (isLeft (J.decodeProject bytes))
+ let l = snd (head ls)
+ forM_ [J.encodeProjectLock ls <> "\n", J.encodeProjectLock [("a",l),("A",l)],
+ J.encodeProjectLock [("a",l),("b",l {P.lockRelease = r {P.releaseBundle = T.replicate 64 "c"}})],
+ J.encodeProjectLock [("a",l {P.lockURL = "http://remote.example"})]] $ \bytes ->
+ assertBool "reject malformed/conflicting lock" (isLeft (J.decodeProjectLock bytes))
+ , testCase "DAG structural comparison agrees with small independent tree equality" $ do
+ let trees = [Leaf, Stem Leaf, Fork Leaf Leaf, Stem (Stem Leaf),
+ Fork (Stem Leaf) Leaf, Fork Leaf (Stem Leaf)]
+ forM_ trees $ \a -> forM_ trees $ \b ->
+ I.compareGraphs (W.buildBundle [("root",a)]) (W.buildBundle [("root",b)]) @?= Right (a == b)
+ , testCase "DAG comparison ignores physical ordering but preserves child order" $ do
+ let left = W.buildBundle [("root", Fork (Stem Leaf) (Fork Leaf Leaf))]
+ right = left {W.bundleNodes = Seq.fromList [W.BNLeaf,W.BNFork 0 0,W.BNStem 0,W.BNFork 2 1]}
+ different = right {W.bundleNodes = Seq.fromList [W.BNLeaf,W.BNFork 0 0,W.BNStem 0,W.BNFork 1 2]}
+ W.bundleRoots left @?= [3]
+ I.compareGraphs left right @?= Right True
+ I.compareGraphs left different @?= Right False
+ assertBool "reject cycles before structural indexing" (isLeft (I.compareGraphs left (right {W.bundleNodes = Seq.singleton (W.BNStem 0)})))
+
+ , testCase "inspection byte budget counts distinct retained objects" $ withSystemTempDirectory "inspection-budget" $ \tmp -> do
+ let store = StorePath tmp
+ first = W.encodeBundle (W.buildBundle [("root", Leaf)])
+ a <- putObject store indexedTreeDomain first
+ b <- putObject store indexedTreeDomain (W.encodeBundle (W.buildBundle [("root", Stem Leaf)]))
+ let ex n pin = MM.ModuleExport n (ObjectRef "arboricx.indexed-tree.v1" pin) "arboricx.abi.tree.v1"
+ pin <- MM.putManifest store (MM.ModuleManifest [] [ex "first" a,ex "same" a,ex "next" b])
+ evidence <- I.inspectModuleWithBudget (BS.length first) store pin
+ let status name = case Map.lookup name (I.inspectedExports evidence) of
+ Just (Right _) -> "verified"
+ Just (Left err) -> err
+ Nothing -> "absent"
+ status "first" @?= "verified"
+ status "same" @?= "verified"
+ assertBool "next object unavailable rather than unbounded allocation" ("unavailable:" `T.isPrefixOf` status "next")
+
+ ]
+ where
+ isLeft (Left _) = True
+ isLeft _ = False
diff --git a/test/PatternLowering.hs b/test/PatternLowering.hs
@@ -0,0 +1,135 @@
+-- Opt-in candidate controls. Built through Nix by pattern_lowering.py.
+-- The reference compiler constructs independent authored oracles, never candidate expectations.
+module Main where
+import Research
+import Wire
+import TreeObservation
+import qualified Data.ByteString as BS
+import qualified Data.Text as Text
+import qualified Data.Vector as V
+import qualified Data.Map as Map
+import qualified Data.Aeson as JSON
+import Control.Monad
+import Control.Exception
+import System.Directory
+import Data.List (isSuffixOf,isPrefixOf)
+import System.Environment
+
+load path = do
+ bytes <- BS.readFile path
+ bundle <- either error pure (decodeBundle bytes)
+ either error pure (verifyBundle bundle)
+ let nodes = reconstructBundleTerms(bundleNodes bundle)
+ pure $ Map.fromList [(Text.unpack(exportName ex),nodes V.! fromIntegral(exportRoot ex))|ex<-manifestExports(bundleManifest bundle)]
+bindings (Fork (Stem Leaf) (Fork value _)) = case toList value of
+ Right rows -> Map.fromList [(either error id (toString name),term)|Fork name term<-rows]
+ _ -> error "invalid definition list"
+bindings (Fork Leaf (Fork diagnostic _)) = error ("compile failure: "++formatT Decode diagnostic)
+bindings other = error ("invalid compiler reply: "++show other)
+compile unit env source = apply (apply unit env) (ofString source)
+selected reply = case reply of
+ Fork (Stem Leaf) (Fork _ _) -> Fork (Stem Leaf) (Fork (bindings reply Map.! "main") Leaf)
+ Fork Leaf (Fork problem _) -> Fork Leaf (Fork problem Leaf)
+ _ -> error "invalid compiler reply"
+
+main = do
+ [directory] <- getArgs
+ candidate <- load(directory++"/source_unit.arboricx")
+ reference <- load "test/fixtures/compiler-library.arboricx"
+ let unit = candidate Map.! "compileStableModuleDefinitions"
+ old = reference Map.! "source_unit.compileStableModuleDefinitions"
+ helpers = ofList [Fork (ofString (drop (length prefix) name)) term | (name,term)<-Map.toList reference, prefix<-["prelude.","parser."],prefix `isPrefixOf` name]
+ oracle source = bindings (compile old helpers ("main = "++source)) Map.! "main"
+ corpusBytes <- BS.readFile(directory++"/equations.json")
+ corpus <- either error pure (JSON.eitherDecodeStrict' corpusBytes :: Either String [(String,String,String,String,Bool)])
+ forM_ corpus $ \(label,source,expected,environment,truth) -> do
+ let actual = selected(compile unit (oracle environment) source)
+ wanted = oracle expected
+ unless ((actual==wanted)==truth) $ error (label++": wrong result\n"++show actual++"\nexpected\n"++show wanted)
+ putStrLn ("equation "++label++": passed")
+ let output=directory++"/programs"
+ baseline=directory++"/reference-programs"
+ createDirectoryIfMissing True output
+ createDirectoryIfMissing True baseline
+ files <- filter (isSuffixOf ".tri") <$> listDirectory (directory++"/program-sources")
+ forM_ files $ \file -> do
+ source <- unlines . filter (not . isPrefixOf "!export") . lines <$> readFile (directory++"/program-sources/"++file)
+ let code=bindings(compile unit Leaf source)
+ get name=code Map.! name
+ function=get "main"
+ poison=error "ignored subtree forced" :: T
+ check label actual expected=unless(actual==expected)(error(file++": "++label))
+ trees 0=[Leaf]
+ trees n=let cs=trees(n-1) in Leaf:map Stem cs++[Fork a b|a<-cs,b<-cs]
+ inputs=trees(2::Int)
+ forceTreeShared function
+ if "eq-" `isPrefixOf` file then do
+ let eq=apply(get "y")(get "worker")
+ forM_ inputs $ \a -> forM_ inputs $ \b -> check "finite equality" (apply(apply eq a)b) (if a==b then Stem Leaf else Leaf)
+ check "root mismatch" (apply(apply eq Leaf)(Stem poison)) Leaf
+ check "early mismatch" (apply(apply eq(Fork Leaf poison))(Fork(Stem Leaf)poison)) Leaf
+ let partial=Fork Leaf poison
+ failure<-try(evaluate(apply(apply eq partial)partial))::IO(Either SomeException T)
+ case failure of Left _->pure();Right _->error "partial reflexivity"
+ else if "copy-" `isPrefixOf` file then do
+ let oracle (Fork h r)=Fork(Stem h)(oracle r)
+ oracle _=Leaf
+ forM_ inputs $ \x -> check "finite copy" (apply function x)(oracle x)
+ check "malformed root" (apply function(Stem poison)) Leaf
+ case apply function(Fork poison Leaf) of
+ Fork(Stem _)rest->check "ignored head" rest Leaf
+ _->error "copy head"
+ case apply function(Fork Leaf poison) of
+ Fork first _->check "ignored tail" first(Stem Leaf)
+ _->error "copy tail"
+ else do
+ let pick Leaf=Leaf
+ pick(Stem _)=Stem Leaf
+ pick(Fork _ _)=Fork Leaf Leaf
+ oracle(Fork h r)=Fork(pick h)(oracle r)
+ oracle _=Leaf
+ forM_ inputs $ \x -> check "finite classifier" (apply function x)(oracle x)
+ check "ignored stem" (apply function(Fork(Stem poison)Leaf))(Fork(Stem Leaf)Leaf)
+ check "ignored fork" (apply function(Fork(Fork poison poison)Leaf))(Fork(Fork Leaf Leaf)Leaf)
+ forM_ [(output,code),(baseline,bindings(compile old Leaf source))] $ \(destination,terms) -> do
+ bundle<-buildBundleShared [(Text.pack name,terms Map.! name)|name<-["main","worker","y"]]
+ BS.writeFile(destination++"/"++take(length file-4)file++".arboricx")(encodeBundle bundle)
+ putStrLn("program "++file++": finite/demand controls passed")
+
+ -- Independent code-generation targets: ordinary readable equations must now
+ -- construct the same tree as the hand-written delayed dispatcher. The retained
+ -- compiler must fail that target, rather than being a vacuous semantic control.
+ let canonical tree = encodeBundle <$> buildBundleShared [("root",tree)]
+ forM_ [("copy-equations","copy-delayed"),("pick-equations","pick-delayed")] $ \(authored,manual) -> do
+ fresh <- load(output++"/"++authored++".arboricx") >>= canonical . (Map.! "main")
+ wanted <- load(baseline++"/"++manual++".arboricx") >>= canonical . (Map.! "main")
+ previous <- load(baseline++"/"++authored++".arboricx") >>= canonical . (Map.! "main")
+ unless(fresh==wanted && previous/=wanted)(error("code-generation target: "++authored))
+ forM_ ["copy-triage","pick-triage","eq-triage-explicit"] $ \name -> do
+ fresh <- load(output++"/"++name++".arboricx") >>= canonical . (Map.! "main")
+ previous <- load(baseline++"/"++name++".arboricx") >>= canonical . (Map.! "main")
+ unless(fresh==previous)(error("non-pattern control changed: "++name))
+ putStrLn "exact delayed-dispatch targets, wrong-compiler and unchanged-control checks passed"
+ let function source = do
+ let term=bindings(compile unit Leaf source) Map.! "main"
+ forceTreeShared term
+ pure term
+ poison=error "split moved an unreached test" :: T
+ check source input expected = do
+ term<-function source
+ unless(apply term input==expected)(error("demand/order: "++source))
+ check "f _ t = 23\nf (t _) _ = 24\nf _ _ = 25\nmain input = f input t" poison (ofNumber 23)
+ check "f (t _) t = 23\nf _ _ = 24\nmain input = f t input" poison (ofNumber 24)
+ check "f (t t (t x)) = x\nf _ = 24\nmain input = f (t (t t) input)" poison (ofNumber 24)
+ check "f (t (t x) y) = y\nf (t x y) = x\nf _ = t\nmain input = f input"
+ (Fork (ofNumber 23) poison) (ofNumber 23)
+ check "main input = (wanted : let\n f t ^wanted = 23\n f _ _ = 24\n in f (t t) t) input" poison (ofNumber 24)
+ preserve<-function "f (t _) = t\nf original = original\nmain input = f input"
+ case apply preserve(Fork(ofNumber 23)poison) of
+ Fork first _ | first==ofNumber 23 -> pure ()
+ _->error "fallback lost original subject"
+ whole<-function "main (t original{_} rest) = t original rest\nmain _ = t"
+ case apply whole(Fork(ofNumber 23)poison) of
+ Fork first _ | first==ofNumber 23 -> pure ()
+ _->error "known constructor lost whole-child capture"
+ putStrLn "split-column, nested-failure, pin and whole-capture demand controls passed"
diff --git a/test/Spec.hs b/test/Spec.hs
@@ -1,11 +1,13 @@
module Main where
import Eval
+import PackageTests (packageTests)
import FileEval
import Lexer
import Parser
import REPL
import Research
+import TreeObservation (forceTreeShared)
import qualified NodeTable as Nodes
import Wire
import SupportFixture (verifyMixedSupport, verifyReuseScopes, verifySupportSources,
@@ -73,9 +75,11 @@ allTestLibsEnv = unsafePerformIO $ do
tests :: TestTree
tests = testGroup "Tricu Tests"
- [ lexer
+ [ packageTests
+ , lexer
, parser
, simpleEvaluation
+ , evaluationDemandTests
, lambdas
, arithmetic
, providedLibraries
@@ -120,6 +124,76 @@ tests = testGroup "Tricu Tests"
]
+-- Demand is tested with exceptions rather than unbounded divergent reductions.
+-- Physical sharing below is a Haskell implementation check, not a tree ABI promise.
+evaluationDemandTests :: TestTree
+evaluationDemandTests = localOption (mkTimeout 30000000) $ testGroup "Evaluation demand boundaries"
+ [ testCase "constant application discards its argument" $
+ apply (Fork Leaf (ofNumber 23)) poison @?= ofNumber 23
+ , testCase "constructor application exposes only the outer constructor" $ do
+ case apply Leaf poison of
+ Stem _ -> pure ()
+ _ -> assertFailure "expected Stem"
+ case apply (Stem poison) poison of
+ Fork _ _ -> pure ()
+ _ -> assertFailure "expected Fork"
+ , testCase "triage demands shape but not unused handlers or children" $ do
+ let value = ofNumber 23
+ constant = Fork Leaf value
+ apply (Fork (Fork value poison) poison) Leaf @?= value
+ apply (Fork (Fork poison constant) poison) (Stem poison) @?= value
+ apply (Fork (Fork poison poison) (Fork Leaf constant))
+ (Fork poison poison) @?= value
+ demanded (evaluate (apply (Fork (Fork Leaf Leaf) Leaf) poison))
+ , testCase "distribution retains the duplicated argument's physical sharing" $ do
+ -- t (t t) t c -> t c (t c). Both occurrences reference c.
+ input <- newIORef (Fork (ofNumber 23) (ofNumber 24)) >>= readIORef
+ case apply (Fork (Stem Leaf) Leaf) input of
+ Fork left (Stem right) -> do
+ a <- evaluate left >>= makeStableName
+ b <- evaluate right >>= makeStableName
+ assertBool "distribution copied the argument" (a == b)
+ _ -> assertFailure "unexpected distribution result"
+ , testCase "host list decoding checks the whole spine but not elements" $ do
+ fmap length (toList (Fork poison Leaf)) @?= Right 1
+ demanded (evaluate (toList (Fork Leaf poison)))
+ , testCase "complete flat serialization demands hidden children" $
+ demanded (evaluate (BS.length (encodeTreeTerm (Fork Leaf poison))))
+ , testCase "complete indexed serialization demands hidden children" $
+ demanded (buildBundleShared [("root", Fork Leaf poison)])
+ , testCase "full observation visits a shared depth-80 DAG within a bounded interval" $ do
+ let tower 0 = Leaf
+ tower n = let child = tower (n - 1) in Fork child child
+ outcome <- Timeout.timeout 2000000 (forceTreeShared (tower (80 :: Int)))
+ outcome @?= Just ()
+ , testCase "full observation demands hidden children and does not retain failures" $ do
+ demanded (forceTreeShared (Fork Leaf poison))
+ forceTreeShared (Fork Leaf (Stem Leaf))
+ , testCase "full observation handles deep trees without expanding a recursive call stack" $ do
+ let chain = foldr (\_ child -> Stem child) Leaf [1..65536 :: Int]
+ outcome <- Timeout.timeout 2000000 (forceTreeShared chain)
+ outcome @?= Just ()
+ , testCase "stable local bindings and ordinary applications discard unused inputs" $ do
+ forM_ ["main input = (ignored : 23) input",
+ "main input = let unused = input in 23"] $ \source -> do
+ function <- compileLocalControl [] source
+ apply function poison @?= ofNumber 23
+ , testCase "stable function consumes a finite prefix of an infinite producer" $ do
+ function <- compileLocalControl [] $ unlines
+ [ "main input = (let rec ones x = t x (ones x) in case (ones input) of"
+ , " [head | _] = head"
+ , " _ = 0)" ]
+ apply function (ofNumber 23) @?= ofNumber 23
+ ]
+ where
+ poison = error "evaluation-demand sentinel" :: T
+ demanded :: IO a -> Assertion
+ demanded action = do
+ outcome <- try (action >> pure ()) :: IO (Either SomeException ())
+ case outcome of
+ Left err -> assertBool (show err) ("evaluation-demand sentinel" `isInfixOf` show err)
+ Right () -> assertFailure "expected the observer to demand the poisoned subtree"
+
-- Host-only negative controls: no source compiler or retained toolchain needed.
artifactSafetyTests :: TestTree
artifactSafetyTests = testGroup "artifact safety" $
diff --git a/test/base_workloads.py b/test/base_workloads.py
@@ -3,7 +3,8 @@
Generate exact manual workers from base.tri and isolated alternative workers.
Use fresh processes (never --repeat), full-result Python oracles and --profile
-forcing; report load and invocation separately. This is not compiler acceptance.
+observation samples; report load and invocation-through-output separately.
+This is not compiler acceptance.
"""
import argparse
import hashlib
@@ -158,15 +159,15 @@ def profiles(path):
def measurements(path):
p = profiles(path)
- result = {}
+ result = {'measurement_scope': 'invocation-through-rendered-output-v1'}
for phase, begin, end in [('load', 'load-begin', 'load-end'),
- ('invoke', 'invoke-begin-1', 'invoke-end-1')]:
+ ('invoke', 'invoke-begin-1', 'observe-end-1')]:
for key in ['wall_ns', 'allocated_bytes', 'gc_elapsed_ns']:
result[phase + '_' + key] = p[end][key] - p[begin][key]
residency = re.search(r'^\s*([0-9,]+) bytes maximum residency', path.read_text(), re.M)
assert residency, 'RTS maximum residency missing'
result['rts_max_residency_bytes'] = int(residency[1].replace(',', ''))
- result['post_invoke_live_bytes'] = p['invoke-end-1']['live_bytes']
+ result['post_invoke_live_bytes'] = p['observe-end-1']['live_bytes']
return result
diff --git a/test/executable_arguments.py b/test/executable_arguments.py
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Cheap artifact-only CLI transport checks; no source compilation or generations."""
import hashlib
+import json
from pathlib import Path
import re
import subprocess
@@ -35,7 +36,7 @@ class ExecutableArguments(unittest.TestCase):
def call(self, command, *options, success=True):
result = subprocess.run([str(TRICU), 'arboricx', command, '--store', str(self.store),
- *options, '+RTS', '-N1', '-M256m', '-RTS'],
+ *options, '+RTS', '-N1', '-T', '-M256m', '-RTS'],
capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr)
return result
@@ -136,6 +137,42 @@ class ExecutableArguments(unittest.TestCase):
self.assertIn('Invalid executable tree', self.invoke('--arg-export', wrong + ':value', success=False).stderr)
+ def test_io_no_result_keeps_output_and_errors(self):
+ from stable_bundle import byte_tree
+ def natural(value):
+ result = b''
+ while value:
+ result += b'\x02' + (b'\x01\0' if value & 1 else b'\0')
+ value >>= 1
+ return result + b'\0'
+ def pair(a, b):
+ return b'\x02' + a + b
+ path = self.root / 'io.tree'
+ def action(tag, payload):
+ path.write_bytes(pair(byte_tree(b'tricuIO'), pair(natural(1), pair(natural(tag), payload))))
+ options = ['--module', self.identity, '--name', 'id', '--arg-tree-file', str(path)]
+ action(10, byte_tree(b'program output\n'))
+ self.assertEqual(self.call('run', *options, '--io').stdout, 'program output\nt')
+ self.assertEqual(self.call('run', *options, '--io', '--no-result').stdout, 'program output\n')
+ for full in [False, True]:
+ flags = ['--profile'] + (['--profile-full-result'] if full else [])
+ profiled = self.call('run', *options, '--io', '--no-result', *flags)
+ self.assertEqual(profiled.stdout, 'program output\n')
+ labels = [json.loads(line)['profile'] for line in profiled.stderr.splitlines()
+ if line.startswith('{')]
+ expected = ['load-begin', 'load-end', 'invoke-begin-1', 'invoke-end-1']
+ if full:
+ expected += ['force-begin-1', 'force-end-1']
+ expected += ['observe-begin-1', 'observe-end-1']
+ self.assertEqual([label for label in labels if not label.startswith('yield-')], expected)
+ for flags in [['--no-result'], ['--io', '--no-result', '--result']]:
+ reply = self.call('run', *options, *flags, success=False)
+ self.assertEqual(reply.stdout, '') # reject before doing IO
+ self.assertIn('--no-result requires --io', reply.stderr)
+ action(2, byte_tree(b'explicit failure'))
+ failed = self.call('run', *options, '--io', '--no-result', success=False)
+ self.assertEqual(failed.stdout, '')
+ self.assertIn('explicit failure', failed.stderr)
class ArtifactSafety(unittest.TestCase):
"""Independent wire fixtures and real CLI paths, all inside temporary stores."""
@@ -147,12 +184,12 @@ class ArtifactSafety(unittest.TestCase):
def call(self, *args, success=True):
reply = subprocess.run([str(TRICU), *map(str, args), '--store', str(self.store),
- '+RTS', '-N1', '-M256m', '-RTS'],
+ '+RTS', '-N1', '-T', '-M256m', '-RTS'],
cwd=self.root, capture_output=True, text=True, timeout=15)
self.assertEqual(reply.returncode == 0, success, reply.stdout + reply.stderr)
return reply
- def bundle(self, names=('value',), *, depth=0, evaluation='normal-order', kind='term', abi='arboricx.abi.tree.v1'):
+ def bundle(self, names=('value',), *, depth=0, result_remainder=False, evaluation='normal-order', kind='term', abi='arboricx.abi.tree.v1'):
# One leaf, two indexed sections, manifest v1.1. All lengths/offsets are
# rebuilt, not patched so that malformed lengths masquerade as rejection.
def word(n):
@@ -160,16 +197,22 @@ class ArtifactSafety(unittest.TestCase):
def text(s):
b = s.encode('utf-8')
return word(len(b)) + b
+ root = depth + 3 if result_remainder else depth
fields = ['arboricx.bundle.manifest.v1', 'tree-calculus-executable-object',
'tree-calculus.v1', 'indexed', 'arboricx.indexed.node.v1',
'arboricx.indexed.payload.v1', 'tree-calculus.v1', evaluation, 'arboricx.abi.tree.v1']
manifest = (b'ARBMNFST' + struct.pack('>HH', 1, 1) + b''.join(map(text, fields))
- + word(0) + b'\0' + word(1) + word(depth) + text('default') + word(len(names))
- + b''.join(text(name) + word(depth) + text(kind if i else 'term')
+ + word(0) + b'\0' + word(1) + word(root) + text('default') + word(len(names))
+ + b''.join(text(name) + word(root) + text(kind if i else 'term')
+ text(abi if i else 'arboricx.abi.tree.v1') for i, name in enumerate(names))
+ word(0) + word(0))
- nodes = (struct.pack('>Q', depth + 1) + word(1) + b'\0'
+ nodes = (struct.pack('>Q', root + 1) + word(1) + b'\0'
+ b''.join(word(9) + b'\x02' + word(i-1) + word(i-1) for i in range(1, depth+1)))
+ if result_remainder:
+ # ok Leaf remainder, with an exponentially large but shared remainder.
+ nodes += (word(5) + b'\x01' + word(0)
+ + word(9) + b'\x02' + word(0) + word(depth)
+ + word(9) + b'\x02' + word(depth + 1) + word(depth + 2))
def section(identifier, offset, length):
return struct.pack('>IHHIQQI', identifier, 1, 1, 0, offset, length, 0)
data = (b'ARBORICX' + struct.pack('>HHIQQ', 1, 0, 2, 0, 32)
@@ -187,6 +230,25 @@ class ArtifactSafety(unittest.TestCase):
self.call('arboricx', 'import', '--file', bundle)
self.assertLess(sum(p.stat().st_size for p in self.store.glob('objects/*/*')), 4096)
+ def test_profile_observes_selected_result_and_full_forcing_is_explicit(self):
+ reply = self.call('arboricx', 'import', '--file', self.bundle(depth=80, result_remainder=True))
+ pin = re.search(r'Created module alias .* -> ([0-9a-f]{64})', reply.stdout)[1]
+ command = ['arboricx', 'run', '--module', pin, '--name', 'value', '--result', '--format', 'number']
+ ordinary = self.call(*command)
+ self.assertEqual(ordinary.stdout, 'ok: 0')
+ for full in [False, True]:
+ options = ['--profile'] + (['--profile-full-result'] if full else [])
+ profiled = self.call(*command, *options)
+ self.assertEqual(profiled.stdout, ordinary.stdout)
+ labels = [json.loads(line)['profile'] for line in profiled.stderr.splitlines() if line.startswith('{')]
+ expected = ['load-begin', 'load-end', 'invoke-begin-1', 'invoke-end-1']
+ if full:
+ expected += ['force-begin-1', 'force-end-1']
+ expected += ['observe-begin-1', 'observe-end-1']
+ self.assertEqual(labels, expected)
+ failure = self.call(*command, '--profile-full-result', success=False)
+ self.assertIn('--profile-full-result requires --profile', failure.stderr)
+
def test_flat_expansion_boundary(self):
bundle = self.bundle(depth=10) # 2**11 - 1 canonical bytes
for budget, success in [('2046', False), ('2047', True)]:
diff --git a/test/library_inspector.py b/test/library_inspector.py
@@ -0,0 +1,333 @@
+#!/usr/bin/env python3
+"""Opt-in Haskell-only publication -> clean fetch -> offline inspector acceptance.
+
+Build through Nix first. Publishes only into the supplied NEW local directory;
+never contacts a public registry. Reports and artifacts are retained for reuse.
+"""
+import argparse
+import hashlib
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import json
+from pathlib import Path
+import re
+import shutil
+import struct
+import subprocess
+import threading
+import os
+import tempfile
+import time
+from shipped_compiler import verify, ROOT
+from stable_bundle import assembled, sections
+
+RELEASES = [
+ ('tricu-base', 'foundation', 'base_release', 'lib/packages/base.tri'),
+ ('tricu-file-io', 'fileio', 'io_release', 'lib/packages/io.tri'),
+ ('arboricx-inspection', 'inspection', 'inspection_release', 'lib/arboricx/inspect.tri'),
+]
+
+
+def cases(directory):
+ """Independent byte fixtures; the Haskell oracle determines acceptance/report."""
+ directory.mkdir()
+ result = []
+ for name in ['id', 'append', 'false']:
+ target = directory / (name + '.arboricx')
+ shutil.copyfile(ROOT / 'test/fixtures' / target.name, target)
+ result.append((name, target, True))
+ manifest, nodes = sections(ROOT / 'test/fixtures/id.arboricx')
+ # Preserve the wire-profile prefix, replace root/export rows independently.
+ at = 12
+ for _ in range(9):
+ size = struct.unpack_from('>I', manifest, at)[0]; at += 4 + size
+ capabilities = struct.unpack_from('>I', manifest, at)[0]; at += 4
+ assert capabilities == 0
+ at += 1
+ prefix = manifest[:at]
+ def text(value):
+ value = value.encode('utf-8')
+ return struct.pack('>I', len(value)) + value
+ def custom(exports, records):
+ m = prefix + struct.pack('>II', 1, 0) + text('root') + struct.pack('>I', len(exports))
+ for name, root, kind, abi in exports:
+ m += text(name) + struct.pack('>I', root) + text(kind) + text(abi)
+ m += struct.pack('>II', 0, 0) # metadata and extension counts
+ n = struct.pack('>Q', len(records)) + b''.join(struct.pack('>I', len(r)) + r for r in records)
+ return assembled(m, n)
+ abi = 'arboricx.abi.tree.v1'
+ leaf = [b'\0']
+ samples = [
+ ('names-and-shared-exports', custom([('tab\tline\n%\x1b é', 0, 'term', abi), ('second', 0, 'term', abi)], leaf), True),
+ ('bad-magic', b'X' + assembled(manifest, nodes)[1:], False),
+ ('truncated', assembled(manifest, nodes)[:-1], False),
+ ('bad-manifest', assembled(b'X' + manifest[1:], nodes), False),
+ ('invalid-unselected-export', custom([('good', 0, 'term', abi), ('bad', 5, 'term', abi)], leaf), False),
+ ('invalid-unselected-abi', custom([('good', 0, 'term', abi), ('bad', 0, 'term', 'wrong')], leaf), False),
+ ('duplicate-export', custom([('same', 0, 'term', abi), ('same', 0, 'term', abi)], leaf), False),
+ ('bad-forward-edge', custom([('root', 0, 'term', abi)], [b'\1' + struct.pack('>I', 0)]), False),
+ ('duplicate-node', custom([('root', 0, 'term', abi)], [b'\0', b'\0']), False),
+ ]
+ # 41 physical nodes denote >2^40 logical occurrences. Inspection must not
+ # reconstruct or unfold this tree merely to report the stored record count.
+ dag = leaf + [b'\2' + struct.pack('>II', i - 1, i - 1) for i in range(1, 41)]
+ samples.append(('exponential-sharing', custom([('huge', 40, 'term', abi)], dag), True))
+ for label, data, good in samples:
+ path = directory / (label + '.arboricx'); path.write_bytes(data)
+ result.append((label, path, good))
+ return result
+
+
+def stage_site(root, site, report):
+ """Administrative, opt-in staging; immutable bytes before discoverability.
+
+ The caller owns this local workspace exclusively. This is not network
+ publication, a signed attestation, or a concurrent index-merge protocol.
+ """
+ if not report.get('complete'):
+ raise ValueError('Only an accepted publication can be staged')
+ index_path = site / 'packages.json'
+ index = json.loads(index_path.read_text())
+ if index.get('schema') != 1:
+ raise ValueError('Unsupported site index')
+ releases = list(index['releases'])
+ planned = []
+ titles = {'tricu-base': 'Foundation: lists, Results and decimal formatting',
+ 'tricu-file-io': 'File IO and hashing actions',
+ 'arboricx-inspection': 'Arboricx inspection: validate without execution'}
+ for name, release in report['releases'].items():
+ data = (root / 'producer' / (name + '.arboricx')).read_bytes()
+ if hashlib.sha256(data).hexdigest() != release['bundle'] or not 0 < len(data) <= 1048576:
+ raise ValueError('Invalid site dependency: ' + name)
+ candidate = dict(name=name, version=release['version'], hash=release['bundle'],
+ manifest=release['manifest'], title=titles[name])
+ existing = [r for r in releases if (r['name'], r['version']) == (name, release['version'])]
+ if existing and (len(existing) != 1 or any(existing[0][k] != candidate[k] for k in ['hash', 'manifest'])):
+ raise ValueError('Immutable site release conflict: ' + name)
+ if not existing:
+ releases.append(candidate)
+ path = site / 'bundles' / (release['bundle'] + '.arboricx')
+ if path.exists() and path.read_bytes() != data:
+ raise ValueError('Corrupt existing site bundle')
+ planned.append((path, data))
+ # Validate the entire change before writing any new asset.
+ for path, data in planned:
+ if not path.exists():
+ with path.open('xb') as output:
+ output.write(data)
+ index['releases'] = releases
+ with tempfile.NamedTemporaryFile(dir=site, prefix='.packages-', delete=False) as output:
+ temporary = Path(output.name)
+ output.write((json.dumps(index, indent=2) + '\n').encode())
+ try:
+ temporary.chmod(0o644)
+ os.replace(temporary, index_path)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('tricu', type=Path)
+ p.add_argument('--oracle', required=True, type=Path)
+ p.add_argument('--directory', required=True, type=Path)
+ p.add_argument('--version', default='0.1.0', help='Exact release label for this library set')
+ p.add_argument('--stage-site', type=Path, help='After acceptance, stage bundles/releases in this static-site directory')
+ args = p.parse_args()
+ metadata = verify()
+ tricu, oracle, root = args.tricu.resolve(), args.oracle.resolve(), args.directory.resolve()
+ root.mkdir(parents=True, exist_ok=False)
+ producer, tools, application = root / 'producer', root / 'tools', root / 'application'
+ workspace, consumer = producer / 'workspace', root / 'consumer'
+ workspace.mkdir(parents=True); consumer.mkdir()
+ report = dict(complete=False, compiler_sha256=metadata['sha256'], sources={}, commands=[], releases={}, cases=[])
+ started = time.monotonic()
+ def record():
+ (root / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
+ def command(executable, words, seconds=60, success=True, rts=True):
+ remaining = 2400 - (time.monotonic() - started)
+ if remaining <= 0:
+ raise TimeoutError('whole-job deadline exceeded')
+ argv = [str(executable), *map(str, words), *(['+RTS', '-N1', '-M8192m', '-RTS'] if rts else [])]
+ before = time.monotonic()
+ reply = subprocess.run(argv, cwd=consumer, capture_output=True, timeout=min(seconds, remaining))
+ event = dict(argv=argv, seconds=time.monotonic()-before, code=reply.returncode,
+ stdout=reply.stdout.decode('utf-8', errors='replace'), stderr=reply.stderr.decode('utf-8', errors='replace'))
+ report['commands'].append(event); record()
+ assert (reply.returncode == 0) == success, event
+ return reply.stdout
+ def run(*words, **options):
+ return command(tricu, words, **options).decode('utf-8')
+ def imported(bundle, store):
+ output = run('arboricx', 'import', '--file', bundle, '--store', store)
+ return re.search(r'Created module alias .* -> ([0-9a-f]{64})', output)[1]
+ compiler = imported(ROOT / metadata['bundle'], tools)
+ report['compiler_manifest'] = compiler
+ options = ['--toolchain', compiler, '--tool-store', tools, '--compile-timeout', '900']
+ sources = {row['alias']: row['path'] for row in metadata['sources']}
+ sources.update(binary='lib/binary.tri', arboricx='lib/arboricx/arboricx.tri',
+ arboricx_common='lib/arboricx/common.tri', arboricx_manifest='lib/arboricx/manifest.tri',
+ arboricx_nodes='lib/arboricx/nodes.tri')
+ sources.update({alias: path for _, _, alias, path in RELEASES})
+ for alias, path in sources.items():
+ data = (ROOT / path).read_bytes(); (workspace / (alias + '.tri')).write_bytes(data)
+ report['sources'][path] = hashlib.sha256(data).hexdigest()
+ registry = root / 'registry'
+ for name, binding, alias, _ in RELEASES:
+ bundle = producer / (name + '.arboricx')
+ print('Compiling publication:', name, flush=True)
+ run('arboricx', 'compile', '--file', workspace / (alias + '.tri'), '--store', producer / 'store',
+ '--output', bundle, *options, seconds=930)
+ published = run('package', 'publish', '--root', registry, '--name', name, '--version', args.version, '--file', bundle)
+ assert published == run('package', 'publish', '--root', registry, '--name', name, '--version', args.version, '--file', bundle)
+ _, row = published.splitlines(); _, _, manifest, digest = row.split('\t')
+ report['releases'][name] = dict(version=args.version, manifest=manifest, bundle=digest, binding=binding)
+ record()
+ # Controlled GET peer serves exactly the immutable publication files. Native
+ # route behavior is independently exercised by package_registry.py.
+ requests = []
+ routes = {}
+ for name, release in report['releases'].items():
+ digest = release['bundle']
+ routes['/_arboricx/package/' + name + '/' + release['version']] = registry / 'packages' / name / release['version']
+ routes['/_arboricx/bundle/hash/' + digest] = registry / 'objects' / digest[:3] / (digest + '.arboricx')
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ requests.append(self.path)
+ path = routes.get(self.path)
+ body = path.read_bytes() if path else b'not found'
+ self.send_response(200 if path else 404)
+ self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body)
+ def log_message(self, *_):
+ pass
+ server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start()
+ config = consumer / 'registries'
+ config.write_text('arboricx.registries.v1\nhome\thttp://127.0.0.1:' + str(server.server_port) + '\n')
+ project_file, project_lock = consumer / 'tricu.project', consumer / 'tricu.lock'
+ project_source = (ROOT / 'demos/package-inspector/tricu.project').read_text()
+ project_file.write_text(project_source.replace('\t0.1.0\n', '\t' + args.version + '\n'))
+ report['sources']['demos/package-inspector/tricu.project'] = hashlib.sha256(project_source.encode()).hexdigest()
+ def project(action, *extra, success=True):
+ return run('project', action, '--project', project_file,
+ *(['--registries', config] if action in ['resolve', 'update', 'fetch'] else []),
+ *(['--store', application] if action in ['fetch', 'bind', 'check', 'explain'] else []),
+ *extra, success=success)
+ assert not application.exists(), 'consumer starts without producer CAS objects'
+ port = server.server_port
+ try:
+ project('resolve')
+ assert not application.exists(), 'resolution must not import executable bytes'
+ project('bind', success=False)
+ project('fetch')
+ project('resolve') # frozen: no index refresh
+ finally:
+ server.shutdown(); server.server_close(); thread.join()
+ assert len(requests) == 6, requests
+ report['registry_requests'] = list(requests)
+ original_lock = project_lock.read_bytes()
+ (root / 'initial.lock').write_bytes(original_lock)
+ # Actual closed socket: subsequent operations cannot depend on the registry.
+ project('resolve'); project('fetch'); project('bind'); project('check')
+ assert not any(p.is_file() for p in (application / 'aliases').rglob('*'))
+ shutil.copyfile(ROOT / 'demos/package-inspector/main.tri', consumer / 'main.tri')
+ report['sources']['demos/package-inspector/main.tri'] = hashlib.sha256((consumer / 'main.tri').read_bytes()).hexdigest()
+ assert sorted(p.name for p in consumer.glob('*.tri')) == ['fileio.tri', 'foundation.tri', 'inspection.tri', 'main.tri']
+ print('Compiling consumer with the registry stopped', flush=True)
+ bundle = root / 'inspector.arboricx'
+ run('arboricx', 'compile', '--file', consumer / 'main.tri', '--store', application, '--output', bundle,
+ *options, seconds=930)
+ inspector = imported(bundle, application)
+ report['inspector_manifest'] = inspector
+ def inspect(path, good=True, granted=True):
+ return run('arboricx', 'run', '--store', application, '--module', inspector, '--name', 'main',
+ '--arg', path, '--io', '--no-result', *(['--allow-read', path] if granted else []), success=good, seconds=180)
+ inputs = cases(root / 'cases')
+ # A real executable IO export would abort if the inspector ran it.
+ shutil.copyfile(consumer / 'fileio.tri', root / 'cases/fileio.tri')
+ payload = root / 'cases/payload.tri'
+ payload.write_text('!import fileio as IO\n!export main\nmain = IO.io (IO.abortIO "INSPECTED EXPORT WAS EXECUTED")\n')
+ inert = root / 'cases/inert-io.arboricx'
+ run('arboricx', 'compile', '--file', payload, '--store', application, '--output', inert, *options, seconds=930)
+ inputs.append(('inert-io', inert, True))
+ inputs.extend((name, producer / (name + '.arboricx'), True) for name in report['releases'])
+ inputs.append(('inspector-self', bundle, True))
+ for label, path, good in inputs:
+ print('Inspecting:', label, flush=True)
+ expected = command(oracle, [path], success=good, rts=False).decode('utf-8')
+ actual = inspect(path, good)
+ if good:
+ assert actual == expected, (label, actual, expected)
+ assert actual.splitlines()[1] == 'bundle-sha256\t' + hashlib.sha256(path.read_bytes()).hexdigest()
+ else:
+ assert 'arboricx.inspect.v1' not in actual, (label, actual)
+ report['cases'].append(dict(label=label, valid=good, report=actual)); record()
+ inspect(root / 'cases/id.arboricx', False, False)
+ inspect(root / 'cases/missing.arboricx', False)
+ # Deliberate one-dependency update: add a public constant to the foundation
+ # interface, preserving the three operations used by the authored consumer.
+ # This is a local acceptance release, never staged in the site's public index.
+ updated_source = workspace / 'base_update_release.tri'
+ updated_source.write_text((ROOT / 'lib/packages/base.tri').read_text().replace(
+ '!export showNumber\n', '!export showNumber\n!export revision\n') + '\nrevision = 1\n')
+ report['update_source_sha256'] = hashlib.sha256(updated_source.read_bytes()).hexdigest()
+ updated_bundle = producer / 'base-update.arboricx'
+ run('arboricx', 'compile', '--file', updated_source, '--store', producer / 'store',
+ '--output', updated_bundle, *options, seconds=930)
+ next_version = 'project-update-test' if args.version != 'project-update-test' else 'project-update-test-2'
+ update_release = run('package', 'publish', '--root', registry, '--name', 'tricu-base',
+ '--version', next_version, '--file', updated_bundle).splitlines()[1].split('\t')
+ _, _, new_manifest, new_digest = update_release
+ assert new_manifest != report['releases']['tricu-base']['manifest']
+ routes['/_arboricx/package/tricu-base/' + next_version] = registry / 'packages/tricu-base' / next_version
+ routes['/_arboricx/bundle/hash/' + new_digest] = registry / 'objects' / new_digest[:3] / (new_digest + '.arboricx')
+ project_file.write_text(project_file.read_text().replace('tricu-base\t' + args.version + '\n', 'tricu-base\t' + next_version + '\n'))
+ project('check', success=False)
+ server = ThreadingHTTPServer(('127.0.0.1', port), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start()
+ try:
+ project('resolve', success=False)
+ assert len(requests) == 6, requests
+ diff = project('update', '--dependency', 'foundation')
+ assert 'Updated foundation' in diff and 'Updated fileio' not in diff and 'Updated inspection' not in diff
+ report['update_diff'] = diff
+ project('check', success=False)
+ project('bind', success=False) # no implicit fetch
+ project('fetch')
+ finally:
+ server.shutdown(); server.server_close(); thread.join()
+ assert len(requests) == 8, requests
+ def lock_rows(data):
+ return dict((line.split('\t')[0], line) for line in data.decode().splitlines()[1:])
+ before, after = lock_rows(original_lock), lock_rows(project_lock.read_bytes())
+ assert before['fileio'] == after['fileio'] and before['inspection'] == after['inspection']
+ assert before['foundation'] != after['foundation']
+ project('fetch'); project('bind'); project('check')
+ updated_inspector_bundle = root / 'inspector-updated.arboricx'
+ run('arboricx', 'compile', '--file', consumer / 'main.tri', '--store', application,
+ '--output', updated_inspector_bundle, *options, seconds=930)
+ updated_inspector = imported(updated_inspector_bundle, application)
+ report['updated_inspector_manifest'] = updated_inspector
+ report['consumer_identity_unchanged'] = updated_inspector == inspector
+ assert report['consumer_identity_unchanged'], 'pinned inspector fixture changed after the additive foundation update'
+ inspector = updated_inspector
+ target = root / 'cases/id.arboricx'
+ assert inspect(target) == command(oracle, [target], rts=False).decode('utf-8')
+ # Explain and compare the retained locks after the registry has closed.
+ report['project_explanation'] = project('explain')
+ report['project_diff'] = run('project', 'diff', '--before', root / 'initial.lock',
+ '--after', project_lock, '--store', application)
+ assert 'export revision: added' in report['project_diff']
+ foundation_diff = report['project_diff'].split('Binding foundation\n')[1].split('Binding inspection\n')[0]
+ assert foundation_diff.count('tree identical') == 3 and 'tree different' not in foundation_diff
+ assert 'local module: verified' in report['project_explanation']
+ report['updated_registry_requests'] = list(requests)
+ report['complete'] = True; report['seconds'] = time.monotonic() - started; record()
+ if args.stage_site:
+ stage_site(root, args.stage_site.resolve(), report)
+ report['staged_site'] = str(args.stage_site.resolve()); record()
+ print('PASS project dependencies, clean fetch, offline inspector, isolated dependency update, Haskell parity and negative controls')
+ print('Inspector:', inspector, '\nStore:', application)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/native_math_performance.py b/test/native_math_performance.py
@@ -49,8 +49,8 @@ def probe(root, repo, generation, tool, candidate, run, import_bundle, number, b
return output
samples = [json.loads(line) for line in measurements[-1]['stderr'].splitlines() if line.startswith('{')]
begin = next(s for s in samples if s['profile'] == 'invoke-begin-1')
- end = next(s for s in samples if s['profile'] == 'invoke-end-1')
- summaries.append(dict(label=label, name=name, wall_ns=end['wall_ns'] - begin['wall_ns'],
+ end = next(s for s in samples if s['profile'] == 'observe-end-1')
+ summaries.append(dict(measurement_scope='invocation-through-rendered-output-v1', label=label, name=name, wall_ns=end['wall_ns'] - begin['wall_ns'],
allocated_bytes=end['allocated_bytes'] - begin['allocated_bytes'],
output_sha256=hashlib.sha256(output.encode()).hexdigest()))
(root / 'performance.json').write_text(json.dumps(summaries, indent=2) + '\n')
diff --git a/test/package_registry.py b/test/package_registry.py
@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+"""Haskell-only package protocol controls; optional native server + offline build.
+
+Default: small existing bundle, controlled HTTP peer, no source compilation.
+--native-server: a compiled module exporting main root port, as documented in
+validation.md. Runs the actual Arboricx routes and compiles a tiny consumer.
+"""
+import argparse
+import hashlib
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import json
+from pathlib import Path
+import re
+import socket
+import subprocess
+import tempfile
+import threading
+import time
+import urllib.error
+import urllib.request
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('tricu', type=Path)
+ group = p.add_mutually_exclusive_group()
+ group.add_argument('--native-server', type=Path)
+ group.add_argument('--prepare-server', type=Path, help='Compile current native server into a new retained directory')
+ args = p.parse_args()
+ tricu = args.tricu.resolve()
+ if args.prepare_server:
+ prepared = args.prepare_server.resolve()
+ prepared.mkdir(parents=True, exist_ok=False)
+ workspace = prepared / 'workspace'
+ workspace.mkdir()
+ metadata = json.loads((ROOT / 'bootstrap/stable-compiler.json').read_text())
+ sources = {row['alias']: row['path'] for row in metadata['sources']}
+ sources.update(http='lib/http.tri', socket='lib/socket.tri', arboricx_server='lib/arboricx/server.tri')
+ hashes = {}
+ for alias, path in sources.items():
+ data = (ROOT / path).read_bytes()
+ (workspace / (alias + '.tri')).write_bytes(data)
+ hashes[path] = hashlib.sha256(data).hexdigest()
+ (workspace / 'main.tri').write_text('!import io *\n!import arboricx_server as S\n!export main\n'
+ 'main root port = io (S.arboricxServer root "127.0.0.1" port)\n')
+ args.native_server = prepared / 'server.arboricx'
+ command = [str(tricu), 'arboricx', 'compile', '--file', str(workspace / 'main.tri'),
+ '--store', str(prepared / 'store'), '--output', str(args.native_server),
+ '--compile-timeout', '900', '+RTS', '-N1', '-M8192m', '-RTS']
+ reply = subprocess.run(command, capture_output=True, timeout=930)
+ (prepared / 'build.log').write_bytes(reply.stdout + reply.stderr)
+ (prepared / 'preparation.json').write_text(json.dumps(dict(command=command,
+ compiler_sha256=metadata['sha256'], sources=hashes, exit_code=reply.returncode), indent=2)+'\n')
+ assert reply.returncode == 0, (reply.stdout + reply.stderr).decode()
+ with tempfile.TemporaryDirectory(prefix='tricu-package-') as temp:
+ root = Path(temp)
+ registry = root / 'registry'
+ store = root / 'store'
+ config, lock = root / 'registries', root / 'library.lock'
+ requests = []
+ overrides = {}
+
+ def run(*words, success=True, seconds=60):
+ reply = subprocess.run([str(tricu), *map(str, words), '+RTS', '-N1', '-M2048m', '-RTS'],
+ capture_output=True, text=True, timeout=seconds)
+ assert (reply.returncode == 0) == success, (words, reply.stdout, reply.stderr)
+ return reply.stdout
+
+ def package(*words, **kw):
+ return run('package', *words, **kw)
+
+ library = ROOT / 'test/fixtures/id.arboricx'
+ if args.native_server:
+ source = root / 'library.tri'
+ source.write_text('!export id\n!export marker\nid x = x\nmarker = 7\n')
+ library = root / 'library.arboricx'
+ run('arboricx', 'compile', '--file', source, '--store', root / 'producer-store',
+ '--output', library, seconds=120)
+ record = package('publish', '--root', registry, '--name', 'identity', '--version', '1.0',
+ '--file', library)
+ _, row = record.splitlines()
+ name, version, manifest, bundle = row.split('\t')
+ index_path = '/_arboricx/package/identity/1.0'
+ bundle_path = '/_arboricx/bundle/hash/' + bundle
+ bundle_file = registry / 'objects' / bundle[:3] / (bundle + '.arboricx')
+ assert hashlib.sha256(bundle_file.read_bytes()).hexdigest() == bundle
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ requests.append(self.path)
+ if self.path in overrides:
+ status, body = overrides[self.path]
+ elif self.path == index_path:
+ status, body = 200, record.encode()
+ elif self.path == bundle_path:
+ status, body = 200, bundle_file.read_bytes()
+ else:
+ status, body = 404, b'not found'
+ self.send_response(status)
+ self.send_header('Content-Length', str(len(body)))
+ self.send_header('Location', '/must-not-follow')
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *_):
+ pass
+
+ server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ url = 'http://127.0.0.1:' + str(server.server_port)
+ config.write_text('arboricx.registries.v1\nhome\t' + url + '\n')
+ resolve = ['resolve', '--registries', config, '--registry', 'home', '--name', name,
+ '--version', version, '--lock', lock]
+ fetch = ['fetch', '--registries', config, '--lock', lock, '--store', store]
+ try:
+ package(*resolve)
+ original_lock = lock.read_bytes()
+ package('resolve', '--registries', config, '--registry', 'home', '--name', name,
+ '--version', version, '--lock', config, success=False)
+ assert requests == [index_path]
+ # Exact same version cannot be changed, even with --update.
+ overrides[index_path] = (200, record.replace(manifest, 'a' * 64).encode())
+ package(*resolve, '--update', success=False)
+ assert lock.read_bytes() == original_lock
+ overrides[index_path] = (302, b'redirect')
+ package(*resolve, success=False)
+ assert '/must-not-follow' not in requests
+ overrides[index_path] = (200, b'x' * 4097)
+ package(*resolve, success=False)
+ overrides[index_path] = (200, record.replace('identity\t', 'other\t').encode())
+ package(*resolve, success=False)
+ overrides.clear()
+ # A different version is an explicit selection change, not mutation.
+ selection = root / 'selection.lock'
+ selection.write_bytes(original_lock)
+ overrides['/_arboricx/package/identity/2.0'] = (200, record.replace('identity\t1.0\t', 'identity\t2.0\t').encode())
+ change = ['resolve', '--registries', config, '--registry', 'home', '--name', name,
+ '--version', '2.0', '--lock', selection]
+ package(*change, success=False)
+ assert selection.read_bytes() == original_lock
+ package(*change, '--update')
+ assert b'identity\t2.0\t' in selection.read_bytes()
+ overrides.clear()
+ # Fetch uses only transport pins, not a fresh index result.
+ requests.clear()
+ overrides[bundle_path] = (200, b'corrupt')
+ package(*fetch, success=False)
+ assert not (store / 'objects' / manifest[:3] / manifest).exists()
+ overrides.clear()
+ # A valid bundle at the selected pin must still match the module pin.
+ lock.write_bytes(original_lock.replace(manifest.encode(), b'a' * 64))
+ package(*fetch, success=False)
+ assert not (store / 'objects').exists()
+ lock.write_bytes(original_lock)
+ package(*fetch)
+ assert requests == [bundle_path] * 3
+ requests.clear()
+ package(*fetch)
+ assert requests == []
+ # Verification includes the exported trees, not merely the manifest.
+ manifest_file = store / 'objects' / manifest[:3] / manifest
+ tree_pin = manifest_file.read_text().splitlines()[1].split('\t')[3]
+ tree_file = store / 'objects' / tree_pin[:3] / tree_pin
+ tree_bytes = tree_file.read_bytes()
+ tree_file.write_bytes(b'corrupt tree')
+ package(*fetch, success=False)
+ tree_file.unlink()
+ package(*fetch, success=False)
+ tree_file.write_bytes(tree_bytes)
+ assert requests == []
+ # Explicit trust and URL binding, including when content is cached.
+ config.write_text('arboricx.registries.v1\nhome\t' + url + '\nhome\t' + url + '\n')
+ package(*fetch, success=False)
+ config.write_text('arboricx.registries.v1\n')
+ package(*fetch, success=False)
+ config.write_text('arboricx.registries.v1\nhome\thttps://localhost\n')
+ package(*fetch, success=False)
+ config.write_text('arboricx.registries.v1\nhome\t' + url + '\n')
+ package('bind', '--lock', lock, '--store', store, '--output', root / 'identity.tri')
+ assert (root / 'identity.tri').read_text() == '!export * from ' + manifest + '\n'
+ assert requests == []
+ package('bind', '--lock', lock, '--store', store, '--output', lock, success=False)
+ assert lock.read_bytes() == original_lock
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join()
+
+ # Cached verification/fetch and binding work with the peer stopped.
+ package(*fetch)
+ package('bind', '--lock', lock, '--store', store, '--output', root / 'identity.tri')
+ # Corrupt local content is not accepted or silently repaired.
+ object_file = store / 'objects' / manifest[:3] / manifest
+ original = object_file.read_bytes()
+ object_file.write_bytes(b'corrupt')
+ package(*fetch, success=False)
+ object_file.write_bytes(original)
+
+ if args.native_server:
+ from http_headers import number
+ with socket.socket() as sock:
+ sock.bind(('127.0.0.1', 0))
+ port = sock.getsockname()[1]
+ port_file = root / 'port.tree'
+ port_file.write_bytes(number(port))
+ runtime = root / 'server-store'
+ imported = run('arboricx', 'import', '--file', args.native_server.resolve(), '--store', runtime)
+ pin = re.search(r'Created module alias .* -> ([0-9a-f]{64})', imported)[1]
+ log = open(root / 'server.log', 'w+')
+ proc = subprocess.Popen([str(tricu), 'arboricx', 'run', '--module', pin, '--name', 'main',
+ '--store', str(runtime), '--arg', str(registry), '--arg-tree-file', str(port_file),
+ '--io', '--allow-read', str(registry), '+RTS', '-N1', '-M2048m', '-RTS'], stdout=log, stderr=log)
+ url = 'http://127.0.0.1:' + str(port)
+ # Ignore environmental proxies for our local protocol probes.
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
+ try:
+ for _ in range(100):
+ if proc.poll() is not None:
+ log.seek(0)
+ raise AssertionError(log.read())
+ try:
+ with opener.open(url + '/_arboricx/health', timeout=2) as response:
+ assert response.read() == b'OK\n'
+ break
+ except (OSError, urllib.error.URLError):
+ time.sleep(.1)
+ else:
+ raise AssertionError('native server startup timeout')
+ for suffix in ['../x', 'identity/../x', 'identity/1.0/extra', 'identity/%31', 'a'*129 + '/1', 'identity/' + '1'*129]:
+ try:
+ opener.open(url + '/_arboricx/package/' + suffix, timeout=10)
+ raise AssertionError('unsafe index path accepted: ' + suffix)
+ except urllib.error.HTTPError as e:
+ assert e.code == 400, (suffix, e.code)
+ native_store = root / 'native-store'
+ native_lock = root / 'native.lock'
+ config.write_text('arboricx.registries.v1\nhome\t' + url + '\n')
+ package('resolve', '--registries', config, '--registry', 'home', '--name', name,
+ '--version', version, '--lock', native_lock)
+ package('fetch', '--registries', config, '--lock', native_lock, '--store', native_store)
+ finally:
+ proc.terminate()
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ log.close()
+ # The actual compiler consumes a hash-pinned workspace binding offline.
+ package('bind', '--lock', native_lock, '--store', native_store, '--output', root / 'identity.tri')
+ (root / 'app.tri').write_text('!import identity as I\n!export main\nmain = I.id 42\n')
+ value = run('eval', root / 'app.tri', '--store', native_store, '--format', 'number', seconds=120)
+ assert value.strip() == '42', value
+ print('PASS: exact registry resolution, verified fetching, immutable locks, offline binding' +
+ ('; native server and offline compilation' if args.native_server else ''))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/pattern_lowering.py b/test/pattern_lowering.py
@@ -0,0 +1,326 @@
+#!/usr/bin/env python3
+"""Unpublished pattern-lowering candidate: authenticated rebuild, controls and costs.
+
+Reuses complete unchanged reference modules, rebuilds every affected consumer up
+through source_unit, and leaves accepted compilers/fixtures untouched. The Haskell
+control driver is compiled through Nix. No recovery frontend or equality jet.
+"""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import re
+import statistics
+import subprocess
+import time
+
+from bundle_dag import Intern, merge_bundles, read
+from stable_bundle import assembled
+from executable_object import KINDS, verify_payload
+from fixture_sources import snapshot_bytes, verify
+from native_header import native_header
+from native_library_migration import digest
+from repair_support_fixture import checked_receipt
+from shipped_compiler import verify as verify_compiler
+
+ROOT = Path(__file__).resolve().parent.parent
+COMMON = '''false = t
+true = t t
+triage leaf stem fork = t (t leaf stem) fork
+lAnd = triage (_ : false) (_ x : x) (_ _ x : x)
+y = ((mut wait fun : wait mut (x : fun (wait mut x)))
+ (x : x x)
+ (a0 a1 a2 : t (t a0) (t t a2) a1))
+select leafK stemK forkK value = ((chosen : chosen t)
+ (triage leafK (child : _ : stemK child)
+ (left right : _ : forkK left right) value))
+'''
+EQ_BODY = '''triage
+ (triage true (_ : false) (_ _ : false))
+ (a : triage false (self a) (_ _ : false))
+ (a b : triage false (_ : false) (c d : lAnd (self a c) (self b d)))'''
+PROGRAMS = {
+ 'copy-triage': 'worker self xs = triage t (_ : t) (head tail : t (t head) (self tail)) xs',
+ 'copy-delayed': 'worker self xs = select (_ : t) (_ : t) (head tail : t (t head) (self tail)) xs',
+ 'copy-case': 'worker self xs = case xs of\n [] = []\n [head | tail] = t (t head) (self tail)\n _ = []',
+ 'copy-equations': 'worker self [] = []\nworker self [head | tail] = t (t head) (self tail)\nworker self _ = []',
+ 'copy-case-cons-first': 'worker self xs = case xs of\n [head | tail] = t (t head) (self tail)\n _ = []',
+ 'copy-equations-cons-first': 'worker self [head | tail] = t (t head) (self tail)\nworker self _ = []',
+ 'copy-equations-cons-first-three': 'worker self [head | tail] = t (t head) (self tail)\nworker self [] = []\nworker self _ = []',
+ 'pick-triage': 'pick value = triage t (_ : t t) (_ _ : t t t) value',
+ 'pick-delayed': 'pick value = select (_ : t) (_ : t t) (_ _ : t t t) value',
+ 'pick-case': 'pick value = case value of\n t = t\n (t _) = t t\n _ = t t t',
+ 'pick-equations': 'pick t = t\npick (t _) = t t\npick _ = t t t',
+ 'eq-triage': 'worker = (self : ' + EQ_BODY + ')',
+ 'eq-triage-explicit': 'worker self left right = (' + EQ_BODY + ') left right',
+ 'eq-delayed': '''worker self left right = select
+ (_ : select (_ : t t) (_ : t) (_ _ : t) right)
+ (a : select (_ : t) (b : self a b) (_ _ : t) right)
+ (a b : select (_ : t) (_ : t) (c d : lAnd (self a c) (self b d)) right)
+ left''',
+ 'eq-equations': 'worker self t t = t t\nworker self (t a) (t b) = self a b\nworker self (t a b) (t c d) = lAnd (self a c) (self b d)\nworker self _ _ = t',
+ 'eq-nested-case': '''worker self left right = case left of
+ t = case right of
+ t = t t
+ _ = t
+ (t a) = case right of
+ (t b) = self a b
+ _ = t
+ (t a b) = case right of
+ (t c d) = lAnd (self a c) (self b d)
+ _ = t
+ _ = t''',
+}
+
+
+def authored_cases():
+ """Reuse the existing independent corpus, not its generation-preparation CLI."""
+ text = (ROOT / 'test/source_equations.py').read_text()
+ namespace = {'json': json}
+ exec(compile(text[text.index('def quoted'):text.index('manager =')],
+ 'source_equations.py:authored-corpus', 'exec'), namespace)
+ return namespace['cases']
+
+
+def audit(workspace, store, producer, reused):
+ """Verify receipts, all exported objects and exact local/external pin sets."""
+ def obj(domain, pin):
+ assert re.fullmatch('[0-9a-f]{64}', pin)
+ payload = (store / 'objects' / pin[:3] / pin).read_bytes()
+ assert digest(domain, payload) == pin
+ return payload
+ modules = {}
+ for source in sorted(workspace.glob('*.tri')):
+ fields = (store / 'cache/source-build' / source.stem).read_text().strip().split('\t')
+ assert len(fields) == 4 and fields[0] == 'arboricx.source-build-cache.v1'
+ _, key, manifest, receipt = fields
+ rows = checked_receipt(obj('arboricx.build-receipt.v1', receipt), source.read_bytes(), manifest, producer)
+ exports = {}
+ for line in obj('arboricx.module-manifest.v1', manifest).decode().splitlines()[1:]:
+ tag, name, kind, pin, abi = line.split('\t')
+ assert tag == 'export' and kind in KINDS and abi == 'arboricx.abi.tree.v1'
+ assert name not in exports
+ verify_payload(kind, obj(kind, pin))
+ exports[name] = pin
+ dependencies = [row for row in rows if row[0] == 'local']
+ assert [row[1] for row in dependencies] == native_header(source.read_text())[0]
+ modules[source.stem] = dict(manifest=manifest, receipt=receipt, exports=exports,
+ dependencies=dependencies, pins=[row for row in rows if row[0] == 'pin'],
+ source_sha256=sha(source.read_bytes()))
+ for alias, module in modules.items():
+ expected = set()
+ for _, dep, manifest, receipt in module['dependencies']:
+ assert (manifest, receipt) == (modules[dep]['manifest'], modules[dep]['receipt'])
+ expected.update(('pin', dep, manifest, name, pin) for name, pin in modules[dep]['exports'].items())
+ if alias in reused:
+ ref = reused[alias]['manifest']
+ assert module['manifest'] == ref
+ expected.update(('pin', ref, ref, name, pin) for name, pin in module['exports'].items())
+ assert {tuple(row) for row in module['pins']} == expected, ('stale dependency pins', alias)
+ return modules
+
+
+def sha(raw):
+ return hashlib.sha256(raw).hexdigest()
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('tricu', type=Path)
+ parser.add_argument('--directory', type=Path, required=True)
+ parser.add_argument('--resume', action='store_true', help='diagnostic checkpoint reuse, not fresh-generation acceptance')
+ parser.add_argument('--skip-costs', action='store_true')
+ args = parser.parse_args()
+ root, host = args.directory.resolve(), args.tricu.resolve()
+ root.mkdir(parents=True, exist_ok=args.resume)
+ workspace, store = root / 'workspace', root / 'store'
+ workspace.mkdir(exist_ok=args.resume)
+ metadata = verify_compiler()
+ rows = {row[2]: row for row in verify(ROOT / 'test/fixtures')}
+ report = dict(complete=False, resumed=args.resume, compiler_sha256=metadata['sha256'],
+ runtime_sha256=sha(host.read_bytes()), sources={}, reused={}, commands=[], samples=[], costs=[])
+ started = time.monotonic()
+ if (root / 'report.json').exists():
+ previous = (root / 'report.json').read_bytes()
+ assert json.loads(previous)['compiler_sha256'] == metadata['sha256']
+ (root / ('previous-' + sha(previous) + '.json')).write_bytes(previous)
+ def save():
+ report['seconds'] = time.monotonic() - started
+ (root / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
+ def command(argv, seconds=30):
+ remaining = 1200 - (time.monotonic() - started)
+ if remaining <= 0:
+ raise TimeoutError('20-minute candidate budget exhausted')
+ begin = time.monotonic()
+ reply = subprocess.run(list(map(str, argv)), cwd=ROOT, capture_output=True, text=True,
+ timeout=min(seconds, remaining))
+ report['commands'].append(dict(argv=list(map(str, argv)), returncode=reply.returncode,
+ seconds=time.monotonic()-begin, stdout=reply.stdout, stderr=reply.stderr))
+ save()
+ if reply.returncode:
+ raise RuntimeError(reply.stderr[-3000:] + reply.stdout[-3000:])
+ return reply
+ def run(options, seconds=30, memory=8192):
+ return command([host, 'arboricx', *options, '+RTS', '-N1', '-T', f'-M{memory}m', '-RTS'], seconds)
+ def imported(path):
+ reply = run(['import', '--file', path, '--store', store], 60)
+ return re.search(r'Created module alias .* -> ([0-9a-f]{64})', reply.stdout)[1]
+ entries = {}
+ for line in (ROOT / 'bootstrap/compiler-source-map.v1').read_text().splitlines()[1:]:
+ fields = line.split('\t')
+ entries[fields[1]] = (fields[3], [] if fields[4] == '-' else fields[4].split(','))
+ needed = []
+ def visit(alias):
+ if alias not in needed:
+ for dep in entries[alias][1]:
+ visit(dep)
+ needed.append(alias)
+ visit('source_unit')
+ affected = {'source_pattern'}
+ for alias in needed:
+ if any(dep in affected for dep in entries[alias][1]):
+ affected.add(alias)
+ active = affected | {dep for alias in affected for dep in entries[alias][1]}
+ report['affected'] = sorted(affected)
+ # Check the transitive unchanged closure, even behind a retained facade.
+ for alias in needed:
+ data = (ROOT / entries[alias][0]).read_bytes()
+ report['sources'][alias] = sha(data)
+ if alias not in affected:
+ assert snapshot_bytes(ROOT / 'test/fixtures', rows[alias][4]) == data, ('stale reuse', alias)
+ tool = imported(ROOT / metadata['bundle'])
+ payload = (store / 'objects' / tool[:3] / tool).read_bytes()
+ assert digest('arboricx.module-manifest.v1', payload) == tool
+ producer = next(row.split('\t')[3] for row in payload.decode().splitlines()[1:] if row.split('\t')[1] == 'sourceBuildGraph')
+ for alias in needed:
+ if alias not in active:
+ continue
+ path = workspace / (alias + '.tri')
+ if alias in affected:
+ path.write_bytes((ROOT / entries[alias][0]).read_bytes())
+ else:
+ cohort, _, _, _, source_pin = rows[alias]
+ fixture = ROOT / 'test/fixtures' / (cohort + '.arboricx')
+ names = [(name, name[len(alias)+1:]) for name in read(fixture)[3] if name.startswith(alias+'.')]
+ assert names
+ bundle = root / (alias + '-retained.arboricx')
+ merge_bundles(bundle, [(fixture, names)], prune_unreachable=True)
+ pin = imported(bundle)
+ path.write_text('!export * from ' + pin + '\n')
+ report['reused'][alias] = dict(source_pin=source_pin, manifest=pin, bundle_sha256=sha(bundle.read_bytes()))
+ assert {p.stem for p in workspace.glob('*.tri')} == active
+ subject = root / 'source_unit.arboricx'
+ run(['compile', '--file', workspace / 'source_unit.tri', '--toolchain', tool,
+ '--tool-store', store, '--store', store, '--output', subject, '--compile-timeout', '600'], 630)
+ report['modules'] = audit(workspace, store, producer, report['reused'])
+ assert imported(subject) == report['modules']['source_unit']['manifest'], 'tested/published unit mismatch'
+ report['candidate_sha256'] = sha(subject.read_bytes())
+ sources = root / 'program-sources'
+ sources.mkdir(exist_ok=args.resume)
+ for name, body in PROGRAMS.items():
+ if name.startswith('pick-'):
+ body += '\nworker self xs = triage t (_ : t) (head tail : t (pick head) (self tail)) xs'
+ source = COMMON + body + '\nmain input = y worker input' + (' input' if name.startswith('eq-') else '') + '\n'
+ (sources / (name+'.tri')).write_text(source)
+ (root / 'equations.json').write_text(json.dumps(authored_cases()) + '\n')
+ # GHC is invoked THROUGH Nix, never direct cabal or an ambient compiler.
+ expression = ('let f = builtins.getFlake ("git+file://" + toString ./.); '
+ 'p = f.inputs.nixpkgs.legacyPackages.${builtins.currentSystem}; '
+ 'in p.haskellPackages.ghcWithPackages (h: [h.cryptonite h.memory h.base16-bytestring h.vector h.aeson])')
+ command(['nix', 'shell', '--impure', '--expr', expression, '--command', 'ghc', '-O2', '-rtsopts',
+ '-XOverloadedStrings', '-XLambdaCase', '-XMultiWayIf', '-XScopedTypeVariables', '-isrc',
+ '-outputdir', root / 'test-objects', ROOT / 'test/PatternLowering.hs', '-o', root / 'controls'], 180)
+ report['controls'] = command([root / 'controls', root, '+RTS', '-M4096m', '-RTS'], 180).stdout
+ local = root / ('local-control-' + str(time.time_ns()))
+ command(['python3', ROOT / 'test/local_control.py', host, '--unit', subject, '--directory', local], 180)
+ report['local_control'] = json.loads((local / 'report.json').read_text())
+ if not args.skip_costs:
+ cost_probes(root, run, imported, report, save)
+ report['complete'] = True
+ save()
+ print('PASS: unpublished candidate; all affected consumers rebuilt and audited.')
+ for row in report['costs'] + report.get('construction_costs', []):
+ print(json.dumps(row))
+
+
+def cost_probes(root, run, imported, report, save):
+ names = ['copy-triage', 'copy-equations', 'copy-case', 'copy-equations-cons-first-three',
+ 'pick-triage', 'pick-case', 'pick-equations', 'eq-triage-explicit', 'eq-equations', 'eq-nested-case']
+ pins = {name: {role: imported(root / folder / (name+'.arboricx'))
+ for role, folder in [('reference', 'reference-programs'), ('candidate', 'programs')]}
+ for name in names}
+ values = [[b'\0', b'\1\0', b'\2\0\0'][i % 3] for i in range(2048)]
+ list_file = root / 'list.tree'
+ list_file.write_bytes(b''.join(b'\2'+v for v in values)+b'\0')
+ intern = Intern()
+ index = intern.node((0,))
+ for _ in range(12):
+ index = intern.node((2, index, index))
+ # Preserve a single export by selecting main before replacing its node table.
+ single = root / 'input-template.arboricx'
+ merge_bundles(single, [(root / 'reference-programs/eq-triage.arboricx', [('main', 'main')])], prune_unreachable=True)
+ template = read(single)
+ import struct
+ manifest = bytearray(template[0])
+ for offsets in template[4].values():
+ for offset in offsets:
+ struct.pack_into('>I', manifest, offset, index)
+ dag = root / 'dag.arboricx'
+ dag.write_bytes(assembled(manifest, intern.encoded()))
+ argument = imported(dag)
+ for name in names:
+ if name.startswith('eq-'):
+ arguments, expected = ['--arg-export', argument+':main'], '10'
+ else:
+ xs = [b'\1'+v for v in values] if name.startswith('copy-') else values
+ arguments = ['--arg-tree-file', list_file]
+ expected = ''.join(str(v) for v in b''.join(b'\2'+v for v in xs)+b'\0')
+ for repetition in range(4):
+ for role in (['reference', 'candidate'] if repetition % 2 == 0 else ['candidate', 'reference']):
+ reply = run(['run', '--module', pins[name][role], '--name', 'main', '--store', root/'store',
+ *arguments, '--format', 'ternary', '--profile', '--profile-full-result'], 20, 512)
+ assert reply.stdout == expected, (name, role, 'independent complete-output oracle')
+ samples = {v['profile']: v for line in reply.stderr.splitlines() if line.startswith('{') for v in [json.loads(line)]}
+ before, after = samples['force-begin-1'], samples['force-end-1']
+ report['samples'].append(dict(name=name, role=role, warmup=repetition == 0,
+ wall_ns=after['wall_ns']-before['wall_ns'], allocated_bytes=after['allocated_bytes']-before['allocated_bytes']))
+ medians = {role: {key: statistics.median(row[key] for row in report['samples']
+ if row['name'] == name and row['role'] == role and not row['warmup'])
+ for key in ['wall_ns', 'allocated_bytes']} for role in ['reference', 'candidate']}
+ report['costs'].append(dict(name=name, scope='full-result-forcing-v1', medians=medians))
+ save()
+ # Also screen compiler construction costs, not only the resulting programs.
+ # Full forcing observes all returned definition trees. Semantic/code-generation
+ # oracles for these exact sources have already run in the Haskell driver.
+ from stable_bundle import byte_tree
+ reference = root / 'reference-unit.arboricx'
+ merge_bundles(reference, [(ROOT / 'test/fixtures/compiler-library.arboricx',
+ [('source_unit.compileStableModuleDefinitions', 'compileStableModuleDefinitions')])], prune_unreachable=True)
+ compilers = {'reference': imported(reference), 'candidate': imported(root / 'source_unit.arboricx')}
+ environment = root / 'empty-environment.tree'
+ environment.write_bytes(b'\0')
+ report['construction_samples'], report['construction_costs'] = [], []
+ for name in ['copy-equations', 'pick-equations', 'eq-equations']:
+ source = root / (name + '.source.tree')
+ source.write_bytes(byte_tree((root / 'program-sources' / (name+'.tri')).read_bytes()))
+ for repetition in range(4):
+ for role in (['reference', 'candidate'] if repetition % 2 == 0 else ['candidate', 'reference']):
+ reply = run(['run', '--module', compilers[role], '--name', 'compileStableModuleDefinitions',
+ '--store', root/'store', '--arg-tree-file', environment, '--arg-tree-file', source,
+ '--profile', '--profile-full-result', '--result', '--format', 'number'], 30, 1024)
+ # Deliberately cheap presentation AFTER complete forcing: the
+ # definition list is not a number. A compiler error is not success.
+ assert reply.stdout == 'ok: <not-number: Invalid Tree Calculus number>'
+ samples = {v['profile']: v for line in reply.stderr.splitlines() if line.startswith('{') for v in [json.loads(line)]}
+ before, after = samples['force-begin-1'], samples['force-end-1']
+ report['construction_samples'].append(dict(name=name, role=role, warmup=repetition == 0,
+ wall_ns=after['wall_ns']-before['wall_ns'], allocated_bytes=after['allocated_bytes']-before['allocated_bytes']))
+ medians = {role: {key: statistics.median(row[key] for row in report['construction_samples']
+ if row['name'] == name and row['role'] == role and not row['warmup'])
+ for key in ['wall_ns', 'allocated_bytes']} for role in ['reference', 'candidate']}
+ report['construction_costs'].append(dict(name=name, scope='compile-full-definitions-v1', medians=medians))
+ save()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/project_dependencies.py b/test/project_dependencies.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+"""Cheap real-CLI project controls; controlled GET peer, no compiler execution."""
+import argparse
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+import subprocess
+import tempfile
+import threading
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('tricu', type=Path)
+ exe = parser.parse_args().tricu.resolve()
+ with tempfile.TemporaryDirectory(prefix='tricu-project-') as temp:
+ root = Path(temp)
+ store, registry = root / 'cas', root / 'registry'
+ declaration, lock, config = root / 'tricu.project', root / 'tricu.lock', root / 'registries'
+ workspace = root / 'workspace'; workspace.mkdir()
+ requests, overrides = [], {}
+ def run(*args, ok=True):
+ reply = subprocess.run([str(exe), *map(str, args), '+RTS', '-N1', '-M1024m', '-RTS'],
+ cwd=root, capture_output=True, text=True, timeout=30)
+ assert (reply.returncode == 0) == ok, (args, reply.stdout, reply.stderr)
+ return reply.stdout if ok else reply.stderr
+ def publish(name, version, fixture):
+ return run('package', 'publish', '--root', registry, '--name', name, '--version', version,
+ '--file', ROOT / 'test/fixtures' / (fixture + '.arboricx')).splitlines()[1].split('\t')
+ first = publish('core', '1', 'id')
+ second = publish('core', '2', 'append')
+ other = publish('other', '1', 'false')
+ class Handler(BaseHTTPRequestHandler):
+ def log_message(self, *_): pass
+ def do_GET(self):
+ requests.append(self.path)
+ parts = self.path.split('/')
+ target = None
+ if parts[:3] == ['', '_arboricx', 'package'] and len(parts) == 5:
+ target = registry / 'packages' / parts[3] / parts[4]
+ elif parts[:4] == ['', '_arboricx', 'bundle', 'hash'] and len(parts) == 5:
+ target = registry / 'objects' / parts[4][:3] / (parts[4] + '.arboricx')
+ body = overrides.get(self.path)
+ if body is None and target and target.exists(): body = target.read_bytes()
+ self.send_response(200 if body is not None else 404)
+ body = body if body is not None else b'not found'
+ self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body)
+ server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start()
+ url = 'http://127.0.0.1:' + str(server.server_port)
+ config.write_text('arboricx.registries.v1\nhome\t' + url + '\n')
+ def write(ds):
+ declaration.write_text('arboricx.project.v1\n' + ''.join('\t'.join(row) + '\n' for row in ds))
+ initial = [('core', 'home', 'core', '1'), ('other', 'home', 'other', '1')]
+ def project(action, *args, ok=True):
+ opts = ['--registries', config] if action in ['resolve', 'update', 'fetch'] else ['--directory', workspace]
+ if action in ['fetch', 'bind', 'check']: opts += ['--store', store]
+ return run('project', action, *opts, *args, ok=ok)
+ def frozen(): return lock.read_bytes()
+ state = workspace / '.arboricx-project-bindings'
+ pending = workspace / '.arboricx-project-bindings.pending'
+ def pins(data):
+ return {fields[0]: fields[1:] for fields in (row.split('\t') for row in data.decode().splitlines()[1:])}
+ try:
+ write(initial)
+ project('update', '--all', ok=False)
+ assert not requests and not lock.exists()
+ assert 'Added core' in project('resolve')
+ original = frozen()
+ assert len(requests) == 2
+ assert 'unchanged' in project('resolve') and len(requests) == 2
+ assert not store.exists() # resolve never fetches or imports
+ project('bind', ok=False)
+ assert not state.exists() and not list(workspace.glob('*.tri'))
+ project('fetch')
+ assert len(requests) == 4 and not list(workspace.glob('*.tri'))
+ # Preflight detects a conflict anywhere before writing any bindings.
+ authored = workspace / 'other.tri'; authored.write_text('authored source\n')
+ project('bind', ok=False)
+ assert not state.exists() and not pending.exists() and not (workspace / 'core.tri').exists()
+ authored.unlink()
+ authored.symlink_to(declaration)
+ project('bind', ok=False)
+ authored.unlink()
+ project('bind'); project('check'); project('bind')
+ old_binding = (workspace / 'core.tri').read_bytes()
+ before_state = state.read_bytes()
+ # Scope enforcement includes additions and removals, and happens before GET.
+ write([('core', 'home', 'core', '2'), ('other', 'home', 'other', '2')])
+ project('resolve', ok=False)
+ project('update', '--dependency', 'core', ok=False)
+ assert len(requests) == 4 and frozen() == original
+ write([('core', 'home', 'core', '2'), initial[1]])
+ for flags in [[], ['--dependency', 'missing'], ['--dependency', 'core', '--dependency', 'core'],
+ ['--all', '--dependency', 'core']]:
+ project('update', *flags, ok=False)
+ assert len(requests) == 4
+ text = project('update', '--dependency', 'core')
+ assert 'Updated core' in text and 'Updated other' not in text and 'module changed; bundle changed' in text
+ updated = frozen()
+ assert len(requests) == 5 and pins(original)['other'] == pins(updated)['other']
+ project('check', ok=False)
+ project('bind', ok=False) # new module not fetched; old bindings preserved
+ assert (workspace / 'core.tri').read_bytes() == old_binding and state.read_bytes() == before_state
+ project('fetch'); assert len(requests) == 6
+ # Simulate interrupted binding after journal and one new file; recover.
+ next_state = 'arboricx.project-bindings.v1\n' + ''.join(a + '\t' + r[4] + '\n' for a, r in pins(updated).items())
+ pending.write_text(next_state)
+ (workspace / 'core.tri').write_text('!export * from ' + second[2] + '\n')
+ project('check', ok=False)
+ project('bind'); project('check'); assert not pending.exists()
+ (workspace / 'core.tri').write_text('user edit\n')
+ project('check', ok=False); project('bind', ok=False)
+ assert (workspace / 'core.tri').read_text() == 'user edit\n'
+ (workspace / 'core.tri').write_text('!export * from ' + second[2] + '\n')
+ # Rechecking a frozen version rejects changed assertions, also across
+ # a local binding rename; failed resolutions never replace the lock.
+ path = '/_arboricx/package/core/2'
+ overrides[path] = ('arboricx.package-release.v1\ncore\t2\t' + first[2] + '\t' + first[3] + '\n').encode()
+ assert 'equivocation' in project('update', '--dependency', 'core', ok=False)
+ assert frozen() == updated
+ write([('renamed', 'home', 'core', '2'), initial[1]])
+ assert 'equivocation' in project('update', '--all', ok=False)
+ assert frozen() == updated
+ overrides.clear()
+ # Failed later resolution leaves the old complete lock untouched.
+ write([('core', 'home', 'core', '1'), ('other', 'home', 'other', 'missing')])
+ project('update', '--all', ok=False); assert frozen() == updated
+ # A late-origin mismatch must not populate even the first dependency
+ # in a fresh store. This is stronger than observing a cached fetch.
+ write([('core', 'home', 'core', '2'), ('other', 'away', 'other', '1')])
+ lock.write_bytes(updated.replace(b'other\thome\t', b'other\taway\t'))
+ config.write_text('arboricx.registries.v1\nhome\t' + url + '\naway\thttp://127.0.0.1:1\n')
+ clean = root / 'must-stay-empty'
+ count = len(requests)
+ run('project', 'fetch', '--registries', config, '--store', clean, ok=False)
+ assert len(requests) == count and not any(p.is_file() for p in clean.rglob('*'))
+ lock.write_bytes(updated)
+ # Origin trust is preflighted for ALL dependencies before fetch.
+ write([('core', 'home', 'core', '2'), initial[1]])
+ config.write_text('arboricx.registries.v1\nhome\thttp://127.0.0.1:1\n')
+ count = len(requests)
+ project('fetch', ok=False); project('resolve', ok=False)
+ assert len(requests) == count and frozen() == updated
+ config.write_text('arboricx.registries.v1\nhome\t' + url + '\n')
+ # Remove only an owned, unedited binding. Other authored files survive.
+ (workspace / 'main.tri').write_text('authored main\n')
+ write([('core', 'home', 'core', '2')])
+ project('update', '--dependency', 'other')
+ assert len(requests) == count
+ authored.write_text('edited owned binding\n')
+ project('bind', ok=False)
+ assert authored.read_text() == 'edited owned binding\n'
+ authored.write_text('!export * from ' + other[2] + '\n')
+ project('bind'); project('check')
+ assert not authored.exists() and (workspace / 'main.tri').read_text() == 'authored main\n'
+ # Output alias/input collisions must not destroy declarations or locks.
+ project('resolve', '--lock', declaration, ok=False)
+ project('resolve', '--lock', config, ok=False)
+ finally:
+ server.shutdown(); server.server_close(); thread.join()
+ count = len(requests)
+ project('resolve'); project('fetch'); project('bind'); project('check')
+ assert len(requests) == count
+ # Corrupt a CAS manifest: offline check/fetch must fail, not repair.
+ manifest = store / 'objects' / second[2][:3] / second[2]
+ saved = manifest.read_bytes(); manifest.write_bytes(saved + b'corrupt')
+ project('fetch', ok=False); project('check', ok=False)
+ manifest.write_bytes(saved)
+ project('check')
+ assert not any(p.is_file() for p in (store / 'aliases').rglob('*'))
+ # Non-default declaration/lock paths, default adjacent binding directory,
+ # and the global --store option all compose without changing cwd.
+ alternate = root / 'alternate'; alternate.mkdir()
+ (alternate / 'input.project').write_bytes(declaration.read_bytes())
+ (alternate / 'selection.lock').write_bytes(lock.read_bytes())
+ for action in ['bind', 'check']:
+ run('--store', store, 'project', action, '--project', 'alternate/input.project', '--lock', 'alternate/selection.lock')
+ assert (alternate / 'core.tri').exists() and not (root / 'core.tri').exists()
+ # Empty sets remain explicit and may remove every dependency offline.
+ write([])
+ project('update', '--all'); project('bind'); project('check'); project('resolve'); project('fetch')
+ assert not list(workspace.glob('core.tri')) and (workspace / 'main.tri').exists()
+ assert len(requests) == count
+ print('PASS project freeze/update scope, atomic lock failure, offline fetch/bind/check, ownership, recovery, equivocation and corruption controls')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/project_inspection.py b/test/project_inspection.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""Offline, read-only project reports against independent DAG/CAS fixtures."""
+import argparse
+import hashlib
+from pathlib import Path
+import struct
+import subprocess
+import tempfile
+from stable_bundle import assembled, sections
+
+ROOT = Path(__file__).resolve().parent.parent
+ABI = 'arboricx.abi.tree.v1'
+KIND = 'arboricx.indexed-tree.v1'
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('tricu', type=Path)
+ exe = p.parse_args().tricu.resolve()
+ with tempfile.TemporaryDirectory(prefix='project-inspection-') as temp:
+ root = Path(temp); store = root / 'cas'
+ def put(kind, data):
+ pin = hashlib.sha256(kind.encode() + b'\0' + data).hexdigest()
+ path = store / 'objects' / pin[:3] / pin
+ path.parent.mkdir(parents=True, exist_ok=True); path.write_bytes(data)
+ return pin
+ manifest, _ = sections(ROOT / 'test/fixtures/id.arboricx')
+ at = 12
+ for _ in range(9):
+ size = struct.unpack_from('>I', manifest, at)[0]; at += 4 + size
+ assert struct.unpack_from('>I', manifest, at)[0] == 0
+ prefix = manifest[:at + 5]
+ def text(value):
+ data = value.encode(); return struct.pack('>I', len(data)) + data
+ def graph(nodes, selected=None):
+ selected = len(nodes) - 1 if selected is None else selected
+ m = (prefix + struct.pack('>II', 1, selected) + text('default') + struct.pack('>I', 1)
+ + text('root') + struct.pack('>I', selected) + text('term') + text(ABI) + struct.pack('>II', 0, 0))
+ records = [bytes([len(children)]) + b''.join(struct.pack('>I', c) for c in children) for children in nodes]
+ return assembled(m, struct.pack('>Q', len(nodes)) + b''.join(struct.pack('>I', len(r)) + r for r in records))
+ def esc(value):
+ return value.replace('%', '%25').replace('\t', '%09').replace('\n', '%0A').replace('\r', '%0D')
+ def module(exports):
+ data = 'arboricx.module-manifest.v1\n'
+ for name, obj, abi, kind in exports:
+ data += '\t'.join(['export', esc(name), esc(kind), obj, esc(abi)]) + '\n'
+ return put('arboricx.module-manifest.v1', data.encode())
+ def lock(file, pin, version):
+ file.write_text('arboricx.project-lock.v1\nfoundation\thome\thttp://127.0.0.1:1\tbase\t' + version + '\t' + pin + '\t' + 'b'*64 + '\n')
+ before, after = root / 'before.lock', root / 'tricu.lock'
+ declaration = root / 'tricu.project'
+ declaration.write_text('arboricx.project.v1\nfoundation\thome\tbase\t2\n')
+ def snapshot():
+ return {str(p.relative_to(root)): (p.read_bytes(), p.stat().st_mtime_ns) for p in root.rglob('*') if p.is_file()}
+ def run(action, *args, ok=True, selected_store=store):
+ saved = snapshot()
+ reply = subprocess.run([str(exe), 'project', action, '--store', str(selected_store), *map(str, args),
+ '+RTS', '-N1', '-M512m', '-RTS'], cwd=root, capture_output=True, text=True, timeout=30)
+ assert (reply.returncode == 0) == ok, (reply.stdout, reply.stderr)
+ assert snapshot() == saved, 'report mutated files'
+ assert '\x1b' not in reply.stdout
+ return reply.stdout
+ def diff(ok=True, selected_store=store):
+ return run('diff', '--before', before, '--after', after, ok=ok, selected_store=selected_store)
+ left = put(KIND, graph([[], [0], [0, 0], [1, 2]]))
+ right = put(KIND, graph([[], [0, 0], [0], [2, 1]]))
+ assert left != right # reordered physical nodes, identical ordered tree
+ old = module([('value', left, ABI, KIND)])
+ new = module([('value', right, ABI, KIND), ('extra', left, ABI, KIND)])
+ lock(before, old, '1'); lock(after, new, '2')
+ report = diff()
+ assert 'object changed; tree identical' in report and 'export extra: added' in report
+ assert 'Updated foundation' in report
+ # Bind once via the real ownership protocol; reporting must not rewrite it.
+ subprocess.run([str(exe), 'project', 'bind', '--store', str(store), '+RTS', '-N1', '-M512m', '-RTS'], cwd=root, check=True, capture_output=True, timeout=30)
+ assert 'local module: verified' in run('explain')
+ binding_bytes = (root / 'foundation.tri').read_bytes()
+ (root / 'foundation.tri').write_text('edited source\n')
+ assert 'generated binding: differs' in run('explain', ok=False)
+ (root / 'foundation.tri').write_bytes(binding_bytes)
+ pending = root / '.arboricx-project-bindings.pending'
+ pending.write_text('interrupted')
+ assert 'pending or unreadable' in run('explain', ok=False)
+ pending.unlink()
+ assert 'generated binding: matches lock' in run('explain')
+ declaration.write_text(declaration.read_text().replace('base\t2', 'base\t3'))
+ assert 'declared: home base@3' in run('explain', ok=False)
+ # Same pin is NOT sufficient evidence when its local bytes are absent/bad.
+ lock(before, new, '2')
+ target = store / 'objects' / right[:3] / right
+ saved = target.read_bytes(); target.unlink()
+ assert 'tree unknown' in diff(ok=False) and 'missing' in diff(ok=False)
+ target.write_bytes(saved + b'corrupt')
+ assert 'invalid:' in diff(ok=False)
+ target.write_bytes(saved)
+ empty = root / 'absent-store'
+ assert 'unknown (module evidence unavailable)' in diff(ok=False, selected_store=empty)
+ assert not empty.exists()
+ # Exponential logical tree and an extra unreachable record: no unfolding.
+ nodes = [[]] + [[i-1, i-1] for i in range(1, 81)]
+ huge = put(KIND, graph(nodes))
+ extra = put(KIND, graph(nodes + [[0]], 80))
+ lock(before, module([('huge', huge, ABI, KIND)]), '1')
+ lock(after, module([('huge', extra, ABI, KIND)]), '2')
+ assert 'object changed; tree identical' in diff()
+ different = put(KIND, graph(nodes[:-1] + [[79]]))
+ lock(after, module([('huge', different, ABI, KIND)]), '2')
+ assert 'tree different' in diff()
+ # Unsupported ABIs/kinds are explicit; no semantic equivalence inferred.
+ lock(after, module([('huge', huge, 'future.abi', KIND)]), '2')
+ report = diff(ok=False)
+ assert 'ABI changed' in report and 'unsupported:' in report and 'tree unknown' in report
+ lock(after, module([('huge', huge, ABI, 'future.kind')]), '2')
+ assert 'kind changed' in diff(ok=False)
+ # Malformed-but-authenticated DAG and invalid object paths fail as evidence.
+ malformed = put(KIND, graph([[], [1]], 0))
+ lock(after, module([('huge', malformed, ABI, KIND)]), '2')
+ assert 'invalid:' in diff(ok=False)
+ lock(after, module([('huge', '../outside', ABI, KIND)]), '2')
+ assert 'invalid:' in diff(ok=False)
+ unsafe = 'tab\tline\n%\x1b é'
+ lock(after, module([(unsafe, left, ABI, KIND)]), '2')
+ report = diff()
+ assert '%09' in report and '%0a' in report and '%25' in report and '%1b' in report and '%c3%a9' in report
+ assert 'export huge: removed' in report
+ oversized = put('arboricx.module-manifest.v1', b'x' * 65537)
+ lock(after, oversized, '2')
+ assert 'inspection byte budget' in diff(ok=False)
+ # Nonregular CAS files must not be followed or block on FIFO reads.
+ import os
+ path = store / 'objects' / oversized[:3] / oversized
+ path.unlink(); os.mkfifo(path)
+ assert 'expected regular CAS file' in diff(ok=False)
+ path.unlink()
+ after.write_text('arboricx.project-lock.v1\n')
+ assert 'Removed foundation' in diff()
+ print('PASS read-only explain/diff; independent DAG equality, exponential sharing, missing/corrupt/unsupported evidence and display escaping')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/provenance.py b/test/provenance.py
@@ -0,0 +1,237 @@
+#!/usr/bin/env python3
+"""Native receipt retention controls; optional real-library offline rebuilds.
+
+Default tests use independently constructed CAS/receipt bytes, not compilation.
+--accepted consumes retained library_inspector.py artifacts, captures four packs
+from a disposable producer copy, deletes that copy, and rebuilds only from packs.
+"""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import struct
+import subprocess
+import tempfile
+import time
+from stable_bundle import assembled, sections
+
+ROOT = Path(__file__).resolve().parent.parent
+RECEIPT = 'arboricx.build-receipt.v1'
+SOURCE = 'arboricx.source.v1'
+INPUTS = 'arboricx.build-inputs.v1'
+OPTIONS = 'arboricx.build-options.v1'
+MANIFEST = 'arboricx.module-manifest.v1'
+INDEXED = 'arboricx.indexed-tree.v1'
+ABI = 'arboricx.abi.tree.v1'
+
+
+def digest(kind, data):
+ return hashlib.sha256(kind.encode() + b'\0' + data).hexdigest()
+
+
+def put(store, kind, data):
+ pin = digest(kind, data)
+ path = store / 'objects' / pin[:3] / pin
+ path.parent.mkdir(parents=True, exist_ok=True); path.write_bytes(data)
+ return pin
+
+
+def object_path(store, pin):
+ return store / 'objects' / pin[:3] / pin
+
+
+def snapshot(root):
+ return {str(p.relative_to(root)): (p.read_bytes(), p.stat().st_mtime_ns)
+ for p in root.rglob('*') if p.is_file()}
+
+
+def controls(exe):
+ with tempfile.TemporaryDirectory(prefix='provenance-controls-') as temp:
+ root = Path(temp); store = root / 'cas'; workspace = root / 'workspace'; workspace.mkdir()
+ def run(*args, good=True):
+ result = subprocess.run([str(exe), 'provenance', *map(str, args), '+RTS', '-N1', '-M512m', '-RTS'],
+ cwd=root, capture_output=True, text=True, timeout=30)
+ assert (result.returncode == 0) == good, (args, result.stdout, result.stderr)
+ return result.stdout + result.stderr
+ # A leaf is NOT a compiler. Successful verification with this fixture
+ # must not interpret its sourceBuildGraph export.
+ m, _ = sections(ROOT / 'test/fixtures/id.arboricx'); at = 12
+ for _ in range(9):
+ size = struct.unpack_from('>I', m, at)[0]; at += 4 + size
+ assert struct.unpack_from('>I', m, at)[0] == 0
+ def text(t):
+ b = t.encode(); return struct.pack('>I', len(b)) + b
+ manifest = (m[:at+5] + struct.pack('>II', 1, 0) + text('default') + struct.pack('>I', 1)
+ + text('root') + struct.pack('>I', 0) + text('term') + text(ABI) + struct.pack('>II', 0, 0))
+ leaf = put(store, INDEXED, assembled(manifest, struct.pack('>QI', 1, 1) + b'\0'))
+ def module(name):
+ return put(store, MANIFEST, (MANIFEST + '\nexport\t' + name + '\t' + INDEXED + '\t' + leaf + '\t' + ABI + '\n').encode())
+ compiler, output = module('sourceBuildGraph'), module('main')
+ source = b'!export main\nmain = t\n'
+ (workspace / 'main.tri').write_bytes(source)
+ options = digest(OPTIONS, b'source-module.stable.v1')
+ rows = 'source\t' + digest(SOURCE, source) + '\ncompiler\t' + leaf + '\noptions\t' + options + '\n'
+ receipt = put(store, RECEIPT, (RECEIPT + '\n' + rows + 'output\t' + output + '\n').encode())
+ key = digest('arboricx.source-build-key.v1', ('arboricx.source-build-key.v1\n' + rows).encode())
+ cache = store / 'cache/source-build/main'; cache.parent.mkdir(parents=True)
+ cache.write_text('\t'.join(['arboricx.source-build-cache.v1', key, output, receipt]))
+ def capture(target, good=True, selected_receipt=None):
+ return run('capture', '--file', workspace / 'main.tri', '--store', store,
+ '--tool-store', store, '--toolchain', compiler, '--output', target,
+ *(['--receipt', selected_receipt] if selected_receipt else []), good=good)
+ (workspace / 'main.tri').write_bytes(source + b'-- drift\n')
+ capture(root / 'stale', False); assert not (root / 'stale').exists()
+ (workspace / 'main.tri').write_bytes(source)
+ pack = root / 'pack'
+ pin = re.search(r'Retained build inputs: ([0-9a-f]{64})', capture(pack))[1]
+ before = snapshot(pack)
+ assert 'Producer claims only; no build executed' in run('verify', '--pack', pack, '--pin', pin)
+ assert snapshot(pack) == before
+ capture(pack, False); assert snapshot(pack) == before
+ run('verify', '--pack', pack, '--pin', '0'*64, good=False)
+ run('rebuild', '--pack', pack, '--pin', pin, '--trust-compiler', '0'*64, '--output', root / 'unauthorized', good=False)
+ assert not (root / 'unauthorized').exists()
+ packed = pack / 'store'
+ for kind, obj in [(SOURCE, digest(SOURCE, source)), (OPTIONS, options), (RECEIPT, receipt), (INDEXED, leaf)]:
+ path = object_path(packed, obj); original = path.read_bytes()
+ path.unlink(); run('verify', '--pack', pack, '--pin', pin, good=False)
+ path.write_bytes(original + b'corrupt'); run('verify', '--pack', pack, '--pin', pin, good=False)
+ path.write_bytes(original)
+ # Recompute the descriptor pin to exercise semantic validation, not merely SHA checks.
+ descriptor = pack / 'build-inputs'; original = descriptor.read_bytes()
+ for changed in [original.replace(b'entry\tmain', b'entry\t../outside'),
+ original.replace(b'unit\tmain', b'unit\tother'), original + b'unknown\tfield\n']:
+ descriptor.write_bytes(changed)
+ run('verify', '--pack', pack, '--pin', digest(INPUTS, changed), good=False)
+ descriptor.write_bytes(original)
+ objects = packed / 'objects'; moved = packed / 'real-objects'
+ objects.rename(moved); objects.symlink_to(moved, target_is_directory=True)
+ run('verify', '--pack', pack, '--pin', pin, good=False)
+ objects.unlink(); moved.rename(objects)
+ # An immutable receipt can be selected even after the cache locator is gone.
+ cache.unlink()
+ explicit = capture(root / 'explicit-receipt', selected_receipt=receipt)
+ assert pin in explicit
+ # Distribute only the pack; the compiler/library producer is no longer present.
+ shutil.rmtree(store); shutil.rmtree(workspace)
+ assert 'Verified retained bytes' in run('verify', '--pack', pack, '--pin', pin)
+ print('PASS native receipt retention, no-execution verification, stale source, corruption, missing inputs, trust gate, path and symlink controls')
+
+
+def acceptance(exe, accepted, directory):
+ directory.mkdir(parents=True, exist_ok=False)
+ metadata = json.loads((accepted / 'report.json').read_text())
+ compiler = metadata['compiler_manifest']
+ producer = directory / 'disposable-producer'; producer.mkdir()
+ for name in ['producer', 'tools', 'application', 'consumer']:
+ shutil.copytree(accepted / name, producer / name)
+ isolated = directory / 'isolated'; isolated.mkdir()
+ report = dict(complete=False, commands=[], packs={}, compiler=compiler)
+ started = time.monotonic()
+ def record(): (directory / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
+ def run(*args, good=True, seconds=120, namespace='provenance'):
+ remaining = 2400 - (time.monotonic() - started)
+ if remaining <= 0: raise TimeoutError('whole provenance acceptance deadline')
+ before = time.monotonic()
+ result = subprocess.run([str(exe), namespace, *map(str, args), '+RTS', '-N1', '-M8192m', '-RTS'],
+ cwd=isolated, capture_output=True, text=True, timeout=min(seconds, remaining),
+ env=dict(os.environ, tricu_datadir=str(directory / 'NO-PACKAGED-COMPILER')))
+ report['commands'].append(dict(namespace=namespace, args=list(map(str,args)), code=result.returncode, stdout=result.stdout,
+ stderr=result.stderr, seconds=time.monotonic()-before)); record()
+ assert (result.returncode == 0) == good, report['commands'][-1]
+ return result.stdout
+ for entry in ['base_release', 'io_release', 'inspection_release', 'main']:
+ workspace = producer / ('consumer' if entry == 'main' else 'producer/workspace')
+ store = producer / ('application' if entry == 'main' else 'producer/store')
+ pack = directory / ('pack-' + entry)
+ args = ['--project', workspace / 'tricu.project'] if entry == 'main' else []
+ reply = run('capture', '--file', workspace / (entry + '.tri'), '--store', store,
+ '--tool-store', producer / 'tools', '--toolchain', compiler, '--output', pack, *args)
+ pin = re.search(r'Retained build inputs: ([0-9a-f]{64})', reply)[1]
+ report['packs'][entry] = pin
+ shutil.rmtree(producer)
+ assert not producer.exists()
+ for entry, pin in report['packs'].items():
+ print('Rebuilding only from retained inputs:', entry, flush=True)
+ pack = directory / ('pack-' + entry)
+ saved = snapshot(pack)
+ run('verify', '--pack', pack, '--pin', pin)
+ result = run('rebuild', '--pack', pack, '--pin', pin, '--trust-compiler', compiler,
+ '--output', directory / ('rebuild-' + entry), '--compile-timeout', '900', seconds=930)
+ assert 'Reproduced native module and receipt exactly' in result
+ assert snapshot(pack) == saved
+ # Two genuine histories, one executable: an ignored source comment changes
+ # source/receipt/pack identities without changing the native module.
+ history = directory / 'history-producer'
+ shutil.copytree(directory / 'rebuild-main', history)
+ (history / 'rebuild.txt').unlink()
+ main = history / 'workspace/main.tri'
+ main.write_bytes(main.read_bytes() + b'\n-- Independently retained source history.\n')
+ run('compile', '--file', main, '--store', history / 'application', '--tool-store', history / 'tools',
+ '--toolchain', compiler, '--output', history / 'inspector.arboricx', '--compile-timeout', '900',
+ seconds=930, namespace='arboricx')
+ alternate = directory / 'alternate-history'
+ reply = run('capture', '--file', main, '--store', history / 'application', '--tool-store', history / 'tools',
+ '--toolchain', compiler, '--project', history / 'workspace/tricu.project', '--output', alternate)
+ alternate_pin = re.search(r'Retained build inputs: ([0-9a-f]{64})', reply)[1]
+ def identities(pack):
+ receipt = (pack / 'build-inputs').read_text().splitlines()[2].split('\t')[1]
+ module = object_path(pack / 'store', receipt).read_text().splitlines()[4].split('\t')[1]
+ return receipt, module
+ old_receipt, old_module = identities(directory / 'pack-main')
+ new_receipt, new_module = identities(alternate)
+ assert old_module == new_module and old_receipt != new_receipt and alternate_pin != report['packs']['main']
+ shutil.rmtree(history)
+ reply = run('rebuild', '--pack', alternate, '--pin', alternate_pin, '--trust-compiler', compiler,
+ '--output', directory / 'rebuild-alternate-history', '--compile-timeout', '900', seconds=930)
+ assert 'Reproduced native module and receipt exactly' in reply
+ report['distinct_histories'] = dict(module=old_module, old_receipt=old_receipt,
+ new_receipt=new_receipt, alternate_pack=alternate_pin)
+ # A valid, fully authenticated producer claim can still be false. Change
+ # the source and its native receipt to claim the old inspector output, with
+ # no dependencies. Include a matching malicious producer cache. Copying that
+ # cache into the rebuild store could falsely "reproduce" the claimed output.
+ forged = directory / 'false-claim'
+ shutil.copytree(directory / 'pack-main', forged)
+ store = forged / 'store'
+ rows = (forged / 'build-inputs').read_text().splitlines()
+ old_receipt = rows[2].split('\t')[1]
+ receipt_rows = object_path(store, old_receipt).read_text().splitlines()
+ source = b'!export main\nmain = 1\n'
+ src = put(store, SOURCE, source)
+ new_receipt_bytes = ('\n'.join([RECEIPT, 'source\t'+src, receipt_rows[2], receipt_rows[3], receipt_rows[4]]) + '\n').encode()
+ new_receipt = put(store, RECEIPT, new_receipt_bytes)
+ new_descriptor = ('\n'.join([INPUTS, 'entry\tmain', 'receipt\t'+new_receipt, rows[3], 'unit\tmain\t'+new_receipt]
+ + [row for row in rows if row.startswith('project\t')]) + '\n').encode()
+ (forged / 'build-inputs').write_bytes(new_descriptor)
+ pin = digest(INPUTS, new_descriptor)
+ key_body = '\n'.join(['arboricx.source-build-key.v1', 'source\t'+src, receipt_rows[2], receipt_rows[3]]) + '\n'
+ key = digest('arboricx.source-build-key.v1', key_body.encode())
+ poison = store / 'cache/source-build/main'; poison.parent.mkdir(parents=True, exist_ok=True)
+ poison.write_text('\t'.join(['arboricx.source-build-cache.v1', key, receipt_rows[4].split('\t')[1], new_receipt]))
+ run('verify', '--pack', forged, '--pin', pin)
+ mismatch = run('rebuild', '--pack', forged, '--pin', pin, '--trust-compiler', compiler,
+ '--output', directory / 'mismatching-rebuild', '--compile-timeout', '900', seconds=930, good=False)
+ assert 'Rebuild mismatch' in mismatch and 'tree different' in mismatch
+ report['false_claim_detected'] = True
+ report['complete'] = True; report['seconds'] = time.monotonic()-started; record()
+ print('PASS four offline reproductions; exact native receipts and modules; distinct histories share one executable; authenticated false claim/cache poison rejected by fresh rebuild')
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('tricu', type=Path)
+ p.add_argument('--accepted', type=Path)
+ p.add_argument('--directory', type=Path)
+ args = p.parse_args()
+ if bool(args.accepted) != bool(args.directory): p.error('--accepted and --directory must be used together')
+ exe = args.tricu.resolve()
+ controls(exe)
+ if args.accepted: acceptance(exe, args.accepted.resolve(), args.directory.resolve())
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/sharing_probes.py b/test/sharing_probes.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""Bounded Haskell-only sharing probes using the shipped stable compiler.
+
+Fresh processes; separate full-result forcing from loading and rendering. These
+are synthetic characterization probes, not performance gates or reduction counts.
+"""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import re
+import statistics
+import struct
+import subprocess
+import time
+
+from bundle_dag import Intern, read
+from stable_bundle import assembled
+
+WORK = '''work [] = []
+work [head | tail] = t (t head) (work tail)
+work _ = t
+'''
+COMPARE = '''eq t t = t t
+eq (t a) (t b) = eq a b
+eq (t a b) (t c d) = case (eq a c) of
+ (t _) = eq b d
+ _ = t
+eq _ _ = t
+main input = eq input input
+'''
+
+
+def natural(n):
+ return b'\0' if not n else b'\2' + (b'\1\0' if n % 2 else b'\0') + natural(n // 2)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('tricu', type=Path)
+ parser.add_argument('--directory', required=True, type=Path)
+ parser.add_argument('--repeats', type=int, default=3)
+ args = parser.parse_args()
+ if not 1 <= args.repeats <= 5:
+ parser.error('--repeats must be within 1..5')
+ host, root = args.tricu.resolve(), args.directory.resolve()
+ root.mkdir(parents=True, exist_ok=False)
+ store = root / 'store'
+ started = time.monotonic()
+ report = dict(host=str(host), scope='fresh-process-full-result-forcing-v1',
+ commands=[], artifacts={}, samples=[], summaries=[])
+
+ def save():
+ (root / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
+
+ def run(options, seconds=20, allow_failure=False):
+ remaining = 180 - (time.monotonic() - started)
+ if remaining <= 0:
+ raise TimeoutError('180-second whole-probe budget exhausted')
+ command = [str(host), *map(str, options), '+RTS', '-N1', '-T', '-M512m', '-RTS']
+ begin = time.monotonic()
+ try:
+ reply = subprocess.run(command, capture_output=True, text=True,
+ timeout=min(seconds, remaining))
+ except subprocess.TimeoutExpired:
+ report['commands'].append(dict(command=command, outcome='timeout'))
+ save()
+ if allow_failure:
+ return None
+ raise
+ report['commands'].append(dict(command=command, returncode=reply.returncode,
+ seconds=time.monotonic() - begin,
+ stdout=reply.stdout, stderr=reply.stderr))
+ save()
+ if reply.returncode and not allow_failure:
+ raise RuntimeError(reply.stderr)
+ return reply
+
+ def imported(path):
+ reply = run(['arboricx', 'import', '--file', path, '--store', store])
+ return re.search(r'Created module alias .* -> ([0-9a-f]{64})', reply.stdout)[1]
+
+ def compile_program(name, body):
+ source, bundle = root / (name + '.tri'), root / (name + '.arboricx')
+ source.write_text('!export main\n' + body + '\n')
+ run(['arboricx', 'compile', '--file', source, '--store', store, '--output', bundle])
+ pin = imported(bundle)
+ report['artifacts'][name] = dict(manifest=pin,
+ source_sha256=hashlib.sha256(source.read_bytes()).hexdigest(),
+ bundle_sha256=hashlib.sha256(bundle.read_bytes()).hexdigest())
+ save()
+ return pin
+
+ def invocation(pin, arguments, result=False):
+ return ['arboricx', 'run', '--module', pin, '--name', 'main', '--store', store,
+ '--format', 'number' if result else 'ternary',
+ *(['--result'] if result else []), *arguments]
+
+ def measure(label, size, command, expected):
+ rows = []
+ for repeat in range(args.repeats):
+ reply = run([*command, '--profile', '--profile-full-result'])
+ assert reply.stdout == expected, (label, size, reply.stdout)
+ samples = {v['profile']: v for line in reply.stderr.splitlines()
+ if line.startswith('{') for v in [json.loads(line)]}
+ before, after = samples['force-begin-1'], samples['force-end-1']
+ row = dict(label=label, size=size, repeat=repeat,
+ force_ns=after['wall_ns'] - before['wall_ns'],
+ sampled_allocated_bytes=after['allocated_bytes'] - before['allocated_bytes'])
+ rows.append(row)
+ report['samples'].append(row)
+ summary = dict(label=label, size=size,
+ median_force_ns=statistics.median(r['force_ns'] for r in rows),
+ median_sampled_allocated_bytes=statistics.median(r['sampled_allocated_bytes'] for r in rows))
+ report['summaries'].append(summary)
+ save()
+ print(json.dumps(summary), flush=True)
+
+ pins = {}
+ for name, body in [('bound', 'let shared = work input in t shared shared'),
+ ('repeated', 't (work input) (work input)')]:
+ pins[name] = compile_program(name, WORK + 'main input = t (t t) (t 23 (' + body + '))')
+ # Independent complete-output control, not merely comparison between variants.
+ xs = b''.join(b'\2\1' + natural(97) for _ in range(4)) + b'\0'
+ expected = b'\2\1\0\2' + natural(23) + b'\2' + xs + xs
+ for pin in pins.values():
+ assert run(invocation(pin, ['--arg', 'aaaa'])).stdout == ''.join(str(v) for v in expected)
+ for size in [32, 256, 2048]:
+ for name, pin in pins.items():
+ measure(name, size, invocation(pin, ['--arg', 'a' * size], result=True), 'ok: 23')
+
+ comparator = compile_program('compare', COMPARE)
+ template = read(root / 'compare.arboricx')
+ for depth in [4, 8, 12, 16]:
+ intern = Intern()
+ index = intern.node((0,))
+ for _ in range(depth):
+ index = intern.node((2, index, index))
+ manifest = bytearray(template[0])
+ for offsets in template[4].values():
+ for offset in offsets:
+ struct.pack_into('>I', manifest, offset, index)
+ bundle = root / f'dag-{depth}.arboricx'
+ bundle.write_bytes(assembled(manifest, intern.encoded()))
+ argument = imported(bundle)
+ measure('structural-self-comparison', depth,
+ invocation(comparator, ['--arg-export', argument + ':main']), '10')
+
+ # Previously --profile forced the divergent remainder. Only the explicit
+ # full-result mode should now fail; a bounded failure is not a divergence proof.
+ partial = compile_program('partial',
+ 'main input = t (t t) (t 23 ((x : x x) (x : x x)))')
+ command = invocation(partial, ['--arg', 'x'], result=True)
+ for flags in [[], ['--profile']]:
+ assert run([*command, *flags]).stdout == 'ok: 23'
+ failed = run([*command, '--profile', '--profile-full-result'], seconds=3, allow_failure=True)
+ assert failed is None or failed.returncode != 0, 'full-result observation unexpectedly completed'
+ report['completed'] = True
+ save()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/source_order.py b/test/source_order.py
@@ -131,9 +131,10 @@ class Runner:
(self.root / (label + '.log')).write_text(output)
row = dict(label=label, seconds=time.monotonic()-start, peak_kib=peak, command=command)
samples = [json.loads(line) for line in output.splitlines() if line.startswith('{')]
- intervals = [sample for sample in samples if sample.get('profile') in ('invoke-begin-1', 'invoke-end-1')]
+ intervals = [sample for sample in samples if sample.get('profile') in ('invoke-begin-1', 'observe-end-1')]
if len(intervals) == 2:
before, after = intervals
+ row['measurement_scope'] = 'invocation-through-rendered-output-v1'
row['invoke_seconds'] = (after['wall_ns'] - before['wall_ns']) / 1e9
row['allocated_bytes'] = after['allocated_bytes'] - before['allocated_bytes']
self.records.append(row)
diff --git a/test/unit.py b/test/unit.py
@@ -1110,15 +1110,17 @@ class BaseWorkloadTests(unittest.TestCase):
rows = [dict(profile=name, wall_ns=i * 100, allocated_bytes=i * 10,
gc_elapsed_ns=i, live_bytes=7)
for i, name in enumerate(['load-begin', 'load-end',
- 'invoke-begin-1', 'invoke-end-1'])]
+ 'invoke-begin-1', 'invoke-end-1',
+ 'observe-begin-1', 'observe-end-1'])]
# Must not perform an unanchored numeric regex search over this line.
path.write_text('\n'.join(json.dumps(r) for r in rows) + '\n' +
'0' * 200000 + ' 9,999 bytes allocated in the heap\n' +
' 1,234 bytes maximum residency (2 samples)\n')
actual = measurements(path)
self.assertEqual(actual['rts_max_residency_bytes'], 1234)
- self.assertEqual(actual['invoke_wall_ns'], 100)
- self.assertEqual(actual['invoke_allocated_bytes'], 10)
+ self.assertEqual(actual['invoke_wall_ns'], 300)
+ self.assertEqual(actual['invoke_allocated_bytes'], 30)
+ self.assertEqual(actual['measurement_scope'], 'invocation-through-rendered-output-v1')
def test_workload_independent_oracles(self):
from base_workloads import workloads, encode
@@ -1570,5 +1572,36 @@ class IndexedObjectAuditTests(unittest.TestCase):
with self.assertRaises(AssertionError): verify_payload('wrong.kind', b'\0')
+class InspectorStagingTests(unittest.TestCase):
+ def test_staging_is_opt_in_immutable_and_authenticates_bytes(self):
+ from library_inspector import stage_site
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ (root / 'producer').mkdir()
+ site = root / 'site'; (site / 'bundles').mkdir(parents=True)
+ index = site / 'packages.json'
+ index.write_text(json.dumps(dict(schema=1, releases=[])))
+ payload = root / 'producer/tricu-base.arboricx'
+ payload.write_bytes(b'fixture bytes')
+ digest = hashlib.sha256(payload.read_bytes()).hexdigest()
+ report = dict(complete=True, releases={'tricu-base': dict(version='0.1.0', manifest='a'*64, bundle=digest)})
+ with self.assertRaises(ValueError): stage_site(root, site, dict(report, complete=False))
+ stage_site(root, site, report)
+ before = index.read_bytes(); stage_site(root, site, report)
+ self.assertEqual(index.read_bytes(), before)
+ self.assertEqual((site / 'bundles' / (digest + '.arboricx')).read_bytes(), payload.read_bytes())
+ payload.write_bytes(b'changed bytes')
+ with self.assertRaises(ValueError): stage_site(root, site, report)
+ changed = hashlib.sha256(payload.read_bytes()).hexdigest()
+ report['releases']['tricu-base']['bundle'] = changed
+ with self.assertRaises(ValueError): stage_site(root, site, report)
+ self.assertEqual(index.read_bytes(), before)
+ self.assertFalse((site / 'bundles' / (changed + '.arboricx')).exists())
+ report['releases']['tricu-base']['version'] = '0.2.0'
+ stage_site(root, site, report)
+ self.assertEqual(len(json.loads(index.read_text())['releases']), 2)
+ self.assertTrue((site / 'bundles' / (digest + '.arboricx')).exists())
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/tricu.cabal b/tricu.cabal
@@ -39,6 +39,10 @@ executable tricu
-fPIC
build-depends:
base >=4.7
+ , http-client
+ , http-client-tls
+ , http-types
+ , network-uri
, aeson
, ansi-terminal
, base16-bytestring
@@ -67,6 +71,10 @@ executable tricu
, vector
, zlib
other-modules:
+ Package
+ Project
+ Module.Inspection
+ Module.Provenance
ContentStore
ContentStore.Alias
ContentStore.Arboricx
@@ -87,6 +95,7 @@ executable tricu
Paths_tricu
REPL
Research
+ TreeObservation
Wire
default-language: Haskell2010
@@ -144,6 +153,7 @@ benchmark tricu-bench
Parser
Paths_tricu
Research
+ TreeObservation
Wire
test-suite tricu-tests
@@ -157,6 +167,10 @@ test-suite tricu-tests
ScopedTypeVariables
build-depends:
base >=4.7
+ , http-client
+ , http-client-tls
+ , http-types
+ , network-uri
, ansi-terminal
, base16-bytestring
, base64-bytestring
@@ -185,6 +199,11 @@ test-suite tricu-tests
, zlib
default-language: Haskell2010
other-modules:
+ Package
+ Project
+ Module.Inspection
+ Module.Provenance
+ PackageTests
ContentStore
ContentStore.Alias
ContentStore.Arboricx
@@ -205,5 +224,6 @@ test-suite tricu-tests
Paths_tricu
REPL
Research
+ TreeObservation
Wire
SupportFixture