epm — API reference

2/30 types documented. Of 234 public methods, 96 carry their own description, 0 are covered by their type's, and 138 have neither. An entry with no prose below its signature is undocumented in the source, not undocumented here.

CLASS BuildLedger

The set of modules already compiled during ONE epm build invocation.

This exists so epm build --all can treat its workspace member list as an unordered SET. Data declares mda and Regex as dependencies and all three are members, so without a ledger the run compiles mda twice — once as Data's dependency and once in its own right. With it, whichever reaches the module first builds it and the rest skip.

It is a class rather than a field on Main because Main.start() implements TaskStarter, whose start() is declared non-MODIFY — so the whole dispatch chain down to the build is non-MODIFY and cannot write a field on Main. Passed as MUTABLE REFERENCE BuildLedger, a non-MODIFY caller can still call mark through it (the borrow ABI makes that a plain BuildLedger*).

Skipping is a COMPILE skip only — never a trust skip. The Layer B consent check runs over the full graph of every target before any of it is built, so a dependency that was already compiled is still checked against epm.lock for whoever depends on it next.

epm/src/BuildLedger.ev:25

Constructors

INIT()

Methods

METHOD has(String name) RETURNS boolean

TRUE if a module of this manifest name has already been built here.

MODIFY METHOD mark(String name) RETURNS void

Record a module as built. Idempotent.

METHOD count() RETURNS int64

How many distinct modules this invocation compiled — the one honest summary line for a workspace build.

CLASS BuildOutcome

IMPLEMENTS Cloneable

epm/src/BuildOutcome.ev:16

Constructors

INIT()
INIT(int64 index, String name, int32 code, String out, String err, String message)

Methods

METHOD clone() RETURNS Cloneable
METHOD at() RETURNS int64
METHOD moduleName() RETURNS String
METHOD exitCode() RETURNS int32
METHOD stdoutText() RETURNS String
METHOD stderrText() RETURNS String
METHOD note() RETURNS String

CLASS BuildPlan

epm/src/BuildPlan.ev:20

Constructors

INIT()

Methods

METHOD isLoaded() RETURNS boolean
METHOD name() RETURNS String
METHOD targetKind() RETURNS String
METHOD outputPath() RETURNS String

The final artifact path, as evc published it. Never reconstructed from the module name plus a platform library shape — that is flag construction, and getting it wrong is silent (a bare -o libfoo.dylib lands in the cwd).

METHOD unitCount() RETURNS int64
MODIFY METHOD load(String outDir) RETURNS boolean

Read <outDir>/module_plan.json. FALSE when absent or unreadable — the caller falls back to the compiler's own path, which is still the live one.

MODIFY METHOD loadFile(String planPath) RETURNS boolean

Read a plan from an explicit path.

--single publishes <stem>.plan.json beside the source rather than one module_plan.json per directory, because several .ev files can share a directory in single mode and a per-directory name would have them overwrite each other. The SCHEMA is identical, so everything below this point is unchanged.

METHOD diagnosticIndex(String code) RETURNS int64

Index of code in the carried diagnostics, or -1. An older plan carries none, so every lookup misses and the caller falls back to the bare tool output — degrading rather than inventing a diagnostic it does not have.

METHOD diagCategory(int64 i) RETURNS String
METHOD diagMessage(int64 i) RETURNS String
METHOD diagExample(int64 i) RETURNS String
METHOD diagCorrected(int64 i) RETURNS String
METHOD diagSee(int64 i) RETURNS String
METHOD manifestCompilerFlags() RETURNS String[]

Flags a MANIFEST declared — the module's own plus those merged from its dependencies. Profile and toolchain flags are deliberately absent: those are policy and come from build-config.json. Data links -lpcre2-8 only because its dependency Regex declared it.

METHOD manifestLinkerFlags() RETURNS String[]
METHOD stagePairCount() RETURNS int64

How many artifacts --install would copy into the toolchain's lib/.

METHOD stageSourceAt(int64 i) RETURNS | String
METHOD stageDestAt(int64 i) RETURNS | String
METHOD sourceCount() RETURNS int64

The .ev files this module was emitted from. Published so a build system can ask whether the emit is current without re-deriving the source set from a manifest it would have to parse a second time.

METHOD sourceAt(int64 i) RETURNS | String
METHOD includeFlags() RETURNS String[]

-I<dir> for each published include DIRECTORY. The plan deliberately carries paths, not flags; this is where a path becomes a flag.

METHOD linkArgs() RETURNS String[]

The full link argument list, in order: kernel, then dependencies, then foreign. Order is semantic to ld, so it is preserved as published.

METHOD unitAt(int64 i) RETURNS | BuildUnit

CLASS BuildUnit

IMPLEMENTS Cloneable

epm/src/BuildUnit.ev:12

Constructors

INIT(String s, String l, String o)

Methods

METHOD clone() RETURNS Cloneable
METHOD sourcePath() RETURNS String
METHOD language() RETURNS String
METHOD objectPath() RETURNS String

CLASS Builder

epm/src/Builder.ev:8

Fields

Constructors

INIT(String compilerSetting, String selfPath)

Methods

MODIFY METHOD setForce(boolean on) RETURNS void
MODIFY METHOD setNoInline(boolean on) RETURNS void
MODIFY METHOD setQuiet(boolean on) RETURNS void

--quiet, forwarded rather than assumed.

epm used to pass --quiet to the compiler on every invocation. That is a decision about what the DEVELOPER sees, and epm was making it for them: --quiet suppresses info and warning output, so every warning the compiler raised was discarded before anyone could read it — including in run_smokes.sh, which builds the whole smoke corpus through epm. Errors were never affected, which is why this went unnoticed: the builds that failed still said why, and the ones that merely warned said nothing. Now it is off by default and passed through only when asked for.

MODIFY METHOD setExternalFlags(REFERENCE REFERENCE String[] compilerFlags, REFERENCE REFERENCE String[] linkerFlags) RETURNS void

The compiler and linker flags contributed by this target's external dependencies, resolved ONCE by Main and handed down.

Builder does not resolve them itself for two reasons. It has no view of the dependency graph — Main.buildTarget owns that — and resolving per module would re-run pkg-config and the installer query for every module in a graph, where the design says once per build.

The set is the UNION over the whole target graph, so a dependency may receive a flag only its sibling needed. That is benign and is already how manifest buildFlags behave, and narrowing it per module would mean carrying a separate graph per dependency for no gain anyone can observe.

METHOD findCompilerScript(String moduleFolder, String selfPath) RETURNS String

Locate the compiler, or return "" — never a guess.

The ladder, in order: 1. EV_COMPILER — an explicit override always wins. 2. Beside epm itself, then bin/evc walking up from epm's own directory. This is the rule the Makefile states: epm is installed into bin/ next to the compiler so one directory on $PATH gives both. It holds wherever the toolchain lives and does not care where the user's project is. 3. The module being built — for an in-repo module when epm was invoked by a bare name carrying no directory. 4. PATH.

It previously fell back to the BARE STRING "evc" and handed that to python3 unchecked, which resolved against the caller's cwd. In this repo bin/ is not on PATH, so every build died with a raw interpreter error naming a path nobody wrote: python3: can't open file '/Users/…/Envzn/evc': No such file or directory Returning "" instead lets the caller say what actually went wrong.

MODIFY METHOD buildViaPlan(String moduleFolder, boolean install, boolean optimize, boolean release, boolean force, String selfPath) RETURNS int32

Build a module through the PLAN: evc --emit-only for the front half, then compile and link from module_plan.json + evbuild.

Phase 7: this is the DEFAULT path, so the compiler's own compile/link half now has no callers left. EV_EPM_NATIVE_BUILD=0 still selects the old delegating invokeCompiler, which hands the whole build to evc — kept as the oracle this path is diffed against, and as the rollback.

MODIFY METHOD buildSingleFile(String evPath, boolean prod, String selfPath) RETURNS int32

Build ONE .ev FILE — evc --single, then this toolchain.

The compiler emits the C++ and publishes a plan; everything after that is the same path a module takes. Single mode used to do its own compile and link, composing a clang line from the compiler's own _CXX, _BASE_CXXFLAGS and kernel paths — so it was the one entry point a build system could not drive, and the one place a flag could differ from every other build without anything noticing.

It also linked with no -o, which is why a stray a.out appears in whatever directory the compiler ran from. The plan names the output.

When --single emits .ll instead of .cpp, nothing here changes: the unit's lang becomes "ll" and compilePrefix already has that arm.

MODIFY METHOD compileAndLink(REFERENCE REFERENCE BuildPlan plan, boolean install, boolean optimize, boolean force, String selfPath) RETURNS int32

Compile and link one loaded plan. The BACK HALF, shared.

A module and a --single file differ only in how their plan is produced: evc <folder> --emit-only versus evc --single <file> --emit-only, and where the plan lands. From here down they are the same object — units, include paths, link arguments, an output — which is the point of the plan being a description rather than a procedure. Sharing this is not a tidy-up: two copies would be two places for the flag order to drift, and the whole invariant is that argv is composed once. optimize, not prod: this half never emits, so posture cannot reach it. It selects the toolchain profile — -O3 -march=native versus -g — and nothing else.

MODIFY METHOD runProcess(String[] cmd) RETURNS boolean

Run one composed step. The argv LIST goes straight to Process->run, never a joined string, so no quoting rule is load-bearing.

MODIFY METHOD invokeCompiler(String moduleFolder, boolean install, boolean optimize, boolean release) RETURNS int32
METHOD getStr(JsonValue obj, String field) RETURNS String
MODIFY METHOD buildDev(String moduleFolder, boolean stage, boolean optimize, boolean release) RETURNS int32

stage asks the compiler to ALSO install this module's artifacts into lib/ (--install), which a library must do if anything is to link it later. Callers pass TRUE for a shared library and FALSE for an executable.

It is a parameter rather than always-on because it is only correct for a library: epm build in a user's application should not scatter that application into the toolchain's lib/.

This is what the deleted per-module Makefile targets did — every one of them passed --install — and omitting it made epm build --all look like it worked while the modules downstream quietly linked whatever an earlier make bootstrap had staged. A workspace build on a clean lib/ is the case that exposes it, and that is precisely the case --all exists to serve.

METHOD kernelDylibPath() RETURNS String

Where the kernel's libENVZN sits, or "" if it cannot be found. make install stages it into /lib beside the compiler, so it is reachable from epm's own location by the same reasoning discovery uses.

MODIFY METHOD buildDist(String moduleFolder, String[] depDylibs) RETURNS int32

Package a RELOCATABLE artifact.

The tarball used to hold the binary and the manifest and nothing else, while the binary's rpaths were absolute paths into the build machine's source tree:

path /Users/brian/Developer/Envzn/kernel
path /Users/brian/Developer/Envzn/evTest/build

So it could not run anywhere but the machine that produced it — the dylibs were missing AND the search paths pointed at someone else's disk. Fixing one without the other fixes nothing, which is why they land together.

The layout is a directory, not a bare binary, because the loader needs somewhere to look:

<name>-<version>/
  <name>            the executable
  <name>.json       the manifest
  lib/              libENVZN + every dependency dylib

@loader_path/lib is ADDED rather than replacing the absolute entries. dyld tries rpaths in order, so on the build machine the absolute ones still resolve and on any other machine they simply miss and fall through to the bundled directory. Deleting them would mean parsing otool -l output to learn what to delete; the cost of leaving them is that the artifact reveals the build tree's layout, which is worth recording but not worth that machinery here.

macOS only for now: install_name_tool is Darwin's. Linux is a v1 target and wants $ORIGIN/lib via patchelf — same shape, different tool, and the step is skipped rather than faked when the tool is absent.

CLASS Config

epm/src/Config.ev:7

Fields

Constructors

INIT()

Methods

MODIFY METHOD read(String filePath) RETURNS void
METHOD get(String key) RETURNS String

The value for key, or "" when the file did not carry it.

Used for external.<package>.prefix and its .include / .libdir siblings — rung 2 of the resolution ladder, and the place a PATH belongs, since a module author cannot know the layout of a machine they have never seen.

METHOD write(String filePath) RETURNS void

CLASS Externals

epm/src/Externals.ev:29

Constructors

INIT()

Methods

METHOD mergePlatform(REFERENCE REFERENCE JsonValue entry, String platform) RETURNS JsonValue

Flatten one entry's platforms block over its base fields.

The merge is FIELD BY FIELD, not whole-object replacement, so a package that differs between platforms only in its library name states only that. The spelling is taken from Go's #cgo darwin LDFLAGS: directives, where the conditional modifies the field rather than duplicating the declaration.

Implemented by building a new object rather than by reading the entry twice: copy every base field the platform block does not override, then append the platform's own. readEntry then has a single flat object and one field-reading pass, instead of the same IF-chain written twice.

METHOD readEntry(REFERENCE REFERENCE JsonValue flat) RETURNS ExternalDep

Read one already-flattened entry into a struct.

include and libdir are stored as written. Whether one is absolute is a question asked at the point of use, not a field carried around.

MODIFY METHOD parse(REFERENCE REFERENCE JsonValue root, String platform, MUTABLE REFERENCE MUTABLE REFERENCE ExternalDep[] out) RETURNS void

Read a manifest's whole external-dependencies array.

platform is passed in rather than detected here: this class does no I/O in Phase 8.1, which keeps it testable against a fixture without a machine probe. 8.2 adds the detection alongside the registry read.

A refused entry is SKIPPED and the walk continues, so a manifest with one bad declaration reports that one rather than going dark on the rest.

MODIFY METHOD validate(ExternalDep d, int64 index) RETURNS boolean

Whether d is a usable declaration. Refusals set .lastError; portability problems only add a note.

The character class on a package name is not cosmetic. The name becomes part of an environment-variable name at resolution time, so a separator or an expansion in it is an injection route (design §12.4).

METHOD validName(String n) RETURNS boolean

^[a-z][a-z0-9-]{0,63}$, checked without a character loop.

find_first_not_of SUCCEEDS at the first character outside the set, so a FAILURE means every character is allowed — the pipe-XOR reads backwards from the name, which is worth the comment.

MODIFY METHOD note(String msg) RETURNS void

Append a non-fatal problem for Main to print.

MODIFY METHOD refuse(String msg) RETURNS void

Record a refusal. Keeps the first one in lastError.

METHOD readStrings(REFERENCE REFERENCE JsonValue arr, MUTABLE REFERENCE MUTABLE REFERENCE String[] out) RETURNS void

Read a JSON array of strings.

A third copy of a helper that BuildPlan.ev and Toolchain.ev each keep privately. Promoting it is a separate cleanup; duplicating four lines is cheaper than a refactor that touches two working classes.

METHOD findBuildConfig(String selfPath) RETURNS String

Locate epm/build-config.json by walking up from the running binary.

Mirrors Toolchain.findEvbuild, which finds epm/tools/evbuild.py the same way, and for the same reason: epm may be invoked from anywhere and the registry lives beside its own source, not beside the caller.

MODIFY METHOD loadRegistry(String configPath) RETURNS void

Read the registry and the host platform from build-config.json.

A missing or unreadable file is NOT fatal: a package whose registry entry is absent can still resolve through the environment, the machine config or the standard directories, and a framework or system link needs no registry at all. Silence here becomes a located message later, from missingMessage, which is the right place for it.

METHOD registryFor(String name) RETURNS NativeDep

The registry entry for name, or an empty one.

METHOD resolvePrefix(ExternalDep d, REFERENCE REFERENCE Config cfg, String moduleFolder) RETURNS Resolution

Where d lives on this machine, and at what version.

Five rungs, first hit wins, every candidate checked by verifyPrefix before it is accepted. Discovery runs only for a library link whose form is not source: a framework, a system library and a vendored source tree have nothing to find.

The INSTALLER rung comes BEFORE pkg-config, deliberately. pkg-config answers with a version-exact path that a routine upgrade invalidates, while the installer answers with a symlink that survives one. The version is then read from pkg-config SEPARATELY, whichever rung supplied the prefix, because it is the only rung that knows one.

No rung carries a timeout: Process.run has none, so a wedged pkg-config wedges the build exactly as a wedged clang already does.

METHOD verifyPrefix(ExternalDep d, String prefix) RETURNS boolean

Whether prefix actually holds what d asked for.

The HEADER is checked first and that ordering is load-bearing: a machine carrying a library's runtime package but not its development headers otherwise passes here and then dies inside clang, which is the failure this design exists to replace with a sentence.

The library match is a PREFIX match on lib<name>. rather than an exact filename, because a real install is versioned — libssl.3.dylib, libpcre2-8.0.dylib. It looks in <libdir> and one directory below it, which is what accepts Debian's multiarch layout. There is no glob engine in the kernel or in epm, so this is listFiles plus prefix and suffix compares, the idiom Manifest already uses.

/usr and /usr/local are the documented escape: a header present there means the development package is installed, and therefore so is the library, wherever the distribution chose to put it.

METHOD dirFor(String prefix, String rel, String fallback) RETURNS String

The directory to search: rel when a manifest gave an ABSOLUTE one, else prefix/rel, else prefix/fallback.

An absolute value is used AS GIVEN and never appended to a prefix. Brian's decision of 2026-09-12 is that whatever a manifest hands us is what we use; joining it to a discovered prefix produced paths like /opt/homebrew/opt/pcre2//opt/homebrew/include, which verified against nothing and made an honest declaration unbuildable. validate still warns that such a manifest will not build on another machine.

METHOD libraryUnder(String dir, String libName, String form) RETURNS boolean

Whether dir, or any directory one level below it, holds lib<name>.* with the extension form implies.

METHOD extensionFits(String fileName, String form) RETURNS boolean

Whether a filename's extension matches the declared form.

form is the whole reason this is a field: -lfoo finds either a shared object or an archive depending on search order, so intent has to be stated rather than inferred.

METHOD versionFromPkgConfig(String pkgConfigName) RETURNS String

A package's version according to pkg-config, or "".

The ONLY rung that knows a version, which is why a version constraint is advisory: four of the five external dependencies in this repo yield nothing here.

METHOD versionFromHeader(String headerPath, String macro) RETURNS String

The version macro out of a vendored header, for form: source.

A vendored tree has no package manager to ask, so the header is the only witness. Reads #define <macro> "x.y.z" and returns what is between the quotes.

METHOD envNameOf(String name) RETURNS String

The package name as an environment-variable fragment: uppercased, with - mapped to _.

Safe because validate already refused anything outside [a-z][a-z0-9-]*, so no separator or expansion can reach here. There is no String.replace in the kernel, so the dash mapping is a split and a rejoin.

METHOD toolOnPath(String tool) RETURNS boolean

Whether tool is executable somewhere on PATH.

METHOD versionWarning(ExternalDep d, Resolution r) RETURNS String

A warning when the resolved version does not satisfy the constraint, or "" when it does, when there is no constraint, or when either side cannot be parsed.

WARNS, permanently, and never fails — Brian's decision of 2026-09-12. A constraint is documentation plus a diagnostic. Four of the five external dependencies in this repo yield no version at all, so a constraint that cannot be checked is the normal case rather than the exception, and an unparseable version is treated as absent rather than as a problem.

Reuses Semver, which already owns range parsing and comparison for package versions. A second comparator would be a second place for the same bug.

METHOD missingMessage(ExternalDep d) RETURNS String

What to say when a declared package could not be found.

Names the package, the constraint, every rung that was tried, and the install command for THIS platform. This is the message that replaces a clang header-not-found, so it has to carry everything a developer needs to act without reading the design document.

Installer names are PRINTED and never executed (design §12.3): epm does not install system libraries, on the evidence that Conan and vcpkg both do and neither displaced the platform package manager.

METHOD joinHeaders(REFERENCE REFERENCE String[] headers) RETURNS String

a, b for a header list, or the package's own name when it has none.

METHOD reportLine(Resolution r) RETURNS String

One line for the resolution report, printed before evc is invoked.

Four columns: package, version, location, how it was found. A dash where a version is genuinely unknowable, which is most of them. This is the whole of the design's disclosure position — the developer is the detector for a header-version mismatch, so the facts have to be in front of them on every build.

METHOD compilerFlags(ExternalDep d, Resolution r, MUTABLE REFERENCE MUTABLE REFERENCE String[] out) RETURNS void

The compiler flags one resolved external contributes.

An include flag only when the package declares headers, because a system link has nothing to include. Defines travel WITH the external rather than sitting in the module's own buildFlags, which is what lets a second module linking the same package inherit them instead of copying them.

METHOD linkerFlags(ExternalDep d, Resolution r, MUTABLE REFERENCE MUTABLE REFERENCE String[] out) RETURNS void

The linker flags one resolved external contributes.

A framework is a TWO-TOKEN pair and must stay one, which the existing flag de-dup already understands. A system library is a bare -l. A source package contributes nothing: its objects come from the existing nativeSources path and link like any other unit.

A static archive is passed as its resolved ABSOLUTE PATH, not as -L plus -l, because the darwin linker prefers a sibling .dylib on the same search path and would silently link the shared copy instead. This is the conclusion Cargo reached when it grew an explicit static link kind.

NO runtime search path is emitted. -Wl, is a denied flag pattern whose own rationale says rpath is driver-owned, and pkg-config hands back a version-exact prefix that a routine upgrade turns into a dangling entry. A prebuilt library already records its own install name, so the emission bought nothing.

METHOD archivePathFor(String dir, String libName) RETURNS String

The full path of lib<name>.a under dir or one level below, or "".

METHOD isPublic(ExternalDep d, REFERENCE REFERENCE String[] foreignHeaders) RETURNS boolean

Whether this external's headers reach a CONSUMER of the declaring module.

DERIVED, never declared. A module's emitted .hpp carries whatever its manifest lists under foreign.headers, spliced in unconditionally with no inlining or profile guard. So an external is public exactly when its own declared headers intersect that list, and no author can get it wrong by writing the answer down twice and disagreeing with themselves.

On this repo's corpus that makes OpenSSL public to Networking's consumers and Accelerate public to AdvancedMath's, while Regex's PCRE2 is private — Regex reaches it through a C++ shim rather than through a bound header, so nothing leaks.

METHOD readForeignHeaders(REFERENCE REFERENCE JsonValue root, MUTABLE REFERENCE MUTABLE REFERENCE String[] out) RETURNS void

A manifest's foreign.headers list.

MODIFY METHOD agree(REFERENCE REFERENCE ExternalDep[] all, REFERENCE REFERENCE String[] owners) RETURNS boolean

Whether every module declaring the same package agrees about it.

owners[i] is the module that declared all[i]. Several modules MAY declare one package — Cargo makes native-library ownership exclusive and that rule would reject the Networking-plus-Crypto graph this repo is heading for. What they may not do is disagree.

link and form must match, because the two produce incompatible command lines. And a package absorbed as static or source by more than one module in one graph is refused outright: both forms copy the library into a separate artifact, so the process would end up holding two copies of its global state, which is a correctness bug rather than wasted bytes. That is the one place Cargo's exclusivity instinct is right, and it is right precisely because the form is absorbing.

METHOD absorbing(String form) RETURNS boolean

Whether a form copies the library into the consuming artifact.

METHOD unionLibraries(REFERENCE REFERENCE String[] a, REFERENCE REFERENCE String[] b, MUTABLE REFERENCE MUTABLE REFERENCE String[] out) RETURNS void

The union of two declarers' library lists, ordered by the LONGER list.

Order is a link-time input, not a set: ssl depends on crypto, so a naive first-seen union that met a crypto-only declarer first would invert them and break a static link. Taking the order from the longer list keeps the declarer that knows the whole package authoritative about its sequence.

METHOD listHas(REFERENCE REFERENCE String[] items, String want) RETURNS boolean
MODIFY METHOD collectFromGraph(String rootFolder, REFERENCE REFERENCE ResolvedDep[] nodes, MUTABLE REFERENCE MUTABLE REFERENCE ExternalDep[] out, MUTABLE REFERENCE MUTABLE REFERENCE String[] owners) RETURNS void

Every external declared anywhere in one resolved graph, with the module that declared each.

The root module is collected SEPARATELY because Resolver.graphNodes holds only its transitive dependencies and not the module being built. Missing that is how a module's own declaration would have gone unread.

The design assigned this to Resolver. It lives here instead, because the ruling is that ONE class owns external dependencies and Resolver's job is resolving Envzn packages; a graph walk it already performs is cheaper to read from than to re-enter.

MODIFY METHOD collectFrom(String moduleFolder, MUTABLE REFERENCE MUTABLE REFERENCE ExternalDep[] out, MUTABLE REFERENCE MUTABLE REFERENCE String[] owners) RETURNS void

Append one module folder's declarations, tagged with its own name.

METHOD anyDeclared(REFERENCE REFERENCE ExternalDep[] all) RETURNS boolean

Whether any module in the graph declared an external at all.

Lets a build stay exactly as quiet as it is today until something is actually declared — the report is disclosure, not decoration.

METHOD linkedLibraries(String artifactPath, MUTABLE REFERENCE MUTABLE REFERENCE String[] out) RETURNS void

The absolute library paths a built artifact records, read out of the artifact itself.

The ARTIFACT is the source of truth here, not a record epm keeps. There is no lock file in this design, so there is nothing to go stale and nothing that can disagree with reality — the binary says what it will load, and this reads it with the platform's own tool.

@rpath and @loader_path entries are skipped: those are Envzn's own modules, resolved relative to the binary, and they are not external dependencies. What remains is the absolute install names a prebuilt library dictated, which is the thing that can be missing on another machine and the thing this design accepts as given rather than rewrites.

METHOD missingPaths(REFERENCE REFERENCE String[] paths, MUTABLE REFERENCE MUTABLE REFERENCE String[] gone) RETURNS void

Which of paths no longer exist on disk.

OS-OWNED PREFIXES ARE SKIPPED, and that is not a shortcut. Since macOS 11 the system libraries live in the dyld shared cache and have NO file on disk at all, so /usr/lib/libSystem.B.dylib is both perfectly loadable and perfectly absent. Testing it reported three false failures on the first run of this check. What matters here is the libraries a package manager installed, which are real files and really can move.

METHOD osOwned(String path) RETURNS boolean

Whether a path belongs to the operating system rather than to a package manager, and therefore need not exist as a file.

CLASS Fetcher

epm/src/Fetcher.ev:9

Fields

Constructors

INIT()

Methods

METHOD splitGitHubUrl(String gitUrl) RETURNS GitHubRepo
METHOD downloadTarball(String gitUrl, String tag, String cacheDir) RETURNS | String
METHOD listTags(String gitUrl) RETURNS | Tag[]
METHOD extractTarball(String tarballPath, String destDir) RETURNS STATUS
METHOD resolveSha(String gitUrl, String tag) RETURNS | String
METHOD resolveVersion(String gitUrl, String range) RETURNS | Tag

CLASS JsonParser

epm/src/JsonParser.ev:7

Fields

Constructors

INIT()

Methods

MODIFY METHOD parse(String source) RETURNS JsonValue
METHOD peek() RETURNS char32
MODIFY METHOD advance() RETURNS void
MODIFY METHOD skipWhitespace() RETURNS void
MODIFY METHOD parseValue() RETURNS JsonValue
MODIFY METHOD parseObject() RETURNS JsonValue
MODIFY METHOD parseArray() RETURNS JsonValue
MODIFY METHOD parseString() RETURNS JsonValue
MODIFY METHOD parseNumber() RETURNS JsonValue
MODIFY METHOD parseBool() RETURNS JsonValue
MODIFY METHOD parseNull() RETURNS JsonValue
METHOD emit(REFERENCE REFERENCE JsonValue v) RETURNS String
METHOD emitValue(REFERENCE REFERENCE JsonValue v) RETURNS String
METHOD emitObject(REFERENCE REFERENCE JsonValue v) RETURNS String
METHOD emitArray(REFERENCE REFERENCE JsonValue v) RETURNS String
METHOD emitString(String s) RETURNS String

CLASS JsonValue

IMPLEMENTS Cloneable

epm/src/JsonValue.ev:7

Constructors

INIT(String kind)

Methods

METHOD clone() RETURNS Cloneable
MODIFY METHOD setString(String v) RETURNS void
MODIFY METHOD setInt(int64 v) RETURNS void
MODIFY METHOD setBool(boolean v) RETURNS void
MODIFY METHOD addField(String name, REFERENCE REFERENCE JsonValue value) RETURNS void
MODIFY METHOD addItem(REFERENCE REFERENCE JsonValue value) RETURNS void
METHOD isObject() RETURNS boolean
METHOD isArray() RETURNS boolean
METHOD isString() RETURNS boolean
METHOD isNumber() RETURNS boolean
METHOD isBool() RETURNS boolean
METHOD isNull() RETURNS boolean
METHOD asString() RETURNS String
METHOD asInt() RETURNS int64
METHOD asBool() RETURNS boolean
METHOD hasField(String name) RETURNS boolean

CLASS LockFile

epm/src/LockFile.ev:7

Constructors

INIT()

Methods

MODIFY METHOD read(String filePath) RETURNS void
METHOD write(String filePath) RETURNS void
MODIFY METHOD addEntry(LockEntry e) RETURNS void
METHOD findByName(String name) RETURNS LockEntry

CLASS Main

IMPLEMENTS TaskStarter

epm/src/Main.ev:7

Constructors

INIT(String[] argv)

Methods

METHOD runBuild(String[] argv) RETURNS int32
METHOD isProdProfile(String variant) RETURNS boolean

Build every member of <wsName>-workspace.json, found by walking up from the current directory.

Members are a SET, not a sequence: each member's dependency graph is resolved and every module is built at most once, tracked by name in .builtNames. So Data listing mda and Regex as dependencies costs nothing extra when those are also members — whichever comes first wins and the rest are skipped. The file therefore never encodes build order, which is the accumulation problem the per-module make targets had. Does this variant compile with the PROD profile?

dist implies it — a package you hand someone is optimised or it is not worth handing over — and --prod asks for it directly. Everything else is dev. One predicate rather than four equals("dist") tests, because the question "is this optimised" was previously answered in four places and would have needed a fifth every time a caller was added.

METHOD isReleasePosture(String variant) RETURNS boolean

Does this variant emit in RELEASE posture?

A SECOND question, and the one that is easy to miss. Optimisation is a compile-line fact; posture changes what is EMITTED — non-always_on ASSERT is stripped and WHEN Build IS RELEASE becomes the live arm. -prod answers yes to both, which is right for something you ship and wrong for something you test: a corpus should run optimised code AND still exercise the assertions it exists to exercise.

optimized is exactly that gap. It is what the smoke suite had by accident before the profile became explicit, and it is now askable.

METHOD buildWorkspace(String wsName, String variant, boolean verbose, boolean force, boolean noInline, boolean quiet, int64 jobs, String selfPath) RETURNS int32
METHOD buildWorkspaceParallel(REFERENCE REFERENCE Workspace ws, String wsName, String variant, boolean verbose, boolean noInline, boolean quiet, boolean force, int64 jobs, String selfPath) RETURNS int32

Build the workspace in DEPENDENCY WAVES, several members at a time.

A wave is every not-yet-built member whose in-workspace dependencies are all built. mlearn names AdvancedMath and stats, so it cannot appear until both have; Json, mda, Regex and money name nothing and all appear in wave 1. The edges are read from each member's own manifest — the dependency tree is stated, never inferred from the member ORDER in the workspace file, which is just a list and carries no ordering promise.

Only in-workspace edges gate a wave. A dependency OUTSIDE the workspace is not something this run builds, so waiting on it would wait forever; it is resolved from lib/ exactly as the serial path resolves it.

Progress is guaranteed by construction: if a pass adds nothing while members remain, the remaining edges form a cycle (or name a member that does not exist), and that is reported rather than spun on.

METHOD cfgCacheDir(String rootDir) RETURNS String

The configured package-cache directory for a workspace root.

METHOD newestCompilerSource(String compilerScript) RETURNS int64

The newest mtime among the compiler's own .py sources, or -1 when the package cannot be located.

METHOD newestPythonIn(String dirPath, int32 depth) RETURNS int64

Newest .py mtime under dirPath, recursively. Depth-bounded for the same reason the compiler-discovery walk is: a fixed ceiling cannot spin on a filesystem that reports an odd tree.

METHOD lastBuiltAt(String moduleFolder) RETURNS int64

When this member was last built successfully, or -1 if never.

buildinfo.json is written by the compiler on every successful build, into the module's artifact dir — build/ for a src/-layout module, the module root for a flat one. It is the one file that means exactly "a build of this module finished", which is the timestamp rule 3 needs. When a build of this module last FINISHED, or -1 if it never has.

The .buildstamp first, because it is the only artifact both build paths write. buildinfo.json is written by the compiler's full path — after the link — and never by --emit-only, so on the plan path it does not come back after a sweep removes it. Reading it alone therefore answered "never built" forever: every run decided the compiler had moved, swept, rebuilt, and left the same answer behind for the next one. It kept working only while the sweep was broken and removed nothing.

buildinfo.json stays as the fallback for a tree built before stamps existed, and the root is checked after build/ for the flat layout.

METHOD buildSingleTarget(String evPath, String variant, boolean verbose, boolean noInline, boolean quiet, boolean force, String selfPath) RETURNS int32

Build ONE module folder: its dependency graph first, then the module. ledger records what this invocation has already compiled, so a module reached twice is built once (see BuildLedger). A target ending in .ev is ONE FILE, not a module.

epm build path/to/One.ev is the --single shape: no manifest, no dependencies, no staging. It is dispatched here rather than inside buildTarget because everything buildTarget does first — find the manifest, resolve the graph, check the lock file — presumes a module, and a single file has none of it.

METHOD isSingleFileTarget(String target) RETURNS boolean
METHOD buildTarget(String moduleFolder, String variant, boolean verbose, boolean force, boolean noInline, boolean quiet, String selfPath, MUTABLE REFERENCE MUTABLE REFERENCE BuildLedger ledger) RETURNS int32
METHOD sweepModule(String moduleFolder, boolean regenerable) RETURNS int32

Delete build artifacts directly inside dirPath. Returns the count.

Path->extension() returns the extension WITH its leading dot (".cpp") — pathSmoke asserts exactly that and Path.ev slices from the dot. This compared against "cpp" and so matched NOTHING: clean has never removed a source artifact. Both spellings are accepted, as Manifest already does, so the code is right whichever belief a reader arrives with. Sweep a MODULE — both places its artifacts can live.

A library emits into <module>/build/ and an executable emits beside its sources, so a sweep that looks in one place cleans half the layouts and reports a confident number for the half it saw. That already cost once: epm clean Data said "removed 0" while every .cpp, .o and .dylib sat in build/. The fix went into runClean and NOT into the compiler-changed sweep, which kept passing the module root alone — so "the compiler changed, cleaning everything" removed nothing at all, and a build that then found the module current printed build successful having done nothing. The message was the only part that worked.

Both callers go through here now, so there is no longer a version of this that can be given one directory. (2026-09-04.)

METHOD runPlan(String[] argv) RETURNS int32

epm plan <module> — the READ SIDE of phase 4.

Loads module_plan.json (what evc emitted and what it needs) and prints the commands epm WOULD issue. Nothing is executed, nothing is written: evc's own path is still the live one, so the printed argv can be diffed against what evc actually runs while both exist.

Toolchain and flag POLICY are NOT in the plan and are not read here. They come from epm/build-config.json via epm/tools/evbuild.py — the single definition make and epm share.

METHOD joinArgs(String[] args) RETURNS String

Render an argv list for display. Display only — the real executor will pass the list to Process->run, never a joined string, so no quoting rule is ever load-bearing.

METHOD runStep(String[] cmd) RETURNS boolean

Run one composed step. The argv LIST is passed straight to Process->run — never a joined string — so no quoting rule is ever load-bearing.

METHOD runClean(String[] argv) RETURNS int32

epm clean <module> · epm clean all [--workspace <name>] · epm clean --cache

A SUBJECT IS REQUIRED. It used to default to ".", so a bare epm clean swept artifacts, the binary and dist/ out of whatever directory you happened to be standing in — silently, and with no way to tell it was about to. (2026-09-04.)

all means EVERY MEMBER OF THE WORKSPACE, which is what it reads as. It used to be a literal alias for --cache, so epm clean all cleaned one folder plus the package cache — neither of the two things a reader would expect. The cache is now its own subject.

all is WORKSPACE-SCOPED, never repo-scoped, and never touches the bootstrap closure: kernel, Networking and epm belong to make clean, because epm links two of them and cannot build what it links. The two alls therefore do not overlap.

METHOD cleanOne(String moduleFolder) RETURNS int64

Sweep one module folder: artifacts, its build/ dir, the binary the manifest names, and dist/.

METHOD cleanCache(String anchor) RETURNS int32

The package cache — its OWN subject, no longer implied by all.

METHOD depDeclaredFlags(String depPath) RETURNS String[]

The build flags a resolved dependency declares. Empty when it declares none, or when its manifest cannot be found (which the resolver has already reported).

METHOD extractNameFromGitUrl(String gitUrl) RETURNS String
METHOD installRemote(PackageRef ref, String tag, String cacheDir) RETURNS | String
METHOD manifestMissing(String verb, String folder) RETURNS boolean

Whether folder has no readable module manifest — and if so, say so.

A folder with no manifest used to read as a module with ZERO dependencies, so epm install bogus printed 0 ok, 0 fetched, 0 failed and exited 0 — a typo, a wrong relative path and a clean install were indistinguishable (gh #294). The argument these verbs take is a module FOLDER, not a package name, so the message says that outright: reaching for epm install Regex is the natural wrong guess.

No $N: in these format strings — a colon straight after a placeholder is read as a format spec and swallowed (gh #289).

METHOD runInstall(String[] argv) RETURNS int32
METHOD runAddPath(String[] argv, String name) RETURNS int32

epm add <name> --path <dir> — a LOCAL dependency.

The remote form (<gitUrl>@<version>) was the only one, so a local dependency could only be added by hand-editing the manifest — and a local path dep is what every module in this repo actually uses.

The version is READ from the target's own manifest rather than typed by the user: the resolver matches a candidate's concrete version against the requested range, so a hand-typed version that disagrees with the target produces a resolution failure at install rather than an error here.

METHOD runAdd(String[] argv) RETURNS int32
METHOD runUpdate(String[] argv) RETURNS int32
METHOD runInit(String[] argv) RETURNS int32
METHOD runRemove(String[] argv) RETURNS int32
METHOD runList(String[] argv) RETURNS int32
METHOD runTree(String[] argv) RETURNS int32
METHOD printTreeNode(Resolver rs, String name, int32 depth) RETURNS void
METHOD printUsage() RETURNS void
METHOD printHelp() RETURNS void
METHOD describeJson(REFERENCE REFERENCE JsonValue v) RETURNS String

One-line rendering of a JsonValue, deep enough to show that a nested object actually parsed.

Not a JSON emitter — JsonParser.emit already is one. This exists to be EYEBALLED against a fixture, so an object prints as {k=v, k=v} rather than as re-indented JSON you then have to diff by hand.

No $N: in any format string here: a colon straight after a placeholder is read as a format spec and swallowed (gh #289).

METHOD dumpExternalDeps(String manifestPath, String platform, String selfPath) RETURNS int32

Phase 8.1 gate — read a manifest's external-dependencies through Externals.parse and print what came back.

platform defaults to darwin but is overridable as a third argument, which is the only way to exercise the LINUX arm of a platform block on this machine. The design's platform case is otherwise unverifiable until somebody runs a Linux build, so being able to prove the MERGE here is worth one optional argument.

Also prints foreign verbatim, because derived visibility is the intersection of that list with each entry's headers, and reading them side by side is how you check a fixture says what you meant.

No $N: in any format string — a colon straight after a placeholder is read as a format spec and swallowed (gh #289).

METHOD runExternalDeps(String[] argv) RETURNS int32

epm external-dependencies [<folder>] [--verify], short form epm externals.

Without --verify it resolves fresh and prints what each declared external came to on this machine — the same report a build prints, asked for on its own. Fresh every time, because there is no lock file: §2.3 of the design shows a recorded prefix is wrong in one direction or the other, so recording one would be a false-alarm generator or a no-op.

With --verify it also reads the built artifact and checks that every absolute library path it records still exists. That is the one thing the resolution report cannot tell you: a library can move out from under a binary that was linked correctly, and nothing rebuilds when it does, because staleness is timestamps over sources. Detection is this command; remediation is --force.

METHOD externalsEnabled() RETURNS boolean

Whether resolved external flags actually reach the compile and link lines. ON by default since the Regex migration; EV_EPM_EXTERNALS=0 opts out.

It was off while the mechanism was unproven, which is what let the two states be compared on one manifest: with buildFlags removed from Regex and the switch off, clang cannot find pcre2.h; with it on, the build succeeds. The declaration is load-bearing, so the default flipped.

The opt-out survives as the rollback, the same way EV_EPM_NATIVE_BUILD=0 still selects the pre-plan build path it replaced.

METHOD reportExternals(String moduleFolder, REFERENCE REFERENCE ResolvedDep[] nodes, String selfPath, REFERENCE REFERENCE Config cfg, MUTABLE REFERENCE MUTABLE REFERENCE String[] outCompiler, MUTABLE REFERENCE MUTABLE REFERENCE String[] outLinker) RETURNS int32

Print where every external in this graph resolved, before evc runs.

Prints NOTHING when no module in the graph declares one, so a build is as quiet as it was until something is actually declared. Unconditional otherwise: epm's --quiet is forwarded to the compiler and suppresses none of epm's own output, and disclosure is the point.

Returns non-zero when a declared package is missing or two modules disagree about one, because both are reasons not to start a compile.

METHOD parentFolderOf(String filePath) RETURNS String

The folder holding filePath, or "." when it has no parent component.

A form: source external resolves relative to the MODULE ROOT, so the fixture's own directory is what the in-tree lookup needs.

METHOD joinList(REFERENCE REFERENCE String[] items) RETURNS String

a, b, c for a printed list, or - when empty.

METHOD dispatch(String[] argv) RETURNS int32
METHOD start() RETURNS STATUS
METHOD runSemverTests() RETURNS int32
METHOD expectSat(Semver sv, String ver, String rng, boolean want) RETURNS boolean
METHOD expectCmp(Semver sv, String a, String b, int32 want) RETURNS boolean
METHOD expectSelect(Semver sv, String[] vers, String rng, String want) RETURNS boolean

CLASS Manifest

epm/src/Manifest.ev:7

Constructors

INIT()

Methods

METHOD findManifestPath(String folder) RETURNS String
MODIFY METHOD read(String filePath) RETURNS void
METHOD write(String filePath) RETURNS void
METHOD writeFiltered(String filePath, String excludeName) RETURNS void
MODIFY METHOD addDependency(PackageRef ref) RETURNS void
METHOD buildFlagList() RETURNS String[]

The module's declared build flags, as ONE list (#34 Layer B).

compiler and linker are concatenated deliberately. The split is meaningless for trust: the driver splices BOTH lists into the clang++ -c command line, so a flag hidden under "linker" reaches the compile step just the same. Consenting to them separately would invite exactly the one-character evasion Layer A had to close.

Order follows the DOCUMENT — whichever key appears first, and each flag as written. That is stable for a given manifest, which is what the consent record needs: the comparison is literal, so a reordering the developer did not make must not read as a change. (Re-ordering the keys in the manifest IS a change to what was approved, and correctly re-prompts.)

METHOD topLevelString(String key) RETURNS String
METHOD findByName(String name) RETURNS PackageRef

CLASS ModuleWorker

epm/src/ModuleWorker.ev:23

Constructors

INIT(String compilerSetting, String selfPath, boolean optimize, boolean release, boolean force, boolean noInline, boolean quiet)

Methods

MODIFY METHOD addItem(String folder, String name, boolean stage) RETURNS void
METHOD itemCount() RETURNS int64
METHOD claim(Channel[int64] work, Channel[BuildOutcome] out) RETURNS int64

Pull items until the work channel closes, building each one.

Returns how many it took, which the caller discards — the value exists so the call has a value to bind, because a bare void call is not a statement and a PARALLEL body can hold nothing else.

CLASS Resolver

epm/src/Resolver.ev:7

Fields

Constructors

INIT(String baseDir, String cacheDir)

Methods

MODIFY METHOD resolve(PackageRef ref, String anchorDir) RETURNS ResolvedDep
METHOD triedPathsFor(PackageRef ref, String anchorDir) RETURNS String[]
METHOD joinPath(String a, String b) RETURNS String
MODIFY METHOD resolveGraph(Manifest root) RETURNS STATUS
MODIFY METHOD visit(String requirer, PackageRef ref, String[] ancestors, String anchorDir) RETURNS STATUS
METHOD nodeIndex(String name) RETURNS int64
METHOD ancestorsContain(String[] ancestors, String name) RETURNS boolean
MODIFY METHOD validateCandidate(String folder, String name, String version) RETURNS boolean

CLASS Semver

epm/src/Semver.ev:14

Constructors

INIT()

Methods

METHOD parseVersion(String s) RETURNS | SemVersion
METHOD compare(SemVersion a, SemVersion b) RETURNS int32
METHOD parseRange(String s) RETURNS | VersionRange
METHOD satisfies(String version, String range) RETURNS | boolean
METHOD selectHighest(String[] versions, String range) RETURNS | String
METHOD makeMatchAll() RETURNS VersionRange
METHOD caretRange(PartialSpec p) RETURNS VersionRange
METHOD tildeRange(PartialSpec p) RETURNS VersionRange
METHOD parseComparatorSet(String s) RETURNS | VersionRange
METHOD contains(SemVersion v, VersionRange r) RETURNS boolean
METHOD parsePartial(String s) RETURNS PartialSpec
METHOD parseIntStrict(String s) RETURNS | int32
METHOD comparePre(String a, String b) RETURNS int32
METHOD isNumericIdent(String s) RETURNS boolean
METHOD numCompare(String a, String b) RETURNS int32
METHOD lexCompare(String a, String b) RETURNS int32
METHOD charIndex(String s, char32 c, int64 from) RETURNS int64
METHOD splitOn(String s, char32 sep) RETURNS String[]

CLASS Toolchain

epm/src/Toolchain.ev:21

Constructors

INIT()

Methods

METHOD isLoaded() RETURNS boolean
METHOD error() RETURNS String
METHOD profileName() RETURNS String
METHOD ccPath() RETURNS String
METHOD cxxPath() RETURNS String
METHOD libraryExt() RETURNS String
METHOD ldPath() RETURNS String

The PINNED linker. Resolved by evbuild via xcrun -f ld, never taken from PATH: a clang with no pinned toolchain links with whatever ld comes first, and on this machine that is Anaconda's ld64-530 (2022), which cannot parse the DWARF 5 both current clangs emit — so -g silently yields a binary whose debug info the system linker discards.

METHOD findEvbuild(String selfPath) RETURNS String

Locate epm/tools/evbuild.py. Mirrors Builder.findCompilerScript: anchor on EPM'S OWN LOCATION, never on the module being built — a user's project lives anywhere, and anchoring on it walks up to / and finds nothing.

MODIFY METHOD load(String selfPath, String profileWanted, String libFile) RETURNS boolean

Run evbuild config --json and take the resolved toolchain from it.

METHOD compilePrefix(String lang) RETURNS String[]

The compile argv prefix for one unit's language: driver, base flags, then the object flags (-c -fPIC). Include paths and the source come from the plan and are appended by the caller. The compiler and its flags for one unit's language.

ll returns COMPLETE and does not fall through to the shared objflags, which is the whole reason it is a named arm rather than a default. An .ll handed to the C++ arm compiles to a correct object and exits 0 — it does not fail — while silently dropping -MMD (unused on IR) and overriding the module's target triple with the host's. A wrong answer that returns success is the shape worth spending a branch on.

METHOD sonameArgsResolved() RETURNS String[]

-install_name @rpath/<lib> on Mach-O, -Wl,-soname,<lib> on ELF.

Already SUBSTITUTED by evbuild — epm asks for the library by name via --lib and receives finished arguments. Deliberately not done here: the governing invariant is that epm never CONSTRUCTS a compiler argument, and splicing a filename into a flag template is constructing one. It also keeps the {lib} token's meaning in exactly one place, where make's renderer already reads it.

CLASS Workspace

A WORKSPACE is a named set of module folders that epm build --all builds together, resolving their union graph and walking it in dependency order.

It exists to answer one question the toolchain Makefile no longer can: "rebuild everything after a git pull or an accidental rm -rf." The per-module make targets used to do that, and the workspace model deletes them: a module is built because it is a MEMBER here, never because someone added a target for it.

── Why the members are WRITTEN DOWN and not discovered ────────────────────── Discovery-by-glob was considered and rejected on evidence. The Envzn repo root holds ~45 directories of which ~15 are modules, and Manifest.findManifestPath accepts ANY .json carrying a "name" field — so probes/, datatest_spike/, compiler-battery/, detector-wiring/ and golden-compiler/ all look like modules to it. A workspace that discovered its members would build the graveyard. Cargo writes [workspace] members down for the same reason.

The list is a SET, deliberately unordered: epm resolves each member's graph and computes the build order. Encoding order in the file would reintroduce the accumulation problem the per-module make targets had.

── The file ───────────────────────────────────────────────────────────────── -workspace.json → { "members": ["Json", "evTest", "mda", …] }

The name is a PREFIX, not a constant: one repo may carry several workspaces (envzn-workspace.json for the language repo's own modules, data-science-workspace.json for the AdvancedMath→stats→mlearn chain that ships as a separate bundle). epm build --all uses "envzn"; --workspace <n> selects another.

Member paths are relative to the file's own directory, so a workspace is relocatable — the same file works in the repo and in an extracted bundle.

epm/src/Workspace.ev:39

Constructors

INIT()

Methods

MODIFY METHOD find(String startFolder, String name) RETURNS boolean

Locate <name>-workspace.json by walking UP from startFolder, so the command works from anywhere inside the tree rather than only at its root.

Bounded at 24 levels rather than "until the parent stops changing": a fixed ceiling cannot spin on a filesystem that reports an odd root. (Same shape as Builder.findCompilerScript, and for the same reason.)

MODIFY METHOD read(String path) RETURNS void

Read the members array from the workspace file at path. A missing file, a missing "members" key, or a non-string entry each yield no members — the caller reports "no members", which is the honest message either way.

METHOD memberPath(int64 index) RETURNS String

A member's folder as an absolute path — <rootDir>/<member>. Members are written relative to the workspace file so the set relocates with it.

INTERFACE ICommand

epm/src/interfaces.ev:7

Methods

METHOD run(String[] args) RETURNS int32

STRUCT ExternalDep

epm/src/structs.ev:124

Fields

STRUCT GitHubRepo

epm/src/structs.ev:55

Fields

STRUCT LockEntry

epm/src/structs.ev:21

Fields

STRUCT NativeDep

epm/src/structs.ev:159

Fields

STRUCT PackageRef

epm/src/structs.ev:9

Fields

STRUCT PartialSpec

epm/src/structs.ev:104

Fields

STRUCT Resolution

epm/src/structs.ev:142

Fields

STRUCT ResolvedDep

epm/src/structs.ev:44

Fields

STRUCT SemVersion

epm/src/structs.ev:72

Fields

STRUCT Tag

epm/src/structs.ev:61

Fields

STRUCT VersionRange

epm/src/structs.ev:83

Fields