commit 185b02106e4bcac9e36f3e0ebd46d39c965cf2b2
parent b99605e3168245139e3e63f1ec5878c0e8ecff26
Author: James Eversole <james@eversole.co>
Date: Fri, 16 Oct 2026 14:29:17 -0500
Resolve native module sources through discoverable workspace maps
Stage explicit source locations for the existing portable compiler ABI; preserve sibling modules with fail-closed collision checks and isolate provenance replay. Compile the registry application directly through the repository map instead of manually copying dependencies.
Validated: nix flake check; workspace_compile.py (mapped/flat identity, edit invalidation, negative controls and provenance replay); provenance.py; nix build .#arboricxServerTest; source-patterns demo returns 23. Haskell host only; no compiler artifact change.
Diffstat:
19 files changed, 396 insertions(+), 35 deletions(-)
diff --git a/demos/modules/tricu.workspace b/demos/modules/tricu.workspace
@@ -0,0 +1 @@
+tricu.workspace.v1
diff --git a/demos/source-build/tricu.workspace b/demos/source-build/tricu.workspace
@@ -0,0 +1 @@
+tricu.workspace.v1
diff --git a/demos/source-map/tricu.workspace b/demos/source-map/tricu.workspace
@@ -0,0 +1 @@
+tricu.workspace.v1
diff --git a/demos/source-patterns/tricu.workspace b/demos/source-patterns/tricu.workspace
@@ -0,0 +1 @@
+tricu.workspace.v1
diff --git a/docs/build-and-run.md b/docs/build-and-run.md
@@ -97,8 +97,8 @@ exported programs.
## Deploy the Arboricx registry (Linux)
The `arboricxServer` flake output builds the image `arboricx-server:latest`.
-It compiles `tricu-apps/arboricxServer.tri` and its explicit native dependencies
-with the packaged stable compiler at build time. Startup runs the compiled
+It compiles `tricu-apps/arboricxServer.tri` directly using the repository
+`tricu.workspace` source locations and packaged stable compiler at build time. Startup runs the compiled
manifest from a read-only Nix store; it does not compile source, search
`tricu.workspace`, or write a compiler cache into registry storage.
diff --git a/docs/language-features.md b/docs/language-features.md
@@ -34,7 +34,7 @@ separate optimized backend.
| Compose failure-aware work | Result/Maybe helpers, parser combinators, IO helpers | They have different continuation/remainder protocols. |
| Check a runtime boundary | Contracts and argument/result annotations | Runtime checks, not types or automatic host exceptions. |
| Inspect code as data | Tree constructors, `triage`, structural equality/inspection | Equal application results do not imply equal function trees. |
-| Organize/share code | Explicit native imports, namespaces, exports, and reexports | No implicit prelude or ambient workspace search. |
+| Organize/share code | Explicit native imports, namespaces, exports, and reexports | No implicit prelude or registry lookup. |
## Functions and tree values
@@ -206,8 +206,9 @@ can be imported with an explicit alias or `*`. `!export name` selects a local
binding; `!export *` exports local definitions, **not imports**.
`!export * from REF` explicitly reexports and acquires that dependency.
-There is no ambient workspace lookup, implicit prelude, or fallback to another
-frontend after compilation failure. Dependency cycles and name/export collisions
+The CLI discovers source locations through the nearest `tricu.workspace`; it
+does not search registries or mutable store aliases for source imports. There is
+no implicit prelude or fallback to another frontend after compilation failure. Dependency cycles and name/export collisions
are errors. The native profile requires a nonempty public interface and rejects
bare final expressions; export `main` for CLI evaluation. See the
[module contract](modules.md.html) for exact rules.
diff --git a/docs/modules.md b/docs/modules.md
@@ -14,11 +14,59 @@ trees, and an Arboricx bundle carries them for distribution.
main = M.add 20 22
```
-For the CLI's flat workspace, put dependencies in the input file's directory:
-`prelude.tri`, `math.tri`, and their declared dependencies. Workspace module names
-start with an ASCII letter or underscore and continue with letters, digits,
-underscores, or hyphens. Use [Build and run](build-and-run.md.html) to select the stable
-compiler, and [Tools](tri-tools.md.html) for larger mapped library workspaces.
+The CLI discovers the nearest `tricu.workspace` in the input file's directory
+or its ancestors, independently of the caller's working directory. Without a
+declaration, dependencies remain sibling `NAME.tri` files. Module names start
+with an ASCII letter or underscore and continue with letters, digits,
+underscores, or hyphens.
+
+### Source locations without a build directory
+
+A workspace declaration maps import names to authored source paths. For example,
+place this at your project root (columns are separated by an actual tab):
+
+```text
+tricu.workspace.v1
+math<TAB>libraries/math.tri
+prelude<TAB>libraries/prelude.tri
+```
+
+Then `apps/main.tri` can import `math` and `prelude` without copying files beside
+it. Paths are relative to the declaration, must end in `.tri`, and can include
+`..` for explicitly shared sources. LF endings and a final newline are required;
+comments and blank lines are not supported. Declarations are limited to 64 KiB;
+names to 128 ASCII characters. All mapped files must exist. Direct symlinks and
+nonregular input files are rejected; use stable, trusted directories.
+
+The repository declaration provides its native library locations, including
+`native_math` and `arboricx_server`. Compile the application directly:
+
+```sh
+result/bin/tricu arboricx compile --file tricu-apps/arboricxServer.tri \
+ --store "$WORK/application" --output "$WORK/server.arboricx" \
+ --compile-timeout 900
+```
+
+Sibling modules remain available. A sibling and mapping with the same name must
+refer to the same file; conflicting and case-colliding names fail rather than
+silently shadowing one another. Parent declarations are not merged. A nearer
+header-only `tricu.workspace` establishes an isolated flat workspace; the
+self-contained module demos use this boundary.
+
+The host copies the selected source locations into a temporary flat workspace
+and removes it after compilation, including on failure. Source text is unchanged;
+the portable compiler still reads headers, discovers the reached dependency
+closure, rejects cycles, and owns compilation and cache semantics. Only reached
+modules are compiled, though all mapped and validly named sibling files are
+copied. This is not registry fetching, recursive library discovery, source
+rewriting, or a new compiler backend. Keep sources stable during the copy/build.
+Full-hash imports and package-generated binding modules work as before.
+
+`eval`, `arboricx compile`, and provenance capture share these location rules.
+Capture retains reached source bytes under their module names; replay needs no
+original directory layout or workspace declaration. Workspace paths themselves
+are not executable or receipt identities. Use [Build and run](build-and-run.md.html)
+to select the stable compiler.
## Headers
diff --git a/docs/tri-tools.md b/docs/tri-tools.md
@@ -15,8 +15,9 @@ result/bin/tricu arboricx compile --file demos/source-patterns/app.tri \
--store "$WORK/application" --name main --output "$WORK/app.arboricx"
```
-The input file's directory is the flat workspace. Declared dependencies resolve
-to `NAME.tri` there. The module basename must be a valid workspace identifier.
+Dependencies use sibling `NAME.tri` files plus the nearest `tricu.workspace`
+source-location declaration; see [workspace rules](modules.md.html#source-locations-without-a-build-directory).
+The module basename must be a valid workspace identifier.
`eval` accepts one file and selects exported `main`. `compile` packages all
published exports, or the subset selected by repeatable `--name` options. Missing
or duplicate selections fail.
@@ -266,9 +267,10 @@ cancellation fail before replacement. See the
## Library workspaces and demos
-Compiler dependencies use source-map aliases, so prepare an explicit workspace
-rather than compiling `lib/source/*.tri` against an assumed directory search.
-For a completed generation and a new `DEMO_DIRECTORY`:
+The repository `tricu.workspace` maps native library aliases to source paths;
+ordinary CLI compilation needs no manually staged directory. Bootstrap source
+maps remain a separate compiler-rebuilding protocol. To reuse already compiled
+dependency manifests from a completed generation in a new `DEMO_DIRECTORY`:
```sh
python3 test/native_library_migration.py result/bin/tricu "$GENERATION" \
diff --git a/docs/validation.md b/docs/validation.md
@@ -1101,3 +1101,23 @@ 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.
+
+## CLI workspace source locations
+
+`nix flake check` includes declaration parsing, ancestor discovery, nearest-file
+boundaries, collision/missing-file rejection, and temporary snapshot cleanup.
+For real packaged-compiler coverage on the Haskell host:
+
+```sh
+nix build .#
+python3 test/workspace_compile.py result/bin/tricu
+nix build .#arboricxServerTest --no-link
+```
+
+The focused harness compiles a nested application with transitive reexports from
+an unrelated working directory, compares its bundle byte-for-byte with a flat
+workspace build, captures and independently replays the mapped inputs beneath a
+conflicting parent declaration, verifies cache invalidation after a source edit, and retains
+negative cycle, missing-import, and source-collision controls. The deployment
+check compiles the actual repository application via `tricu.workspace` and
+exercises its server behavior.
diff --git a/flake.nix b/flake.nix
@@ -72,17 +72,8 @@
arboricxServerBundle = pkgs.runCommand "arboricx-server-bundle" {
nativeBuildInputs = [ tricuPackage ];
} ''
- mkdir workspace
- ${pkgs.lib.concatMapStringsSep "\n" (name:
- "cp ${./lib}/${name}.tri workspace/${name}.tri") [
- "base" "prelude" "bytes" "conversions" "io"
- "parser" "http" "socket"
- ]}
- cp ${./lib/math/native.tri} workspace/native_math.tri
- cp ${./lib/arboricx/server.tri} workspace/arboricx_server.tri
- cp ${./tricu-apps/arboricxServer.tri} workspace/main.tri
mkdir -p $out
- tricu arboricx compile --file workspace/main.tri --store build-store \
+ tricu arboricx compile --file ${./.}/tricu-apps/arboricxServer.tri --store build-store \
--output $out/server.arboricx --compile-timeout 900 \
+RTS -N1 -M8192m -RTS
tricu arboricx import --file $out/server.arboricx --store $out/runtime \
diff --git a/src/Module/Provenance.hs b/src/Module/Provenance.hs
@@ -2,6 +2,7 @@
module Module.Provenance
( capture, verifyPack, prepareRebuild, finishRebuild, Recipe(..) ) where
+import Workspace (workspaceSources)
import ContentStore.Object
import ContentStore.Alias (ObjectRef(..))
import ContentStore.Filesystem (objectPath, putObject)
@@ -233,9 +234,9 @@ cacheReceipt (StorePath store) name = do
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"
+ unless (takeExtension file == ".tri" && J.validBinding (T.pack (takeBaseName file))) $ fail "capture requires a native .tri entry"
let entry = T.pack (takeBaseName file)
- workspace = takeDirectory file
+ sources <- workspaceSources file
receipt <- maybe (cacheReceipt app entry) (\p -> either fail pure (validateObjectHash p) >> pure p) selectedReceipt
context <- case project of
Nothing -> pure Nothing
@@ -254,7 +255,7 @@ capture file app tools compiler selectedReceipt project output = do
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"))
+ c <- collect (Recipe entry receipt compiler [] ctx) combined (\name _ -> maybe (fail "receipt source absent from workspace") (bounded 1048576) (M.lookup (T.unpack name) sources))
-- 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)
@@ -306,6 +307,8 @@ prepareRebuild directory pin trusted output = do
_ <- putObject target d payload
pure ()
createDirectory workspace
+ -- Replay must never inherit source mappings from the output directory's ancestors.
+ BS.writeFile (workspace </> "tricu.workspace") "tricu.workspace.v1\n"
forM_ (M.toList (cReceipts c)) $ \(name,(_,r)) -> obj sourceDomain (rSource r) >>= BS.writeFile (workspace </> T.unpack name ++ ".tri")
case recipeProject recipe of
Nothing -> pure ()
diff --git a/src/PortableCompiler.hs b/src/PortableCompiler.hs
@@ -6,6 +6,7 @@ module PortableCompiler
) where
import ContentStore
+import Workspace (withWorkspace)
import ContentStore.Bundle (unpackIndexedBundleToStore, indexedTreeDomain)
import ContentStore.Executable
import Module.Manifest
@@ -16,7 +17,7 @@ import Crypto.Hash (Digest, SHA256, hash)
import Control.Exception (IOException, catch)
import Control.Monad (when, unless)
import System.Directory (canonicalizePath, createDirectoryIfMissing)
-import System.FilePath (takeExtension, takeDirectory, takeBaseName)
+import System.FilePath (takeExtension, takeBaseName)
import System.Timeout (timeout)
import System.IO (stderr)
import GHC.Clock (getMonotonicTimeNSec)
@@ -88,8 +89,7 @@ validateCompilerOptions options = do
unless (compilerSeconds options >= 1 && compilerSeconds options <= 86400) $
fail "--compile-timeout must be between 1 and 86400 seconds"
--- Native flat-workspace profile: the selected file supplies the directory and
--- target name; local import/re-export edges select NAME.tri within that explicit base.
+-- Workspace location preparation preserves the native flat-workspace ABI.
-- 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
@@ -112,9 +112,8 @@ buildPortableResult options appStore input = do
createDirectoryIfMissing True (unStorePath store0)
root <- canonicalizePath (unStorePath store0)
let store = StorePath root
- directory = takeDirectory source
name = takeBaseName source
- built <- compilerDeadline options $ do
+ built <- compilerDeadline options $ withWorkspace input $ \directory -> do
(compilerIdentity, worker) <- loadCompiler sample options store
let action = foldl apply worker (map ofString
[directory, root, compilerIdentity, name])
diff --git a/src/Workspace.hs b/src/Workspace.hs
@@ -0,0 +1,99 @@
+-- Source location policy only: the portable compiler still owns all header,
+-- dependency, scope, cycle and cache semantics. No source rewriting occurs here.
+module Workspace (workspaceSources, withWorkspace, parseWorkspace) where
+
+import Control.Monad (foldM, forM, forM_, unless)
+import qualified Data.ByteString as BS
+import qualified Data.Map.Strict as M
+import qualified Data.Text as T
+import Data.Text.Encoding (decodeUtf8')
+import Data.Char (toLower)
+import Data.List (sort)
+import System.Directory
+import System.FilePath
+import System.IO (withBinaryFile, IOMode(ReadMode))
+import System.Posix.Files (getSymbolicLinkStatus, isRegularFile)
+import System.IO.Temp (withSystemTempDirectory)
+
+validName :: String -> Bool
+validName [] = False
+validName (c:cs) = first c && all (\x -> first x || x >= '0' && x <= '9' || x == '-') cs
+ where first x = x >= 'a' && x <= 'z' || x >= 'A' && x <= 'Z' || x == '_'
+
+parseWorkspace :: BS.ByteString -> Either String [(String, FilePath)]
+parseWorkspace bytes = do
+ unless (BS.length bytes <= 65536) $ Left "workspace exceeds 64 KiB"
+ text <- either (const $ Left "workspace must be UTF-8") Right (decodeUtf8' bytes)
+ unless (T.isSuffixOf "\n" text && not (T.any (== '\r') text)) $
+ Left "workspace requires LF lines and a final newline"
+ case T.lines text of
+ "tricu.workspace.v1":rows -> reverse . snd <$> foldM row ([], []) rows
+ _ -> Left "invalid workspace header (expected tricu.workspace.v1)"
+ where
+ row (seen, entries) line = case map T.unpack (T.splitOn "\t" line) of
+ [name,path] -> do
+ unless (validName name && length name <= 128) $ Left "invalid workspace module name"
+ unless (not (null path) && not (isAbsolute path) && takeExtension path == ".tri" && all (>= ' ') path) $
+ Left "workspace paths must be relative .tri paths"
+ let key = map toLower name
+ unless (key `notElem` seen) $ Left ("duplicate workspace module: " ++ name)
+ pure (key:seen, (name,path):entries)
+ _ -> Left "workspace rows require NAME<TAB>PATH"
+
+findWorkspace :: FilePath -> IO (Maybe FilePath)
+findWorkspace directory = do
+ let path = directory </> "tricu.workspace"
+ exists <- doesPathExist path
+ if exists then pure (Just path)
+ else if takeDirectory directory == directory then pure Nothing
+ else findWorkspace (takeDirectory directory)
+
+-- The nearest declaration is authoritative; parent declarations are not merged.
+-- Sibling modules remain available, but may not silently shadow mapped modules.
+workspaceSources :: FilePath -> IO (M.Map String FilePath)
+workspaceSources input = do
+ regularSource input
+ source <- canonicalizePath input
+ let directory = takeDirectory source
+ unless (takeExtension source == ".tri" && validName (takeBaseName source)) $
+ fail "Portable compilation requires a .tri module with a valid module basename"
+ declaration <- findWorkspace directory
+ mapped <- case declaration of
+ Nothing -> pure []
+ Just path -> do
+ regularSource path
+ bytes <- withBinaryFile path ReadMode (\h -> BS.hGet h 65537)
+ rows <- either (fail . ((path ++ ": ") ++)) pure (parseWorkspace bytes)
+ forM rows $ \(name,relative) -> do
+ regularSource (takeDirectory path </> relative)
+ resolved <- canonicalizePath (takeDirectory path </> relative)
+ exists <- doesFileExist resolved
+ unless exists $ fail ("Workspace module missing: " ++ name ++ " at " ++ resolved)
+ pure (name,resolved)
+ siblings <- sort <$> listDirectory directory
+ local <- forM [p | p <- siblings, takeExtension p == ".tri", validName (takeBaseName p)] $ \p -> do
+ regularSource (directory </> p)
+ resolved <- canonicalizePath (directory </> p)
+ pure (takeBaseName p,resolved)
+ foldM insert M.empty (mapped ++ local)
+ where
+ insert entries (name,path) = do
+ let collisions = [(n,p) | (n,p) <- M.toList entries, map toLower n == map toLower name]
+ case collisions of
+ [] -> pure (M.insert name path entries)
+ [(n,p)] | n == name && p == path -> pure entries
+ _ -> fail ("Ambiguous workspace module: " ++ name ++ " (mapped and/or sibling sources conflict)")
+
+-- Snapshot locations into the existing compiler ABI. Temporary paths are not
+-- build identities; receipts use module names and source bytes, as before.
+withWorkspace :: FilePath -> (FilePath -> IO a) -> IO a
+withWorkspace input action = do
+ sources <- workspaceSources input
+ withSystemTempDirectory "tricu-workspace" $ \directory -> do
+ forM_ (M.toList sources) $ \(name,path) -> copyFile path (directory </> name <.> "tri")
+ action directory
+
+regularSource :: FilePath -> IO ()
+regularSource path = do
+ status <- getSymbolicLinkStatus path
+ unless (isRegularFile status) $ fail ("Workspace inputs require regular files (no symlinks): " ++ path)
diff --git a/test/Spec.hs b/test/Spec.hs
@@ -1,5 +1,6 @@
module Main where
+import WorkspaceTests (workspaceTests)
import IOBoundaryTests (ioBoundaryTests)
import PortableCompilerTests (portableCompilerTests)
import ExecutableLoadingTests (executableLoadingTests)
@@ -58,7 +59,8 @@ main = do
tests :: TestTree
tests = testGroup "Tricu Tests"
- [ ioBoundaryTests
+ [ workspaceTests
+ , ioBoundaryTests
, portableCompilerTests
, executableLoadingTests
, runtimePresentationTests
diff --git a/test/WorkspaceTests.hs b/test/WorkspaceTests.hs
@@ -0,0 +1,73 @@
+module WorkspaceTests (workspaceTests) where
+
+import Workspace
+import Control.Exception (IOException, try)
+import qualified Data.ByteString as BS
+import qualified Data.Map.Strict as M
+import System.Directory
+import System.FilePath ((</>))
+import System.IO.Temp (withSystemTempDirectory)
+import Test.Tasty
+import Test.Tasty.HUnit
+
+workspaceTests :: TestTree
+workspaceTests = testGroup "workspace source locations"
+ [ testCase "strict declarations" $ do
+ parseWorkspace "tricu.workspace.v1\nmath\tlib/math.tri\n" @?= Right [("math","lib/math.tri")]
+ mapM_ (\bytes -> case parseWorkspace bytes of
+ Left _ -> pure ()
+ Right _ -> assertFailure "accepted invalid declaration")
+ [ "tricu.workspace.v1\nmath\ta.tri\nMath\tb.tri\n"
+ , "tricu.workspace.v1\nmath\ta.tri"
+ , "tricu.workspace.v1\nmath\t/a.tri\n"
+ , "tricu.workspace.v1\n../math\ta.tri\n"
+ , "tricu.workspace.v1\r\n"
+ , "tricu.workspace.v1\n\n"
+ , "tricu.workspace.v1\nx\ta.tri\textra\n"
+ , BS.pack [255]
+ ]
+ , testCase "parent discovery, exact source copies, cleanup, and nearest declaration" $ fresh $ \root -> do
+ let app = root </> "app"; lib = root </> "lib"
+ createDirectory app
+ createDirectory lib
+ BS.writeFile (app </> "main.tri") "!import math as M\n!export main\nmain = M.answer\n"
+ BS.writeFile (lib </> "number.tri") "!export answer\nanswer = 42\n"
+ BS.writeFile (root </> "tricu.workspace") "tricu.workspace.v1\nmath\tlib/number.tri\n"
+ sources <- workspaceSources (app </> "main.tri")
+ M.keys sources @?= ["main", "math"]
+ temp <- withWorkspace (app </> "main.tri") $ \dir -> do
+ BS.readFile (dir </> "math.tri") >>= (@?= "!export answer\nanswer = 42\n")
+ pure dir
+ doesPathExist temp >>= (@?= False)
+ BS.writeFile (app </> "tricu.workspace") "tricu.workspace.v1\n"
+ workspaceSources (app </> "main.tri") >>= ((@?= ["main"]) . M.keys)
+ , testCase "no silent local shadowing or missing mapped sources" $ fresh $ \root -> do
+ BS.writeFile (root </> "main.tri") "!export main\nmain = 1\n"
+ BS.writeFile (root </> "other.tri") "!export main\nmain = 2\n"
+ BS.writeFile (root </> "tricu.workspace") "tricu.workspace.v1\nmain\tother.tri\n"
+ rejects (workspaceSources (root </> "main.tri"))
+ BS.writeFile (root </> "tricu.workspace") "tricu.workspace.v1\nmissing\tabsent.tri\n"
+ rejects (workspaceSources (root </> "main.tri"))
+ , testCase "mapping the same sibling is not ambiguous" $ fresh $ \root -> do
+ BS.writeFile (root </> "main.tri") "!export main\nmain = 1\n"
+ BS.writeFile (root </> "tricu.workspace") "tricu.workspace.v1\nmain\tmain.tri\n"
+ workspaceSources (root </> "main.tri") >>= ((@?= ["main"]) . M.keys)
+ , testCase "direct source symlinks are rejected" $ fresh $ \root -> do
+ BS.writeFile (root </> "target.tri") "!export main\nmain = 1\n"
+ createFileLink (root </> "target.tri") (root </> "main.tri")
+ rejects (workspaceSources (root </> "main.tri"))
+ , testCase "temporary snapshot removed on failure" $ fresh $ \root -> do
+ BS.writeFile (root </> "main.tri") "!export main\nmain = 1\n"
+ let marker = root </> "location"
+ rejects $ withWorkspace (root </> "main.tri") $ \dir -> do
+ writeFile marker dir
+ fail "compiler failure" :: IO ()
+ readFile marker >>= doesPathExist >>= (@?= False)
+ ]
+ where
+ fresh = withSystemTempDirectory "tricu-workspace-test"
+ rejects action = do
+ result <- try (action >> pure ()) :: IO (Either IOException ())
+ case result of
+ Left _ -> pure ()
+ Right _ -> assertFailure "expected workspace error"
diff --git a/test/workspace_compile.py b/test/workspace_compile.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""Haskell-only real-compiler workspace acceptance; no retained build artifacts.
+Usage: python3 test/workspace_compile.py /absolute/path/to/tricu
+"""
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+
+
+def main():
+ host = str(Path(sys.argv[1]).resolve())
+ with tempfile.TemporaryDirectory(prefix="tricu-workspace-acceptance-") as tmp:
+ root = Path(tmp)
+ project = root / "project"
+ app = project / "apps"
+ lib = project / "lib"
+ app.mkdir(parents=True)
+ lib.mkdir()
+ entry = app / "main.tri"
+ entry.write_text("!import facade as F\n!export main\nmain = F.answer\n")
+ (lib / "facade-source.tri").write_text("!export * from numbers\n")
+ (lib / "numbers-source.tri").write_text("!export answer\nanswer = 42\n")
+ declaration = project / "tricu.workspace"
+ declaration.write_text("tricu.workspace.v1\nfacade\tlib/facade-source.tri\nnumbers\tlib/numbers-source.tri\n")
+
+ def run(*args, success=True):
+ p = subprocess.run([host, *map(str, args), "+RTS", "-N1", "-RTS"],
+ cwd=root, text=True, capture_output=True, timeout=180)
+ assert (p.returncode == 0) == success, (args, p.stdout, p.stderr)
+ return p.stdout
+
+ store = root / "store"
+ assert run("eval", entry, "--store", store, "--format", "number").strip() == "42"
+ mapped_bundle = root / "mapped.arboricx"
+ run("arboricx", "compile", "--file", entry, "--store", store, "--output", mapped_bundle)
+ flat = root / "flat"
+ flat.mkdir()
+ shutil.copyfile(entry, flat / "main.tri")
+ shutil.copyfile(lib / "facade-source.tri", flat / "facade.tri")
+ shutil.copyfile(lib / "numbers-source.tri", flat / "numbers.tri")
+ flat_bundle = root / "flat.arboricx"
+ run("arboricx", "compile", "--file", flat / "main.tri", "--store", root / "flat-store", "--output", flat_bundle)
+ assert mapped_bundle.read_bytes() == flat_bundle.read_bytes(), "location changed bundle identity"
+ # Capture shares the location resolver; replay is isolated even under a
+ # parent declaration that would otherwise conflict with retained sources.
+ tools = root / "tools"
+ shipped = Path(__file__).resolve().parent.parent / "bootstrap/build-stable.arboricx"
+ imported = run("arboricx", "import", "--file", shipped, "--store", tools)
+ compiler = re.search(r"Created module alias .* -> ([0-9a-f]{64})", imported)[1]
+ run("arboricx", "compile", "--file", entry, "--store", store,
+ "--toolchain", compiler, "--tool-store", tools, "--output", mapped_bundle)
+ pack = root / "inputs"
+ captured = run("provenance", "capture", "--file", entry, "--store", store,
+ "--toolchain", compiler, "--tool-store", tools, "--output", pack)
+ pin = re.search(r"Retained build inputs: ([0-9a-f]{64})", captured)[1]
+ run("provenance", "rebuild", "--pack", pack, "--pin", pin,
+ "--trust-compiler", compiler, "--output", project / "replayed")
+ # Cache must observe source edits, not just declaration paths.
+ (lib / "numbers-source.tri").write_text("!export answer\nanswer = 7\n")
+ assert run("eval", entry, "--store", store, "--format", "number").strip() == "7"
+ # Ordinary compiler controls remain in force through the mapped route.
+ (lib / "numbers-source.tri").write_text("!import facade as F\n!export answer\nanswer = F.answer\n")
+ run("eval", entry, "--store", store, success=False)
+ (lib / "numbers-source.tri").write_text("!import absent as A\n!export answer\nanswer = A.answer\n")
+ run("eval", entry, "--store", store, success=False)
+ (app / "facade.tri").write_text("!export answer\nanswer = 99\n")
+ run("eval", entry, "--store", store, success=False)
+ print("workspace compile acceptance passed")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tricu-apps/arboricxServer.tri b/tricu-apps/arboricxServer.tri
@@ -5,7 +5,7 @@
-- Arboricx HTTP registry server.
-- Build/deploy: nix build .#arboricxServer (see docs/build-and-run.md).
-- The runner grants reads/writes only to ./store; dependencies are compiled
--- in the explicit flat workspace prepared by flake.nix.
+-- from their declared locations in the repository tricu.workspace.
--
-- Endpoints:
-- GET / -> HTML landing page
diff --git a/tricu.cabal b/tricu.cabal
@@ -87,6 +87,7 @@ executable tricu
Paths_tricu
REPL
PortableCompiler
+ Workspace
Runtime
Runtime.Tree
Runtime.Value
@@ -180,6 +181,7 @@ test-suite tricu-tests
PackageTests
ExecutableLoadingTests
PortableCompilerTests
+ WorkspaceTests
InteractionInspectionTests
ContentStore
ContentStore.Alias
@@ -196,6 +198,7 @@ test-suite tricu-tests
Paths_tricu
REPL
PortableCompiler
+ Workspace
Runtime
Runtime.Tree
Runtime.Value
diff --git a/tricu.workspace b/tricu.workspace
@@ -0,0 +1,41 @@
+tricu.workspace.v1
+arboricx lib/arboricx/arboricx.tri
+arboricx_common lib/arboricx/common.tri
+arboricx_dispatch lib/arboricx/dispatch.tri
+arboricx_manifest lib/arboricx/manifest.tri
+arboricx_nodes lib/arboricx/nodes.tri
+arboricx_server lib/arboricx/server.tri
+base lib/base.tri
+binary lib/binary.tri
+bytes lib/bytes.tri
+conversions lib/conversions.tri
+graph lib/graph.tri
+http lib/http.tri
+intensional lib/intensionalContracts.tri
+io lib/io.tri
+lexer lib/lexer.tri
+native_math lib/math/native.tri
+parser lib/parser.tri
+patterns lib/patterns.tri
+prelude lib/prelude.tri
+socket lib/socket.tri
+source_ast lib/source/ast.tri
+source_build lib/source/build.tri
+source_build_stable lib/source/build-stable.tri
+source_bundle lib/source/bundle.tri
+source_compile lib/source/compile.tri
+source_core lib/source/core.tri
+source_equations lib/source/equations.tri
+source_export_cache lib/source/export-cache.tri
+source_lexer lib/source/lexer.tri
+source_lock lib/source/lock.tri
+source_map lib/source/map.tri
+source_module lib/source/module.tri
+source_object lib/source/object.tri
+source_order lib/source/order.tri
+source_parser lib/source/parser.tri
+source_pattern lib/source/pattern.tri
+source_program lib/source/program.tri
+source_resolve lib/source/resolve.tri
+source_stable lib/source/stable.tri
+source_unit lib/source/unit.tri