Skip to content

Commit c0f8cf7

Browse files
committed
Enforce file-local module scopes and preserve reference identities
Resolve imported declarations in their defining scopes, enforce L76 import locality, and retain immutable generic alias obligations and direct global allocation identities. Qualified frozen source passed all release checks: 3756 tests and one expected failure, 51 examples in every profile, installed packages, site and dependency/security checks. Later documentation checks passed separately; V1 remains incomplete.
1 parent 78effef commit c0f8cf7

40 files changed

Lines changed: 2435 additions & 300 deletions

‎README.md‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,12 @@ instantiations. String comparison uses byte contents. Read the [language tour](e
9999
and [specification](docs/SPEC.md) together with [Status](docs/STATUS.md).
100100

101101
Local bindings cannot reuse an import alias from their own file. This includes
102-
function parameters, loop bindings and match captures. Full module namespace
103-
isolation remains unfinished.
102+
function parameters, loop bindings and match captures. Each file has its own
103+
namespace. Imported declarations use `alias.name`; top-level `_name` is private,
104+
and struct fields remain visible. Qualified types and struct literals retain
105+
their defining file's identity. Import aliases remain local to the file that
106+
declares them. See [qualification](docs/audits/2026-10-07/qualification.md) and
107+
[Status](docs/STATUS.md) for the source-specific verification boundary.
104108

105109
Use `usize` for sizes, lengths and indices. Use `isize` for signed pointer-sized
106110
offsets and position differences. A7 source recursion is rejected; use loops or
@@ -121,11 +125,16 @@ Heap values currently require `del` or `defer del`. Pass lvalues directly to
121125
public address-of or dereference operators. Heap fixed arrays, `new [N]T`, are
122126
rejected. Direct reference aliases share deletion state, and guarded reference
123127
fields are checked. Alias and lifetime checks remain incomplete for array
124-
elements and fields reached through joined allocation sets. Automatic memory
125-
management is not implemented.
126-
127-
The current stdlib provides `io` and `math`. Simple file imports share one
128-
emitted Zig file; full per-module scopes remain unfinished. Some accepted forms
128+
elements and fields reached through joined allocation sets. Known direct global
129+
reference stores now propagate allocation identity through calls, so deleting a
130+
stored allocation invalidates its caller aliases. Global nil-state and parameter
131+
proofs remain incomplete. Automatic memory management is not implemented.
132+
133+
The current stdlib provides `io` and `math`. File imports share one emitted Zig
134+
file after per-file semantic checking. Selected imports and `using import`
135+
remain incomplete. Forwarding another file's import alias is rejected under
136+
L76. Unsupported multi-dot type annotations and struct literals still fail
137+
parsing. Some accepted forms
129138
still fail later in compilation. The [gap list](docs/STATUS.md) records those
130139
cases and the remaining generic and numeric limits.
131140

‎a7/compile.py‎

Lines changed: 83 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ def compile_file_detailed(
510510
type_map = type_checker.node_types
511511

512512
if tc_ok:
513+
self._lower_file_module_bindings(ast, symbol_table)
513514
validator = SemanticValidationPass(
514515
symbol_table, type_checker.node_types
515516
)
@@ -1019,32 +1020,90 @@ def _combined_program_for_file_modules(
10191020
module_resolver: Any,
10201021
) -> ASTNode:
10211022
module_decls: list[ASTNode] = []
1022-
seen_paths: set[str] = set()
1023-
for module_info in loaded_modules:
1024-
if module_info.ast is None or module_resolver.is_virtual_module(module_info.path):
1025-
continue
1026-
# Key on the resolved file path, not the import spelling, so one
1027-
# file loaded under two spellings still merges once. Same-file
1028-
# double imports are rejected earlier as a compile error.
1029-
if module_info.file_path in seen_paths:
1023+
files = [(info.ast, info.file_path) for info in loaded_modules
1024+
if info.ast is not None and not module_resolver.is_virtual_module(info.path)]
1025+
files.append((ast, str(module_resolver.entry_root / "<entry>")))
1026+
written_names = set()
1027+
work = [program for program, _ in files]
1028+
while work:
1029+
node = work.pop()
1030+
if node.name:
1031+
written_names.add(node.name)
1032+
for value in vars(node).values():
1033+
if isinstance(value, ASTNode):
1034+
work.append(value)
1035+
elif isinstance(value, list):
1036+
work.extend(child for child in value if isinstance(child, ASTNode))
1037+
seen_paths = set()
1038+
for index, (program, path) in enumerate(files):
1039+
if path in seen_paths:
10301040
continue
1031-
seen_paths.add(module_info.file_path)
1032-
prefix = self._module_emit_prefix(module_info.path)
1033-
for decl in module_info.ast.declarations or []:
1034-
if decl.kind == NodeKind.IMPORT:
1035-
continue
1036-
if decl.kind == NodeKind.FUNCTION:
1037-
decl.module_emit_prefix = prefix
1041+
seen_paths.add(path)
1042+
for decl in program.declarations or []:
1043+
decl.file_scope = path
1044+
decl.module_index = index
1045+
decl.module_entry = program is ast
1046+
if decl.name and program is not ast:
1047+
module_path = Path(path).relative_to(module_resolver.entry_root).with_suffix("").as_posix()
1048+
identity = self._module_emit_prefix(module_path) + decl.name
1049+
while identity in written_names:
1050+
identity += "_"
1051+
written_names.add(identity)
1052+
decl.module_identity_name = identity
1053+
if decl.kind == NodeKind.IMPORT and not module_resolver.is_virtual_module(decl.module_path or ""):
1054+
module = module_resolver.get_module(decl.module_path, path if program is not ast else None)
1055+
decl.target_file_scope = module.file_path
10381056
module_decls.append(decl)
1039-
1040-
if not module_decls:
1041-
return ast
1042-
1043-
return ASTNode(
1044-
kind=NodeKind.PROGRAM,
1045-
declarations=module_decls + list(ast.declarations or []),
1046-
span=ast.span,
1047-
)
1057+
work = list(program.declarations or [])
1058+
while work:
1059+
node = work.pop()
1060+
vars(node).pop("file_module_call", None)
1061+
vars(node).pop("module_emit_prefix", None)
1062+
for value in vars(node).values():
1063+
if isinstance(value, ASTNode):
1064+
work.append(value)
1065+
elif isinstance(value, list):
1066+
work.extend(child for child in value if isinstance(child, ASTNode))
1067+
return ASTNode(kind=NodeKind.PROGRAM, declarations=module_decls, span=ast.span)
1068+
1069+
def _lower_file_module_bindings(self, program: ASTNode, symbols: Any) -> None:
1070+
# File scopes have already resolved every use. The later whole-program
1071+
# passes receive unique declaration keys in original emission order.
1072+
if not symbols.file_scopes:
1073+
return
1074+
work = list(program.declarations or [])
1075+
while work:
1076+
node = work.pop()
1077+
for value in vars(node).values():
1078+
if isinstance(value, ASTNode):
1079+
work.append(value)
1080+
elif isinstance(value, list):
1081+
work.extend(child for child in value if isinstance(child, ASTNode))
1082+
bound = symbols.resolved_uses.get(id(node))
1083+
target = bound.node if bound is not None else None
1084+
identity = getattr(target, "module_identity_name", None)
1085+
if identity:
1086+
node.source_name = node.name or node.field or node.struct_type
1087+
if node.kind == NodeKind.FIELD_ACCESS:
1088+
node.kind = NodeKind.IDENTIFIER
1089+
node.name = identity
1090+
node.object = None
1091+
node.field = None
1092+
elif node.kind in {NodeKind.IDENTIFIER, NodeKind.TYPE_IDENTIFIER, NodeKind.PATTERN_IDENTIFIER}:
1093+
node.name = identity
1094+
elif node.kind == NodeKind.STRUCT_INIT:
1095+
node.struct_type = identity
1096+
elif node.kind == NodeKind.PATTERN_ENUM:
1097+
node.enum_type = identity
1098+
if getattr(node, "module_identity_name", None):
1099+
node.source_name = node.name
1100+
node.name = node.module_identity_name
1101+
for scope in symbols.file_scopes.values():
1102+
for symbol in scope.symbols.values():
1103+
identity = getattr(symbol.node, "module_identity_name", None)
1104+
if identity:
1105+
symbols.global_scope.symbols[identity] = symbol
1106+
symbols.global_scope.symbols.update(symbols.current_scope.symbols)
10481107

10491108
def _backend_unsupported_feature_errors(
10501109
self,

‎a7/exact_constants.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,13 @@ def evaluate(root, lookup, bindings):
144144
if value is None:
145145
return None
146146
values[key] = value
147+
elif node.kind == NodeKind.FIELD_ACCESS and node.object is not None and node.object.kind == NodeKind.IDENTIFIER:
148+
symbol = lookup(f"{node.object.name}.{node.field}")
149+
value = bindings.get(id(symbol.node)) if symbol and symbol.node else None
150+
if value is None:
151+
return None
152+
values[key] = value
153+
node.exact_constant = value
147154
elif node.kind == NodeKind.UNARY and node.operator == UnaryOp.NEG:
148155
# `~` is not folded here: `flags & ~1` on a `u8` relies on
149156
# the typed operator, and -2 would not fit `u8`.

‎a7/formatters/console_formatter.py‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,14 @@ def _display_semantic(self, results: dict):
134134
sym_table.add_column("Name", style="yellow", width=20)
135135
sym_table.add_column("Kind", style="cyan", width=14)
136136
sym_table.add_column("Type", style="green")
137-
sym_table.add_column("Scope", style="dim", width=12)
137+
sym_table.add_column("Scope", style="dim", min_width=12, overflow="fold")
138138

139139
for sym in symbols[:30]: # Limit to 30 symbols
140140
sym_table.add_row(
141141
Text(sym.get("name", "?")),
142142
sym.get("kind", "?"),
143143
Text(sym.get("type", "?")),
144-
Text(sym.get("scope", "global")),
144+
Text(sym.get("scope", "global"), overflow="fold"),
145145
)
146146

147147
self.console.print(sym_table)
@@ -232,12 +232,19 @@ def _display_pipeline_summary(self, tokens, ast, semantic_results, codegen_resul
232232
def _collect_symbols(self, symbol_table) -> list:
233233
"""Collect symbols from a symbol table for display."""
234234
symbols = []
235-
scope = symbol_table.current_scope if hasattr(symbol_table, 'current_scope') else None
236-
if scope is None and hasattr(symbol_table, 'global_scope'):
237-
scope = symbol_table.global_scope
238-
235+
file_scopes = getattr(symbol_table, "file_scopes", {})
239236
visited = set()
240-
self._walk_scope(scope, symbols, "global", visited)
237+
if file_scopes:
238+
for filename, scope in file_scopes.items():
239+
start = len(symbols)
240+
self._walk_scope(scope, symbols, "global", visited)
241+
for row in symbols[start:]:
242+
row["scope"] = f"{filename}::{row['scope']}"
243+
else:
244+
scope = getattr(symbol_table, "current_scope", None)
245+
if scope is None:
246+
scope = getattr(symbol_table, "global_scope", None)
247+
self._walk_scope(scope, symbols, "global", visited)
241248
return symbols
242249

243250
def _walk_scope(self, scope, symbols: list, scope_name: str, visited: set):

‎a7/formatters/markdown_formatter.py‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
from datetime import datetime
9+
from html import escape
910
from typing import Optional, List, Dict
1011

1112
from .ast_walk import iter_children
@@ -135,7 +136,10 @@ def format_compilation_doc(
135136
lines.append("| Name | Kind | Type | Scope |")
136137
lines.append("|------|------|------|-------|")
137138
for sym in symbols:
138-
lines.append(f"| `{sym['name']}` | {sym['kind']} | `{sym['type']}` | {sym['scope']} |")
139+
scope = escape(sym["scope"], quote=False)
140+
for delimiter in "\\`*_[]|~":
141+
scope = scope.replace(delimiter, "\\" + delimiter)
142+
lines.append(f"| `{sym['name']}` | {sym['kind']} | `{sym['type']}` | {scope} |")
139143
lines.append("")
140144

141145
# Errors
@@ -242,11 +246,19 @@ def _format_ast_tree(self, node, lines: list, indent: int = 0) -> None:
242246
def _collect_symbols(self, symbol_table) -> list:
243247
"""Collect symbols from symbol table."""
244248
symbols = []
245-
scope = symbol_table.current_scope if hasattr(symbol_table, 'current_scope') else None
246-
if scope is None and hasattr(symbol_table, 'global_scope'):
247-
scope = symbol_table.global_scope
249+
file_scopes = getattr(symbol_table, "file_scopes", {})
248250
visited = set()
249-
self._walk_scope(scope, symbols, "global", visited)
251+
if file_scopes:
252+
for filename, scope in file_scopes.items():
253+
start = len(symbols)
254+
self._walk_scope(scope, symbols, "global", visited)
255+
for row in symbols[start:]:
256+
row["scope"] = f"{filename}::{row['scope']}"
257+
else:
258+
scope = getattr(symbol_table, "current_scope", None)
259+
if scope is None:
260+
scope = getattr(symbol_table, "global_scope", None)
261+
self._walk_scope(scope, symbols, "global", visited)
250262
return symbols
251263

252264
def _walk_scope(self, scope, symbols: list, scope_name: str, visited: set):

‎a7/parser.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,9 @@ def _expression_steps(self, action, payload):
13091309
if self.match(TokenType.IDENTIFIER):
13101310
type_name_token = self.advance()
13111311
type_name = type_name_token.value
1312+
if self.match(TokenType.DOT):
1313+
self.advance()
1314+
type_name += "." + self.consume(TokenType.IDENTIFIER, "Expected a qualified type name").value
13121315

13131316
# Check for generic parameters: Type(T1, T2, ...)
13141317
if self.match(TokenType.LEFT_PAREN):
@@ -1591,6 +1594,15 @@ def _expression_steps(self, action, payload):
15911594
# Identifiers, cast expressions, or struct literals
15921595
if self.match(TokenType.IDENTIFIER):
15931596
name = self.advance().value
1597+
if self.match(TokenType.DOT) and self.peek().type == TokenType.IDENTIFIER:
1598+
after = self.position + 2
1599+
close = self._close_at.get(after)
1600+
literal = self.tokens[after].type == TokenType.LEFT_BRACE or (
1601+
self.tokens[after].type == TokenType.LEFT_PAREN and close is not None
1602+
and self.tokens[close + 1].type == TokenType.LEFT_BRACE)
1603+
if literal and self._should_parse_struct_literal():
1604+
self.advance()
1605+
name += "." + self.advance().value
15941606

15951607
# Check for cast expression: cast(type, expr)
15961608
if name == "cast" and self.match(TokenType.LEFT_PAREN):
@@ -1999,10 +2011,15 @@ def _expression_steps(self, action, payload):
19992011
self.current(), self.filename
20002012
)
20012013
variant_identifier = self.advance()
2014+
enum_name = first_identifier.value
2015+
if self.match(TokenType.DOT):
2016+
self.advance()
2017+
enum_name += "." + variant_identifier.value
2018+
variant_identifier = self.consume(TokenType.IDENTIFIER, "Expected a qualified variant name")
20022019

20032020
return ASTNode(
20042021
kind=NodeKind.PATTERN_ENUM,
2005-
enum_type=first_identifier.value,
2022+
enum_type=enum_name,
20062023
variant=variant_identifier.value,
20072024
span=create_span_from_token(start_token),
20082025
)

‎a7/passes/name_resolution.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,12 @@ def visit_program(self, node: ASTNode) -> None:
9696
# then tell a constant declared below it from a new name: `case LIMIT:`
9797
# compares with LIMIT wherever LIMIT is declared in the file.
9898
for decl in node.declarations or []:
99+
if getattr(decl, "file_scope", None):
100+
self.symbols.select_file_scope(decl.file_scope)
99101
self._predeclare(decl)
100102
for decl in node.declarations or []:
103+
if getattr(decl, "file_scope", None):
104+
self.symbols.select_file_scope(decl.file_scope)
101105
self.visit_declaration(decl)
102106
root_stack = self.symbols.scope_stack
103107
while self._nested_functions:
@@ -110,6 +114,9 @@ def visit_program(self, node: ASTNode) -> None:
110114

111115
def _predeclare(self, node: ASTNode) -> None:
112116
"""Define a declaration in the current scope without visiting its body."""
117+
if node.kind == NodeKind.IMPORT and node.alias and node.alias.startswith("__") and self.symbols.current_scope.parent is None:
118+
self.add_error(SemanticErrorType.UNSUPPORTED_FEATURE, node.span,
119+
f"Top-level name '{node.alias}' is reserved for the compiler")
113120
described = {
114121
NodeKind.FUNCTION: (SymbolKind.FUNCTION, "Function", False),
115122
NodeKind.STRUCT: (SymbolKind.STRUCT, "Struct", False),
@@ -124,8 +131,14 @@ def _predeclare(self, node: ASTNode) -> None:
124131
kind, label, mutable = described
125132
fallback = "<unknown>" if node.kind in (NodeKind.CONST, NodeKind.VAR) else "<anonymous>"
126133
name = node.name or fallback
134+
if name.startswith("__") and self.symbols.current_scope.parent is None:
135+
self.add_error(SemanticErrorType.UNSUPPORTED_FEATURE, node.span,
136+
f"Top-level name '{name}' is reserved for the compiler")
127137
self._predeclared.add(id(node))
128138
symbol = Symbol(name=name, kind=kind, type=UNKNOWN, node=node, is_mutable=mutable)
139+
identity = getattr(node, "module_identity_name", None)
140+
if identity:
141+
self.symbols.identity_symbols[identity] = symbol
129142
if not self.symbols.define(symbol):
130143
self._rejected.add(id(node))
131144
self.add_error(SemanticErrorType.ALREADY_DEFINED, node.span, f"{label} '{name}'")

0 commit comments

Comments
 (0)