21. Project manifest
A Hanki project is a directory containing a manifest file hanki.config.hk plus one or more .hk source modules. The manifest is itself a Hanki file - top-level meta-const declarations carry the configuration values:
# hanki.config.hk
name = "todo"
version = "0.1.0"
entry = "src/main.hk"
description = "A simple todo CLI"
In a .config.hk file the bare IDENT = EXPR form is sugar for IDENT: <inferred-type> = EXPR; the type is inferred from the RHS. The typed form IDENT: TYPE = EXPR still parses here too, for cases where inference would land on the wrong type (port: u16 = 8080 vs an inferred i32). Either way, the meta keyword on the RHS is rejected as redundant - same rule as §16.
Trailing top-level expression. A .config.hk file may end with a single bare expression that is the value of the file - Dhall-style. Embedder hosts (Rust, Go, Python apps using Hanki as their config language) consume this value as "the thing the file evaluates to":
# foo.config.hk (host imports a schema and instantiates it)
open schema
default_port: i32 = 8080
MyConfig(name="todo", port=default_port)
The trailing expression sees every binding declared above it, can call any pure def or meta def in scope (including across imported .config.hk files), and is itself evaluated under the meta-eval budget. It must be the last thing in the file - any further declaration after it is a parse error. Bindings above it still populate the file's meta-const map, so a host can read both the trailing value and individual named fields from the same file.
Regular .hk files reject a bare top-level expression - top-level expressions are gated on the .config.hk suffix. The project manifest (hanki.config.hk) is the canonical embedder caller and uses only named bindings; it does not need a trailing expression. The trailing-expression form exists for hosts that prefer the "file IS a typed value" mental model over the "file is a bag of fields" model.
Recognised fields:
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | ✓ | project name |
version | string | ✓ | semver string |
entry | string | ✓ | path (relative to project root) to the module that defines main! |
description | string | one-line description | |
hanki | string | the single toolchain/language-version constraint (covers core + extra, which share one version); absent ⇒ the compiler's own version | |
deps | List<Dep> | contrib + universe dependencies (absent/[] ⇒ a leaf package); see Dependencies below | |
<name> | string | a top-level binding whose name matches a Dep.source is that universe dependency's git-URL binding, and its use alias (§17) |
hanki check . reads hanki.config.hk from the current directory, runs it through the standard parse → type-check → meta-evaluate pipeline, and confirms the entry file together with every module it imports (transitively) lowers. hanki build . does the same and then AOT-compiles the entry to a native binary; hanki run . does the same and then invokes the entry file's main on the bytecode interpreter. Manifest errors use the same file:line:col: error: msg form as any other Hanki diagnostic, so a typo in the manifest reads exactly like a typo in any other source file.
Dependencies (frozen 1.0 contract). The manifest declares dependencies through the baked pkg module (§17, tier 1: core) — a data module (like path) providing two constructible structs, so a manifest builds them as ordinary struct literals. Hanki has no map/tuple literals, so dependencies are a List<Dep>, not a {name: constraint} map:
open pkg
name = "todo"
version = "0.1.0"
entry = "src/main.hk"
hanki = "^1.4" # the ONE toolchain/language-version
# constraint (covers core + extra)
http = "git.sr.ht/~user/http" # a universe dep's git-URL binding;
# doubles as its `use http` alias (§17)
lexer = "path:../front" # a local path dep: a sibling project
# directory, resolved live (never locked)
deps = [
Dep(source = "money", version = "^2.0"), # contrib: curated flat name
Dep(source = "http", version = "^1.2"), # universe: `source` names the
# top-level URL binding above
Dep(source = "lexer", version = "^0.1"), # path: `source` names the
# `path:` binding above
]
struct Dep { source: string, version: string }.sourceis a contrib flat name or a short name with a matching top-level binding; binding presence and shape select the tier — apath:-prefixed binding (lexer = "path:../front") makes it a local path dep, any other binding is its universe git URL, and no binding means contrib — with no cross-source fallthrough.Depcarries no alias field — the alias is the top-level binding (§17).versionis a constraint (below).- Path dependencies compose sibling project directories (
tools/sitereachingtools/front) without collapsing them into one namespace. Thepath:suffix resolves relative to the project root and must name a project directory (its ownhanki.config.hk); the declared constraint is checked against that target's version, anduse <alias>binds the target's<alias>.hkpublic module — several aliases may point into one target directory (lexer+highlight→../front). A path dep is workspace-local source, never locked: no lockfile entry, no content pin, no integrity gate on local edits, and--frozenresolves it live exactly like a normal build (a project whose deps are all path deps needs no lockfile at all). Dependency resolution runs underhanki build,run,check,test, and the standalonehanki resolve; a single-file entry is governed by ahanki.config.hkin its own directory when one exists (same-dir only, no upward walk), so a secondary tool file beside a project manifest composes too. - Version constraints are caret-default SemVer strings:
^1.2.0(compatible,>=1.2.0,<2.0.0),~1.2(patch-level,>=1.2.0,<1.3.0),>=1.2,<2(explicit range, comma = intersection),1.2.0(exact). Core/extra move together under the singlehankifield (a core break bumps major, an extra break minor); contrib/universe carry independent SemVer perDep. - Lockfile —
hanki.lock.config.hk. A generated, committed, human-diffable text.config.hkfile (not binary): itopen pkgs and bindslocked = [Locked(...)], wherestruct Locked { source: string, version: string, hash: string, effects: List<string> }records, per resolved dependency, its resolved identity (git URL, ortier:name@version), exact version, content pin (hash), and approved transitive world + user-effect surface. Re-resolving recomputes each effect surface; a surface that grew past the lockfile'seffectsfails resolution by default — approval is editingeffectsin the diff (shrinkage is always free). Thehashcontent pin is checked the same fail-by-default way: re-resolving a dependency's same version to a different commit hash — a moved upstream tag — fails resolution rather than silently re-locking to the new content, so a build cannot be re-pointed at tampered content behind a stable version. A genuine SemVer bump (the constraint resolving to a new version) re-locks freely; an intentional same-version re-lock means first deleting that entry. The lockfile is text precisely so that approval is reviewable. The separate release-time vcs-value cache (cachingvcs.sha()/describe()so a git-less tarball still builds) is a distinct sibling file sharing the.lock.config.hkfamily, not folded into this dependency lock. - Publish descriptor.
hanki publishderives a canonical, deterministic postcard descriptor from the source manifest so a registry indexes a package without meta-evaluating its source; it carries the resolved deps, the effect surface, and an extensible region of derivedhanki scoreboardGuarantees (a reader ignores Guarantee tags it does not know, so the catalog grows without a format break). The human-authoredhanki.config.hkstays the source of truth; the descriptor is derived at publish and client-verifiable against the content-addressed source.
These wire shapes are frozen for 1.0 (docs/design/package-manifest-schema.md). The resolver has landed for all three dep tiers, transitive across the whole graph — hanki build/run/check/test resolve each declared universe dep's version constraint against the package's git tags, fetch the selected version into the content-addressed cache, then resolve that package's own manifest the same way, recursing over the closure; each contrib dep against the toolchain's seeded contrib/<name>/ source (one shipped version per package); and each path dep against its live local project directory (a workspace-local tier: a fetched or seeded package may not declare path: bindings) — and import each dep's resolved source (§17). One version per source, graph-wide: when several packages constrain the same source, the selected version is the highest satisfying all of their constraints (SemVer-max over the intersection); an empty intersection is a hard error naming every requirer and its constraint — no backtracking solver, no override table, no version duplication — and a dependency cycle is likewise rejected. Each resolved package's own use binds the aliases its manifest declares, never the consuming project's. The whole closure is recorded flat in the lockfile (one Locked entry per resolved package, path deps excluded), and each re-resolved pin is verified against any committed one, so a moved-tag hash change (universe) or a same-version seeded-tree change (contrib) fails by default (above). A contrib pin is the reserved contrib:<name>@<version> identity with a deterministic fnv1a: tree hash (the seeded source has no git). hanki build --frozen resolves the same closure entirely offline — from the committed lockfile plus the warm cache for universe deps (a transitive dep's manifest is read from its warm slot), the hash-verified seeded source for contrib deps, and live local source for path deps, fetching nothing and rewriting nothing — and fails on a cache miss, a missing lockfile (when any dep carries a pin), a contrib tree that no longer matches its pin, or a pin the graph's constraints no longer satisfy (a stale lock), for CI and hermetic, reproducible builds. The package-manager CLI verbs have landed on top of this contract: hanki resolve locks the graph standalone (--frozen verifies the committed lock offline), hanki add <source> edits the manifest — contrib by default, a --git URL / --path DIR binding selecting the universe / path tier — and re-parses the result so a malformed edit never lands, and hanki publish derives the postcard descriptor from the exported API and validates it locally (printing the readable form, or --outputing the encoded bytes). The effect-growth gate has landed: each re-resolve recomputes every dependency's exported effect surface (the union of its public API's effect rows, the "package effect manifest") into the lockfile's effects, and a surface that grew past the committed set — the leftpad-style effects [] → [net] on a patch bump — fails resolution by default, naming the package and the new effect(s); shrinkage is always free, and approval is adding the new effect to that dependency's effects in the lockfile diff (the lock is text precisely so approval is reviewable) — or, equivalently, hanki resolve --accept-effects '<source>=<eff>[,<eff>]', which writes that same widened surface into the lock in one command (the growth error names both forms; --accept-effects cannot combine with --frozen, which never re-locks). Only the hosted registry upload remains planned tooling built on top of this contract; the manifest-reading side and the lockfile/descriptor formats are what these fields fix.
Imports and multi-file layout. A module imports a sibling file with use NAME (qualified) or open NAME (unqualified - drops the module's items into the importing file's scope bare). Both resolve to NAME.hk in the same directory as the importer. Every top-level item kind - def / def name! / actor / struct / type (including each variant constructor) / trait / impl / effect / meta const - lives at its module-qualified name (option.Option, option.Some, parse.parse_command!, todos.Todos). Cross-file references either spell the qualified form (parse.parse_command!(line), spawn todos.Todos, option.Some(v)) or rely on open NAME to drop the bare form into scope. Within the defining file, an item's own bare name still works - the workspace Rewriter splices bare → qualified at merge time, so parse.hk's tests call parse_command! without the prefix. Two use imports each exporting a sum-type variant named Same do not collide (they live at a.Same and b.Same); two opens introducing the same bare name make it ambiguous - a bare use through the pair is an error (H0306, §14), and either export stays reachable via its qualified form. The resolver walks the import graph rooted at entry, detects cycles, and type-checks each reachable file with its dependencies' exports pre-populated in the env. Multi-segment paths like use std.io are reserved for a future module-path scheme and are rejected today. Stdlib modules (io, list, option, display, eq, str) bypass the sibling-file resolution - the embedded stdlib owns them, and use io / open option work without an on-disk file.
The basename hanki.config.hk is reserved by the compiler for the manifest; it is not importable as a user module.
The manifest as a capability host. Evaluating the manifest is the one place the build tool acts as a capability host: the commands that read it (hanki check/build/run/test over a project directory) inject a vcs capability so the manifest can read version-control facts from git at build time. The vcs effect (stdlib extra/vcs.hk) exposes sha() -> string (the commit sha) and describe() -> string (a git describe --tags --always --dirty string); like every effect op, they are open-only, so the manifest writes open vcs and then version = describe(). hanki fmt never evaluates the manifest, so it stays pure and needs no git. Reproducibility cache. In a git checkout the values resolve fresh from git each build, so a repo build always reflects current state. A released source tarball has no .git, so the values would be unresolvable — for that case hanki build --refresh-manifest re-resolves every vcs op the manifest uses and writes them to a git-tracked hanki.vcs.lock.config.hk (a <op> = "value" .config.hk file); a later build in a git-less tree reads the cached values from it instead of failing. The cache is a separate sibling file in the .lock.config.hk family, distinct from the dependency lock (hanki.lock.config.hk, Dependencies above), so the security-reviewed dependency lock stays single-purpose and the vcs cache is free to evolve. Where neither git nor a cached value is available, the op is an unsatisfied capability naming the missing value and the --refresh-manifest recipe, never a silent default.
The .config.hk suffix. hanki.config.hk is the canonical example of a broader convention: any Hanki file whose role is to declare data rather than define runtime behaviour uses the .config.hk suffix. Such files share the grammar, parser, type checker, and effect system with regular .hk modules - but a strict-mode checker pass rejects items that don't belong in a declarative file: actor declarations, def name! actions (including inside impl blocks), and test blocks. The convention is both a tooling signal - globs like **/*.config.hk and editor recognition come for free - and a compile-time guarantee. Renaming a file from .hk to .config.hk is a meaningful refactor: it changes which items are accepted. Rejecting imports of non-.config.hk modules from a .config.hk file is a planned extension to the strict-mode pass - the import resolver knows the imported file path, but the suffix-based veto is not yet wired up.
Worked example. examples/v0_1/config_dsl/ is the convention end to end: app.config.hk opens the host entry module, computes its fields (a derived port, a mapped list) rather than restating literals, and ends in the AppConfig value; main.hk binds it with CONF: AppConfig = config.load("app.config.hk") and reads it like any other const. Nothing reads the file at runtime - editing it changes the binary.
Bare name = expr in .config.hk only. Regular .hk files require the type annotation - a top-level identifier with no type is ambiguous in a module that mixes declarations and code - so the bare binding form is gated on the .config.hk suffix. The meta keyword is rejected on the RHS of every top-level binding (canonical or sugared): there is exactly one way to spell each shape.
Injectable effects - the config capability interface. A .config.hk value (a binding's RHS or the trailing expression) may invoke user-declared effect ops (§6) even though it is otherwise a pure position: the embedding host supplies each op's behavior as an injected capability, so a config can say token = secrets_lookup("api") and let the host decide what that means. Three rules keep this safe:
- Only user effects are injectable. Anything carrying a built-in ambient effect (
io/fs/net/db/process/time/env/random, athrows,Crash) is rejected in a config value (H0614) - OS authority is denied at the API boundary, not opted into by the file. (Declaring actions, actors, tests, orprovideblocks in a config is already a strict-mode error.) - A config needs no
provideblock for the effects it uses - the host is the provider - so the §6 one-provider completeness rule is waived for.config.hkfiles. The set of user-effect atoms a config references with no in-module provider is its capability interface: what a host must inject to evaluate it (exposed to tooling asrequired_capabilities). - Evaluating a config that calls an op with no installed capability fails with
UnsatisfiedCapability- never silently.