Skip to content

Commit 4b4f681

Browse files
mkanatclaude
andcommitted
Wire universe_locations into Validator for cross-universe reference diagnostics
When the compiler encounters a cross-universe FQUN reference to an unconfigured universe, it now emits an ExternalUniverseNotConfiguredDiagnostic instead of raising NotImplementedError. The local deps config is passed as an immutable mapping through the validation frame so it can vary per sub-project. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent da363eb commit 4b4f681

6 files changed

Lines changed: 117 additions & 6 deletions

File tree

define/compiler/config.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Project configuration loading and validation."""
22

3+
import types
34
from pathlib import Path, PurePosixPath
45

56
import protovalidate
@@ -51,13 +52,16 @@ def project_config() -> config_pb2.ProjectConfigFile:
5152
return _load_config(CONFIG_PATH, config_pb2.ProjectConfigFile)
5253

5354

54-
def local_deps_config() -> dict[str, PurePosixPath]:
55+
_EMPTY_DEPS: types.MappingProxyType[str, PurePosixPath] = types.MappingProxyType({})
56+
57+
58+
def local_deps_config() -> types.MappingProxyType[str, PurePosixPath]:
5559
"""Load and validate the local dependency overrides from the current directory.
5660
57-
Returns a mapping from universe name to relative path.
61+
Returns an immutable mapping from universe name to relative path.
5862
"""
5963
if not LOCAL_DEPS_PATH.exists():
60-
return {}
64+
return _EMPTY_DEPS
6165
result = _load_config(LOCAL_DEPS_PATH, local_pb2.LocalDepsFile)
6266
deps: dict[str, PurePosixPath] = {}
6367
for dep in result.deps.local:
@@ -67,4 +71,4 @@ def local_deps_config() -> dict[str, PurePosixPath]:
6771
[f'deps.local: duplicate universe_name "{dep.universe_name}"'],
6872
)
6973
deps[dep.universe_name] = PurePosixPath(dep.path)
70-
return deps
74+
return types.MappingProxyType(deps)

define/compiler/diagnostics.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,16 @@ class ReferencedFileNotFoundDiagnostic(Diagnostic):
301301

302302
file_path: str
303303
message_format: ClassVar[str] = "there is no file '{file_path}' in this project"
304+
305+
306+
@dataclass
307+
class ExternalUniverseNotConfiguredDiagnostic(Diagnostic):
308+
"""Diagnostic for when a cross-universe reference targets an unconfigured universe."""
309+
310+
universe: str
311+
current_universe_name: str
312+
message_format: ClassVar[str] = (
313+
"universe '{universe}' is not configured as a dependency "
314+
"of this universe ({current_universe_name}); "
315+
"add it to .define/deps/local.defcl"
316+
)

define/compiler/driver.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def validate_program(
3939
return validator.Validator().parse_and_validate_program(
4040
path=resolved_path,
4141
expected_universe_name=self.project_config.project.universe_name or "",
42+
universe_locations=config.local_deps_config(),
4243
)
4344

4445
@staticmethod

define/compiler/driver_integration_test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,9 @@
207207

208208
# Key: path relative to PROJECTS_ROOT / "invalid" (as posix string)
209209
EXPECTED_PROJECT_DIAGNOSTICS: dict[str, list[type[diagnostics.Diagnostic]]] = {
210+
"global_name_walk/cross_fqun_missing_universe": [
211+
diagnostics.ExternalUniverseNotConfiguredDiagnostic,
212+
],
210213
"global_name_walk/missing": [diagnostics.ReferencedFileNotFoundDiagnostic],
211214
"global_name_walk/wrong_type": [
212215
diagnostics.ReferencedGlobalNameWrongTypeDiagnostic,

define/compiler/validator.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
from collections import ChainMap, OrderedDict
88
from dataclasses import dataclass
99
from functools import cached_property
10+
from typing import TYPE_CHECKING
11+
12+
if TYPE_CHECKING:
13+
from collections.abc import Mapping
1014

1115
from lark import exceptions as lark_exceptions
1216

@@ -125,6 +129,7 @@ class _ValidationFrame:
125129
diagnostics: list[diagnostics.Diagnostic]
126130
expected_definition_path: pathlib.PurePosixPath | None
127131
expected_universe_name: str | None
132+
universe_locations: Mapping[str, pathlib.PurePosixPath]
128133

129134

130135
class Validator:
@@ -138,6 +143,7 @@ def __init__(self):
138143
self._seen_global_definitions: dict[str, ast.QualityDefinition] = {}
139144
self._validation_frames: list[_ValidationFrame] = []
140145
self._reference_not_found_paths: set[pathlib.PurePosixPath] = set()
146+
self._unknown_universes: set[str] = set()
141147

142148
@cached_property
143149
def _parser(self) -> parser.Parser:
@@ -148,9 +154,14 @@ def parse_and_validate_program(
148154
self,
149155
path: pathlib.PurePath,
150156
expected_universe_name: str | None = None,
157+
universe_locations: Mapping[str, pathlib.PurePosixPath] | None = None,
151158
) -> list[ValidationResult]:
152159
"""Parse, transform, and validate all reached files from one entrypoint."""
153-
self._parse_validate_and_collect(path, expected_universe_name)
160+
self._parse_validate_and_collect(
161+
path,
162+
expected_universe_name,
163+
universe_locations if universe_locations is not None else {},
164+
)
154165
return [
155166
result
156167
for result in self.results_by_path.values()
@@ -164,7 +175,8 @@ def parse_and_validate_program(
164175
def _parse_and_validate_file(
165176
self,
166177
path: pathlib.PurePath,
167-
expected_universe_name: str | None = None,
178+
expected_universe_name: str | None,
179+
universe_locations: Mapping[str, pathlib.PurePosixPath],
168180
) -> ValidationResult:
169181
"""Parse, transform, and validate one Define file."""
170182
# Ensure Windows-style paths are converted to POSIX paths for
@@ -201,13 +213,15 @@ def _parse_and_validate_file(
201213
program=program,
202214
expected_definition_path=expected_definition_path,
203215
expected_universe_name=expected_universe_name,
216+
universe_locations=universe_locations,
204217
)
205218
return run.complete(validation_diagnostics)
206219

207220
def _parse_validate_and_collect(
208221
self,
209222
path: pathlib.PurePath,
210223
expected_universe_name: str | None,
224+
universe_locations: Mapping[str, pathlib.PurePosixPath],
211225
) -> None:
212226
"""Parse/validate one file once and append its result in encounter order."""
213227
logical_path = pathlib.PurePosixPath(path.as_posix())
@@ -217,6 +231,7 @@ def _parse_validate_and_collect(
217231
result = self._parse_and_validate_file(
218232
path=logical_path,
219233
expected_universe_name=expected_universe_name,
234+
universe_locations=universe_locations,
220235
)
221236
self.results_by_path[logical_path] = result
222237

@@ -245,6 +260,7 @@ def validate(
245260
program: ast.Program,
246261
expected_definition_path: pathlib.PurePosixPath | None = None,
247262
expected_universe_name: str | None = None,
263+
universe_locations: Mapping[str, pathlib.PurePosixPath] | None = None,
248264
) -> list[diagnostics.Diagnostic]:
249265
"""Validate all semantic rules and return collected diagnostics.
250266
@@ -258,11 +274,17 @@ def validate(
258274
expected_universe_name: Optional FQUN string from the project config.
259275
When provided, validates that each definition's FQUN matches this
260276
value. When None, skips FQUN matching validation.
277+
universe_locations: Mapping from universe name to local path for
278+
configured external dependencies. Defaults to no configured
279+
dependencies.
261280
"""
262281
frame = _ValidationFrame(
263282
diagnostics=[],
264283
expected_definition_path=expected_definition_path,
265284
expected_universe_name=expected_universe_name,
285+
universe_locations=universe_locations
286+
if universe_locations is not None
287+
else {},
266288
)
267289
self._validation_frames.append(frame)
268290
try:
@@ -444,6 +466,23 @@ def _load_global_name_reference(
444466
reference = typed_global_name.global_name
445467
expected_type = typed_global_name.type_name
446468
if reference.fqun is not None:
469+
canonical = reference.fqun.canonical
470+
if canonical not in self._frame.universe_locations:
471+
if canonical not in self._unknown_universes:
472+
expected = self._frame.expected_universe_name
473+
if expected is None:
474+
raise ValueError(
475+
"expected_universe_name must be set for cross-universe references"
476+
)
477+
self._unknown_universes.add(canonical)
478+
self._diagnostics.append(
479+
diagnostics.ExternalUniverseNotConfiguredDiagnostic(
480+
position=reference.fqun.position,
481+
universe=canonical,
482+
current_universe_name=expected,
483+
)
484+
)
485+
return
447486
raise NotImplementedError(
448487
"Global-reference file walking for FQUN references is not implemented."
449488
)
@@ -453,6 +492,7 @@ def _load_global_name_reference(
453492
self._parse_validate_and_collect(
454493
path=referenced_file,
455494
expected_universe_name=self._frame.expected_universe_name,
495+
universe_locations=self._frame.universe_locations,
456496
)
457497

458498
referenced_result = self.results_by_path[referenced_file]

define/compiler/validator_test.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,3 +809,53 @@ def test_local_name_position(self):
809809
assert len(ln_diags) == 1
810810
assert ln_diags[0].position.line == 2
811811
assert ln_diags[0].position.column == 23
812+
813+
814+
class TestCrossUniverseReference:
815+
def test_unknown_universe_emits_diagnostic(
816+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
817+
):
818+
source = (
819+
"define the potential position<mv:define-lang.org:my_universe:/test> {\n"
820+
"it may only contain dimension points where {\n"
821+
"it has the position<other.example.com:other_universe:/target>.\n"
822+
"}\n"
823+
"}\n"
824+
)
825+
source_path = tmp_path / "test.def"
826+
_ = source_path.write_text(source, encoding="utf-8")
827+
monkeypatch.chdir(tmp_path)
828+
results = validator.Validator().parse_and_validate_program(
829+
Path("test.def"),
830+
expected_universe_name="mv:define-lang.org:my_universe",
831+
)
832+
assert len(results) == 1
833+
diags = results[0].diagnostics
834+
assert len(diags) == 1
835+
assert isinstance(diags[0], diagnostics.ExternalUniverseNotConfiguredDiagnostic)
836+
assert diags[0].universe == "other.example.com:other_universe"
837+
assert diags[0].current_universe_name == "mv:define-lang.org:my_universe"
838+
839+
def test_duplicate_unknown_universe_emits_one_diagnostic(
840+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
841+
):
842+
source = (
843+
"define the potential position<mv:define-lang.org:my_universe:/test> {\n"
844+
"it may only contain dimension points where {\n"
845+
"it has the position<other.example.com:other_universe:/target>.\n"
846+
"it has the position<other.example.com:other_universe:/another>.\n"
847+
"}\n"
848+
"}\n"
849+
)
850+
source_path = tmp_path / "test.def"
851+
_ = source_path.write_text(source, encoding="utf-8")
852+
monkeypatch.chdir(tmp_path)
853+
results = validator.Validator().parse_and_validate_program(
854+
Path("test.def"),
855+
expected_universe_name="mv:define-lang.org:my_universe",
856+
)
857+
assert len(results) == 1
858+
diags = results[0].diagnostics
859+
assert len(diags) == 1
860+
assert isinstance(diags[0], diagnostics.ExternalUniverseNotConfiguredDiagnostic)
861+
assert diags[0].universe == "other.example.com:other_universe"

0 commit comments

Comments
 (0)