Modules, names, and visibility
Each Silk source file defines one module. A module's identity comes from its logical path relative to the compilation source root, and imports create explicit names for declarations in other modules. Imports are compile-time only: they do not run code or create values.
This page defines module identity, source lookup, import bindings, name collisions, and cross-module visibility. Declaration-specific contracts remain on their corresponding reference pages. Package acquisition and version selection are outside the language.
Terminology
- A source root is the logical directory from which project module identities are derived.
- A module identity is a case-sensitive, extensionless path such as
model/User. - An import path is the dotted source spelling of a module identity, such as
model.User. - A namespace binding is a local name that qualifies members of one imported module.
- A selected binding is one imported declaration available directly under a local name.
- An alias is an explicit local name replacing a namespace or selected binding's default name.
- A qualified name begins with a namespace binding or nominal module-scope binding, as in
User.make. - A nominal module scope is a struct, service, or interface name that also qualifies the public declarations of the module whose file basename it matches.
- A module scope is the set of top-level declaration and import names visible throughout one module.
- A module closure is the root module and every module reachable through its transitive imports.
Module identity and loading
MODULE-001 — One source file defines one path-identified module
Status: Confirmed
Each .silk source file is one module. Its canonical identity is its case-sensitive,
extensionless path relative to the compilation source root, written with / between path
segments.
<source root>/model/User.silk -> model/User
<source root>/app/Main.silk -> app/MainSource code names the same identities with dotted import paths:
import model.UserEvery segment is exact. model.User identifies model/User.silk; it does not find
model/user.silk, model/User/index.silk, or another extension.
Boundary: The identity is logical rather than an absolute host path. Absolute paths, source
extensions, empty segments, ., and .. are not valid module identities. A source supplied from
memory still has one explicit logical identity even when it has no filesystem path.
Diagnostics: An import whose exact module does not exist reports MOD0001 at the complete
import path. A compilation request whose root identity itself is noncanonical is a client
error rather than a Silk source diagnostic. A project module attempting to occupy the reserved
silk/ standard-library source space reports MOD0004.
Evidence: module closure, source resolution, module closure tests.
MODULE-002 — Source text does not declare or override module identity
Status: Confirmed
A Silk file contains declarations and imports but no module declaration. The source resolver assigns its identity before parsing the file.
// model/User.silk is already module model/User.
pub struct User { id: i32 }Moving or renaming the file changes the module identity and therefore the canonical identities of its declarations. Imports must name the new path.
Boundary: Module documentation, a namespace alias, or a declaration named User does not change
the containing module's identity. Two identical source texts supplied as model/User and
archive/User define distinct modules and distinct declarations.
Diagnostics: Silk has no module-declaration syntax. A declaration-like spelling attempting to
introduce one receives the ordinary lexer or parser diagnostic for unsupported syntax. An import
that still names the old path reports MOD0001.
Evidence: canonical module identity, syntax files.
MODULE-003 — Import paths resolve from the source root, not from the importing file
Status: Confirmed
Every project import is absolute within the compilation source root. The same import path identifies the same module no matter which module contains it.
<source root>/app/features/Profile.silk
<source root>/model/User.silk// app/features/Profile.silk
import model.UserThis resolves <source root>/model/User.silk. It does not begin lookup below
app/features/.
By default, the entry file's containing directory is the source root. A project or compiler client may select an explicit source root; a nested entry then keeps its path below that root as its module identity.
Boundary: Silk has no relative import spelling. An import cannot use . or .. to walk from
the importing module, and resolution does not probe parent directories or alternate file layouts.
Diagnostics: A syntactically valid path with no exact source reports MOD0001. A file that
exists but cannot be read produces an operational source-resolution failure rather than pretending
that the module is absent.
Evidence: filesystem source lookup, source resolver tests.
MODULE-004 — Compilation loads only the transitively reachable module closure
Status: Confirmed
Compilation begins with one root module and follows its imports transitively. Each reachable module identity is resolved and parsed at most once, even when several modules import it.
app/Main -> feature/Left -> shared/Value
-> feature/Right -> shared/Valueshared/Value belongs to the closure once. A source available to the resolver but unreachable from
app/Main is not part of that compilation merely because it exists below the source root.
Boundary: Loading a module makes its declarations available for static analysis; it does not mean that every function in that module is executable or included in the emitted program. Runtime reachability is a separate property beginning at the selected entry point.
Diagnostics: Each absent reachable target reports its import diagnostic. A damaged import path
retains its parser diagnostic without also requesting a fabricated module or emitting MOD0001.
One operational resolution failure does not erase successfully loaded modules, although the
compilation cannot commit its requested artifact.
Evidence: reachable module closure, module closure tests, executable closure.
MODULE-005 — Imports have no runtime behavior
Status: Confirmed
An import contributes names to its containing module's static scope. It does not execute the imported source, initialize runtime module state, construct an Effect, allocate storage, or change ownership.
import logging.Logger
pub fn main() -> i32 {
return 42
}The import alone performs no logging and creates no Logger value or service requirement.
Boundary: Calling an imported function, constructing an imported value, or mentioning an imported service in an Effect contract has the ordinary behavior of that declaration. The import itself contributes none of that behavior.
An import also does not implicitly re-export names or activate unlisted methods, operators, overloads, or runtime providers. Interface-conformance availability is defined with interface and specialization rules rather than by runtime import side effects.
Diagnostics: A valid but unused import is not a compiler error. Tooling may report a removable unused-import warning. Invalid bindings retain their import or name-resolution diagnostics even when no runtime expression uses them.
Evidence: behavior-neutral imports, module semantic surface.
MODULE-006 — Import cycles are valid and do not choose lookup order
Status: Confirmed
Distinct modules may import each other. The cycle itself is not an error because all reachable top-level declaration headers are known before function bodies resolve.
// a/A.silk
import b.B
pub fn main() -> i32 {
return B.answer()
}// b/B.silk
import a.A
pub fn answer() -> i32 {
return 42
}The modules form an import cycle, and A.main still resolves B.answer canonically.
Boundary: Valid import cycles do not make invalid type or value cycles safe. A recursively embedded nominal value with no indirection remains invalid under its value-type rules. Calling mutually recursive functions can also fail to terminate at runtime even though their names resolve.
Diagnostics: An import cycle alone emits no source diagnostic. Each declaration inside it still receives its ordinary signature, visibility, type, ownership, and control-flow diagnostics.
Evidence: module cycle facts, cycle-safe lookup, name-resolution cycle tests.
Import bindings
IMPORT-001 — A namespace import binds the target's final path segment
Status: Confirmed
A namespace import without an alias creates one local namespace binding. Its default name is the last segment of the imported module path.
import model.User
pub fn main() -> i32 {
let user = User.make(42)
return user.id
}model.User identifies module model/User and binds local namespace User. User.make then
looks up public member make in that module.
Boundary: The namespace is not a runtime value and does not import any member as an unqualified
name. make(42) is unresolved unless make is local or separately selected into scope.
Diagnostics: An unknown target reports MOD0001 at the import path. A valid namespace with no
public member named by a qualified lookup reports the unknown-member diagnostic for that member.
A private member reports SEM0015 rather than appearing unknown.
Evidence: explicit import bindings, namespace lookup tests.
IMPORT-002 — A namespace alias replaces the default local name
Status: Confirmed
as assigns an explicit local name to the imported module namespace.
import compiler.Syntax as Tree
pub fn main() -> i32 {
return Tree.parse()
}The module identity remains compiler/Syntax; only this importing module's local binding changes
from Syntax to Tree.
Boundary: The default name is not retained alongside the alias. Syntax.parse() is unresolved
unless another visible binding independently names Syntax. An alias does not rename the imported
module or any declaration it owns.
A redundant alias such as import compiler.Syntax as Syntax is valid under IMPORT-005. Tooling may
offer to remove the unchanged alias.
Diagnostics: A missing alias name receives the parser's missing-token diagnostic without a
fabricated binding. Binding collisions use the collision rule rather than silently replacing an
existing name. An unchanged alias is compiler-valid; the LSP may report LSP0002 and offer to
remove only the redundant alias clause.
Evidence: import aliases, alias tests.
IMPORT-003 — A selective import binds only its listed public members
Status: Confirmed
A selective import creates direct bindings for the declarations listed between braces. It does not also create the module's default namespace binding.
import compiler.Syntax { Node, node, parse as read }
pub fn main() -> i32 {
let value = node(42)
return read(move value)
}This binds public type Node as Node, constructor function node as node, and public function
parse as read. It does not bind namespace Syntax.
Boundary: Selection is explicit and non-recursive. Other public declarations in
compiler/Syntax do not become visible, and a selected declaration does not bring names used by
its implementation into the importing module.
Selecting a private declaration is distinct from selecting an unknown declaration: the former has a known but inaccessible candidate; the latter has no candidate with that name.
Diagnostics: An unknown selected member reports SEM0014 at that member. A private selected
member reports SEM0015. A local spelling claimed by another valid binding reports SEM0016
without choosing a winner.
Evidence: selected import bindings, selected import tests.
IMPORT-004 — A hybrid import may bind one namespace and selected members together
Status: Confirmed
One import may combine a namespace binding with selected-member bindings from the same target.
import compiler.Syntax as Tree { Node, node, parse }
pub fn main() -> i32 {
return parse(node(20)) + Tree.width(Tree.node(22))
}This binds namespace Tree and direct members Node, node, and parse. All bindings still
identify declarations owned by canonical module compiler/Syntax.
Boundary: The namespace alias affects only the namespace binding. It does not prefix or rename the selected members. Each selected member may use its own explicit alias when a different local name is needed.
The hybrid form is a compact way to request both namespace and selected bindings. IMPORT-006 also permits separate declarations naming the same target when that organization is clearer.
Diagnostics: Each binding is checked independently for unknown members, inaccessibility, and local collisions. Repeating the same canonical target is not itself a diagnostic.
Evidence: hybrid import bindings, hybrid syntax provenance.
Names and collisions
NAME-001 — Top-level declarations are visible throughout their defining module
Status: Confirmed
A top-level declaration may be named anywhere in its defining module, including from a declaration written earlier in the file. Source order does not control name availability.
pub fn main() -> i32 {
return answer()
}
fn answer() -> i32 {
return 42
}main resolves the later answer declaration. Every top-level declaration header is collected
before function bodies resolve.
Boundary: This rule applies to top-level declarations, not local values. A local let binding,
parameter, or pattern binding exists only in its lexical scope and cannot be used before its
declaration. Runtime initialization order is not inferred from top-level source order because
imports and declarations do not execute module initialization.
Diagnostics: A top-level reference with no matching local or imported declaration receives the ordinary unknown-name diagnostic for its position. It is not reported merely because the matching declaration occurs later in the file.
Evidence: declaration indexing, declaration index tests.
NAME-002 — One flat module namespace contains top-level declarations and imports
Status: Confirmed
Functions, nominal types, constants, services, interfaces, module namespace bindings, and selected imports claim names in one flat module scope. Two different bindings cannot use the same local spelling merely because they have different declaration kinds.
struct Token {}
fn Token() -> i32 { return 42 }The second Token is invalid. The language does not choose between a type meaning and a function
meaning based on the expression's expected kind.
Boundary: A namespace and one of its members may legitimately have the same spelling because
qualification keeps them at different lookup positions. With import model.User, User.User may
name public declaration User inside module namespace User. The two User components are not
competing bindings in one scope.
Generic parameters, function parameters, local bindings, fields, and pattern bindings have their own lexical or declaration-local scopes. Their collision rules are defined with those constructs.
Diagnostics: A repeated top-level declaration reports SEM0003 at the later declaration and
points to the first. Distinct import or declaration bindings claiming one module-scope spelling
report SEM0016 without selecting a winner.
Evidence: flat module namespace, canonical declaration index.
NAME-003 — A binding collision has no source-order winner
Status: Confirmed
When different valid declarations or imports claim the same local spelling, the name is unavailable until the source gives the bindings distinct names. Import order, declaration order, and declaration kind never select a winner.
import text.Parser { parse }
import binary.Parser { parse }
pub fn main() -> i32 {
return parse()
}Both imports claim parse, so the call resolves to neither function.
The same rule applies when an imported name collides with a declaration in the importing module:
import text.Parser { parse }
fn parse() -> i32 { return 42 }Boundary: PRELUDE-001 defines no lower-priority standard-library namespace tier. Language bindings retain their own reserved or collision rules; they do not let the module's explicit bindings shadow one another. Distinct lexical scopes may reuse a spelling only where the corresponding local binding rules allow it.
Diagnostics: Each colliding module-scope binding reports SEM0016 with the complete candidate
set available to diagnostics and tooling. A use through that spelling remains unavailable instead
of producing a second misleading lookup result.
Evidence: binding conflict diagnostics.
NAME-004 — Explicit aliases resolve imported-name collisions
Status: Confirmed
Give colliding namespaces or selected members distinct local aliases.
import text.Parser
import binary.Parser as BinaryParser
pub fn main() -> i32 {
return Parser.parse() + BinaryParser.parse()
}Both canonical modules retain their original identities. Only their local names differ.
Selected members may be aliased independently:
import text.Parser { parse as parseText }
import binary.Parser { parse as parseBinary }Boundary: Aliasing changes no declaration identity, type identity, visibility, ownership, or runtime behavior. It also does not create an overload set: two functions still need distinct local names when both would otherwise claim the same spelling.
Diagnostics: An alias that still collides with another binding reports SEM0016. A missing alias
name retains the parser diagnostic. An alias identical to the default spelling is the harmless
redundancy defined by IMPORT-005.
Evidence: explicit import aliases, namespace alias tests.
NAME-005 — A file-named struct or contract also scopes that module's public members
Status: Confirmed
A top-level struct, service, or interface doubles as its defining module's qualifier only when its
declaration name matches the module's final path segment after removing underscores and ignoring
case. For example, declaration UserProfile in module model/user_profile is that module's nominal
scope.
// model/user_profile.silk
pub struct UserProfile {}
pub fn answer() -> i32 {
return 42
}// app/main.silk
import model.user_profile { UserProfile }
pub fn main() -> i32 {
return UserProfile.answer()
}UserProfile.answer resolves the public function in model/user_profile. The same rule applies to
qualified type paths and follows the canonical declaration through a selected-import alias: an
alias changes the local qualifier spelling, not whether the declaration names its module.
Boundary: A different nominal declaration in the same file does not expose the module's
functions or types. For example, Provider.answer() is unavailable when Provider is declared in
model/user_profile; only UserProfile matches the basename. The comparison ignores only case and
underscores, not arbitrary punctuation or path segments.
Scalar enums are deliberately different: their qualifier is reserved for declared members and the
generated value operation, so even a file-named enum does not expose other top-level module
members. Service and interface operations remain operations of their declaring contract regardless
of the file name. The filename rule governs their access to other top-level module members. It never
turns a private member or an imported binding into a public re-export.
Diagnostics: An unknown type path through a valid nominal module scope reports SEM0014. An
unknown call through a struct, service, or interface qualifier—including a declaration that does
not name its module—reports the actor-operation diagnostic SEM0010 instead of searching that
declaration's defining module. A private module member remains inaccessible as SEM0015.
Evidence: nominal scope selection, call resolution, expression resolution, completion coverage.
Visibility
VIS-001 — Declarations are private by default and pub exposes them
Status: Confirmed
A top-level declaration without pub is private to its defining module. pub makes the declaration
eligible for access from another module through an explicit namespace or selected binding.
pub fn answer() -> i32 {
return hidden()
}
fn hidden() -> i32 {
return 42
}Both functions are available inside their defining module. Another module may import or qualify
answer but not hidden.
The same default applies to nominal types, scalar enums, constants, services, interfaces, and other
top-level declarations that support cross-module access. Struct fields have their own pub marker
under the same private-by-default principle. A scalar enum's pub marker exposes its type and
complete member set; individual enum members have no separate visibility marker.
Boundary: pub grants name accessibility; it does not import the declaration anywhere,
re-export it from an importing module, execute it, or weaken its type, Effect, ownership, or target
contract.
Diagnostics: No diagnostic applies merely because a declaration is private or unused. An
external attempt to select or qualify it reports SEM0015 at that use.
Evidence: visibility lookup, struct visibility, name-resolution tests.
VIS-002 — Private declarations remain fully visible inside their defining module
Status: Confirmed
Privacy is a module boundary, not a declaration-order or same-file restriction. Any declaration body in the defining module may name a unique private declaration.
fn normalize(value: i32) -> i32 {
return value
}
pub fn parse(value: i32) -> i32 {
return normalize(value)
}parse may call normalize even though callers from another module cannot.
Boundary: A nested module is not created by directory structure within one source file. Every source file has its own module boundary, so a neighboring file in the same directory does not gain private access.
Diagnostics: A valid same-module private reference receives no visibility diagnostic. A private declaration of the wrong kind still receives the ordinary kind or type diagnostic for its use.
Evidence: defining-module visibility, declaration index.
VIS-003 — Unknown and private imported members are different errors
Status: Confirmed
Lookup preserves whether an imported member is absent or exists but is private.
import model.User
User.missing() // no declaration named missing
User.secret() // declaration exists, but is privateThe first lookup is unknown. The second retains the private declaration as an inaccessible candidate without making it callable.
This distinction applies equally to namespace qualification and selective imports, and to values, types, constants, services, and interfaces.
Boundary: Privacy does not deliberately hide the fact that a declaration exists from compiler diagnostics or navigation metadata. It prevents semantic access. User-facing diagnostics should identify the inaccessible declaration without exposing private implementation details such as hidden field sets.
Diagnostics: An unknown selected or qualified member reports SEM0014. A known private member
reports SEM0015. The compiler does not fall back to another module or prelude declaration after
either failed lookup.
Evidence: visibility outcomes, private import tests.
VIS-004 — A public contract cannot expose a private nominal type
Status: Confirmed
Every nominal type appearing in a public declaration's externally visible contract must itself be public. This includes function parameters and results, public struct fields, and other published type positions.
struct Hidden {}
pub fn reveal(value: Hidden) -> Hidden {
return move value
}reveal is invalid because an importing module could name the function but could not name its
parameter or result type.
A public type may use private representation types behind private fields:
struct Storage { value: i32 }
pub struct Counter {
storage: Storage
}The private field does not enter Counter's externally accessible construction or projection
contract.
Boundary: A private declaration may freely mention other private declarations in its own contract. A public function body may also use private types internally as long as they do not escape through its published signature.
Diagnostics: Each private nominal type exposed by a public contract reports SEM0019 at the
type use and retains the referenced declaration identity. The compiler does not pretend the type is
unknown or silently make it public.
Evidence: nominal visibility, struct visibility specification, private exposure tests.
Redundancy, prelude, and publication
IMPORT-005 — Redundant aliases are semantically harmless
Status: Confirmed
An alias equal to the binding's default name does not change the program and should remain valid.
import model.User as User
pub fn main() -> i32 {
return User.answer()
}This has the same binding and runtime meaning as import model.User.
Boundary: An alias that claims another binding's spelling is not merely redundant; it is a real collision under NAME-003. A missing alias name remains malformed syntax.
Diagnostics: The compiler does not reject an unchanged alias. Language tooling may report a
non-blocking LSP0002 simplification warning and offer to remove as User.
Evidence: current alias policy, redundant alias tests.
IMPORT-006 — The same module may be imported more than once
Status: Confirmed
Import declarations are judged by the bindings they create, not by whether another declaration names the same target module. Distinct declarations may request separate noncolliding views of one module.
import model.User as UserApi
import model.User { User }
fn reset(user: User) -> User {
return UserApi.withId(move user, 0)
}Both imports identify canonical module model/User. The first binds namespace UserApi; the second
binds public type User directly.
Boundary: Repeated imports do not permit conflicting local names. Two declarations importing
different members as the same spelling still report SEM0016. Importing an identical binding twice
is an idempotent redundancy rather than a second declaration identity.
Diagnostics: Repeated targets alone do not produce a compiler error. Tooling may suggest
combining compatible imports into one hybrid declaration or removing an exact duplicate. The
LSP uses LSP0001 for an exact duplicate and LSP0003 when declarations can be consolidated.
Evidence: current repeated-target restriction, duplicate import tests.
IMPORT-007 — Reserved words may appear in import paths but cannot become implicit bindings
Status: Confirmed
When the parser expects an import-path segment, it accepts identifier and reserved-word tokens without changing their lexical classification. A reserved final segment must use an explicit legal namespace alias or a selected-member list so the import never creates a binding whose spelling is otherwise reserved.
import silk.effect { Effect, suspend }Reserved segments in any nonfinal position are ordinary parts of the canonical module identity. Every module-closure, name-resolution, formatting, and tooling consumer observes the same ordered path segments.
Boundary: Contextual acceptance is limited to import paths. A reserved word remains invalid as a
declaration name or explicit alias. import silk.effect is invalid because its implicit namespace
binding would be named effect; selected-member imports such as import silk.effect { Effect }
are valid because they state legal bindings explicitly. A legal explicit namespace alias remains
valid under IMPORT-002 when a caller specifically needs the module namespace.
Diagnostics: A reserved final segment without an alias or selected-member list reports
PAR0004 at that segment and explains that the import needs an explicit binding form. The parser
retains the complete path for recovery, but name resolution does not synthesize a reserved binding.
Evidence: import syntax requirements, name-resolution requirements, contextual path parser tests, canonical path consumer tests, explicit completion behavior.
PRELUDE-001 — Only language bindings are implicit
Status: Confirmed
Foundational type spellings, language syntax, and the sealed Intrinsic namespace are available
without imports. Ordinary standard-library actor namespaces are not. A module imports every
standard-library API it names.
import silk.option { Option }
pub fn main() -> i32 {
let value = Option.some<i32>(42)
drop value
return 42
}The type spelling i32 needs no import because it is part of the language. Option.some needs the
explicit Option actor binding because Option is an ordinary standard-library actor. Because
the actor matches the module filename, that selected declaration also qualifies the module's public
operations under NAME-005. The same rule applies to Effect, Vector, Result, filesystem
services, target providers, and primitive actor operations:
import silk.effect { Effect }
import silk.i32Effect<A ! E ? R> remains language type syntax. The selected actor binding Effect names the
matching nominal declaration and qualifies ordinary standard-library functions such as
Effect.provide.
Boundary: The toolchain may resolve the reserved silk/ source origin differently from project
files, but that packaging privilege does not inject its declarations into every module scope.
Tooling may add missing imports automatically; auto-import is a source edit, not invisible lookup.
A foundational type spelling does not create an ordinary nominal declaration in the module scope.
Therefore import silk.i32 may bind actor namespace i32 while type positions continue to use the
closed language spelling i32; selecting the matching Effect actor behaves similarly beside
Effect type syntax. This does not create general separate type and value namespaces for user
declarations.
Diagnostics: Naming an unimported standard-library namespace uses the ordinary unknown-name diagnostic. Catalog-backed completion may add a visible, collision-aware import edit.
Evidence: current prelude tier, standard-library namespace tests, foundational type names.
EXPORT-001 — Imports do not re-export declarations
Status: Confirmed
An import creates bindings only in the importing module. Those bindings are not members that a third module can import through it.
// api/Public.silk
import model.User
pub fn makeUser(id: i32) -> User.User {
return User.make(id)
}Another module may access Public.makeUser, but it cannot access Public.User merely because
api/Public imported model/User.
Silk currently has no explicit re-export declaration. In particular, pub import is unsupported:
pub import model.UserBoundary: A module may expose ordinary public wrapper functions whose contracts use public types from another module. Those types retain their original canonical identities. Wrapping does not create a type alias or re-exported namespace.
Explicit re-export syntax is deferred until Silk's native library and package model is designed. At that point it must state exactly which declarations become members, preserve their canonical identities, define collision behavior, and avoid turning every ordinary import into a public API commitment.
Diagnostics: Attempting pub import receives a parser diagnostic because no such declaration
exists. Looking up an importing module's private import binding from another module reports an
unknown member rather than following the import transitively.
Evidence: non-re-exporting imports, module semantic surfaces.
Implementation evidence
The compiler closes modules only through parsed imports, keeps catalog declarations out of source scope, accepts harmless import redundancy, and preserves collision diagnostics for genuinely different bindings. The LSP indexes the catalog independently, inserts explicit collision-aware imports, and owns optional redundancy warnings and consolidation actions. Repository examples, fixtures, tests, and generated documentation use the same explicit-import model.
Re-exports remain deferred: pub import is unsupported and ordinary imports are not exported.